Files
superport_v2/lib/features/approvals/presentation/pages/approval_page.dart
JiWoong Sul 753f76e952 feat(menu-permissions): 메뉴 API 연동으로 사이드바 권한 정비
- .env.development.example과 lib/core/config/environment.dart, lib/core/permissions/permission_manager.dart에서 PERMISSION__ 폴백을 view 전용으로 좁히고 기본 정책을 명시적으로 거부하도록 재정비했다

- lib/core/navigation/*, lib/core/routing/app_router.dart, lib/widgets/app_shell.dart, lib/main.dart에서 메뉴 매니페스트·카탈로그를 도입해 /menus 응답을 캐싱하고 라우터·사이드바·Breadcrumb가 동일 menu_code/route_path를 쓰도록 리팩터링했다

- lib/core/permissions/permission_resources.dart와 그룹 권한/메뉴 마스터 모듈을 menu_code 기반 CRUD 및 Catalog 경로 정합성 검사로 전환하고 PermissionSynchronizer·PermissionBootstrapper를 확장했다

- test/helpers/test_permissions.dart, test/widgets/app_shell_test.dart 등 신규 구조를 반영하는 테스트·골든과 doc/frontend_menu_permission_tasks.md 문서를 보강했다
2025-11-12 18:29:03 +09:00

863 lines
33 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart' as intl;
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../../core/config/environment.dart';
import '../../../../core/navigation/route_paths.dart';
import '../../../../core/permissions/permission_manager.dart';
import '../../../../core/permissions/permission_resources.dart';
import '../../../../widgets/app_layout.dart';
import '../../../../widgets/components/feedback.dart';
import '../../../../widgets/components/filter_bar.dart';
import '../../../../widgets/components/superport_dialog.dart';
import '../../../../widgets/components/superport_table.dart';
import '../../../../widgets/components/superport_pagination_controls.dart';
import '../../../../widgets/components/feature_disabled_placeholder.dart';
import '../../domain/entities/approval.dart';
import '../../domain/repositories/approval_repository.dart';
import '../../domain/repositories/approval_template_repository.dart';
import '../../domain/usecases/get_approval_draft_use_case.dart';
import '../../domain/usecases/list_approval_drafts_use_case.dart';
import '../../domain/usecases/save_approval_draft_use_case.dart';
import '../../../inventory/lookups/domain/repositories/inventory_lookup_repository.dart';
import '../../../inventory/shared/widgets/employee_autocomplete_field.dart';
import '../controllers/approval_controller.dart';
import '../dialogs/approval_detail_dialog.dart';
const _approvalsResourcePath = PermissionResources.approvals;
/// 결재 관리 최상위 페이지.
///
/// 기능 플래그에 따라 실제 화면 또는 비활성 안내 화면을 보여준다.
class ApprovalPage extends StatelessWidget {
const ApprovalPage({super.key, this.routeUri});
final Uri? routeUri;
@override
Widget build(BuildContext context) {
final enabled = Environment.flag('FEATURE_APPROVALS_ENABLED');
if (!enabled) {
return AppLayout(
title: '결재 관리',
subtitle: '결재 요청 상태와 단계/이력을 한 화면에서 확인합니다.',
breadcrumbs: const [
AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath),
AppBreadcrumbItem(label: '결재', path: '/approvals/requests'),
AppBreadcrumbItem(label: '결재 관리'),
],
actions: [
Tooltip(
message: '백엔드 연동 후 사용 가능합니다.',
child: ShadButton(
onPressed: null,
leading: const Icon(lucide.LucideIcons.plus, size: 16),
child: const Text('신규 결재'),
),
),
],
child: const FeatureDisabledPlaceholder(
title: '결재 관리 기능 준비 중',
description: '결재 API 연결이 완료되면 실제 결재 요청 목록과 단계 정보를 제공합니다.',
),
);
}
return _ApprovalEnabledPage(routeUri: routeUri);
}
}
/// 결재 기능이 활성화되었을 때 사용되는 실제 페이지 위젯.
class _ApprovalEnabledPage extends StatefulWidget {
const _ApprovalEnabledPage({this.routeUri});
final Uri? routeUri;
@override
State<_ApprovalEnabledPage> createState() => _ApprovalEnabledPageState();
}
class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
late final ApprovalController _controller;
final TextEditingController _transactionController = TextEditingController();
final TextEditingController _requesterController = TextEditingController();
final FocusNode _transactionFocus = FocusNode();
InventoryEmployeeSuggestion? _selectedRequester;
final intl.DateFormat _dateTimeFormat = intl.DateFormat('yyyy-MM-dd HH:mm');
String? _lastError;
String? _lastAccessDeniedMessage;
String? _pendingRouteSelection;
@override
void initState() {
super.initState();
_pendingRouteSelection = _parseRouteSelection(widget.routeUri);
_controller = ApprovalController(
approvalRepository: GetIt.I<ApprovalRepository>(),
templateRepository: GetIt.I<ApprovalTemplateRepository>(),
lookupRepository: GetIt.I.isRegistered<InventoryLookupRepository>()
? GetIt.I<InventoryLookupRepository>()
: null,
saveDraftUseCase: GetIt.I.isRegistered<SaveApprovalDraftUseCase>()
? GetIt.I<SaveApprovalDraftUseCase>()
: null,
getDraftUseCase: GetIt.I.isRegistered<GetApprovalDraftUseCase>()
? GetIt.I<GetApprovalDraftUseCase>()
: null,
listDraftsUseCase: GetIt.I.isRegistered<ListApprovalDraftsUseCase>()
? GetIt.I<ListApprovalDraftsUseCase>()
: null,
)..addListener(_handleControllerUpdate);
WidgetsBinding.instance.addPostFrameCallback((_) async {
await Future.wait([
_controller.loadActionOptions(),
_controller.loadTemplates(),
_controller.loadStatusLookups(),
]);
await _controller.fetch();
_applyRouteSelectionIfNeeded(
_controller.result?.items ?? const <Approval>[],
);
});
}
@override
void didUpdateWidget(covariant _ApprovalEnabledPage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.routeUri != widget.routeUri) {
_pendingRouteSelection = _parseRouteSelection(widget.routeUri);
final currentResult = _controller.result;
if (currentResult != null) {
_applyRouteSelectionIfNeeded(currentResult.items);
}
}
}
void _handleControllerUpdate() {
final error = _controller.errorMessage;
if (_controller.isAccessDenied) {
final message = _controller.accessDeniedMessage ?? '결재를 조회할 권한이 없습니다.';
if (mounted) {
if (_lastAccessDeniedMessage != message) {
SuperportToast.warning(context, message);
_lastAccessDeniedMessage = message;
}
final router = GoRouter.maybeOf(context);
router?.go(dashboardRoutePath);
}
_controller.acknowledgeAccessDenied();
return;
} else {
_lastAccessDeniedMessage = null;
}
if (error != null && error != _lastError && mounted) {
_lastError = error;
SuperportToast.error(context, error);
_controller.clearError();
}
}
/// 라우트 쿼리에서 선택된 결재번호를 읽어온다.
String? _parseRouteSelection(Uri? routeUri) {
final value = routeUri?.queryParameters['selected']?.trim();
if (value == null || value.isEmpty) {
return null;
}
return value;
}
/// 최초 로딩 시 라우트에서 전달된 결재번호가 있으면 자동으로 상세를 연다.
void _applyRouteSelectionIfNeeded(List<Approval> approvals) {
final target = _pendingRouteSelection;
if (target == null) {
return;
}
for (final approval in approvals) {
if (approval.approvalNo == target && approval.id != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
unawaited(_openApprovalDetailDialog(approval));
});
break;
}
}
_pendingRouteSelection = null;
}
Future<void> _openApprovalDetailDialog(Approval approval) async {
final id = approval.id;
if (id == null) {
SuperportToast.error(context, 'ID 정보가 없어 상세를 열 수 없습니다.');
return;
}
await _controller.selectApproval(id);
if (!mounted) {
return;
}
final selected = _controller.selected;
final error = _controller.errorMessage;
if (selected == null) {
if (error != null) {
SuperportToast.error(context, error);
_controller.clearError();
} else {
SuperportToast.error(context, '결재 상세 정보를 불러오지 못했습니다.');
}
return;
}
if (_controller.templates.isEmpty && !_controller.isLoadingTemplates) {
await _controller.loadTemplates(force: true);
if (!mounted) {
return;
}
}
final permissionScope = PermissionScope.of(context);
final canPerformStepActions = permissionScope.can(
_approvalsResourcePath,
PermissionAction.approve,
);
final canApplyTemplate = permissionScope.can(
_approvalsResourcePath,
PermissionAction.edit,
);
await showApprovalDetailDialog(
context: context,
controller: _controller,
dateFormat: _dateTimeFormat,
canPerformStepActions: canPerformStepActions,
canApplyTemplate: canApplyTemplate,
);
if (!mounted) {
return;
}
_controller.clearSelection();
}
@override
void dispose() {
_controller.removeListener(_handleControllerUpdate);
_controller.dispose();
_transactionController.dispose();
_requesterController.dispose();
_transactionFocus.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
return AnimatedBuilder(
animation: _controller,
builder: (context, _) {
final result = _controller.result;
final approvals = result?.items ?? const <Approval>[];
if (result != null) {
_applyRouteSelectionIfNeeded(approvals);
}
final totalCount = result?.total ?? 0;
final currentPage = result?.page ?? 1;
final totalPages = result == null || result.pageSize == 0
? 1
: (result.total / result.pageSize).ceil().clamp(1, 9999);
return AppLayout(
title: '결재 관리',
subtitle: '결재 요청 상태와 단계/이력을 한 화면에서 확인합니다.',
breadcrumbs: const [
AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath),
AppBreadcrumbItem(label: '결재', path: '/approvals/requests'),
AppBreadcrumbItem(label: '결재 관리'),
],
actions: [
ShadButton(
leading: const Icon(lucide.LucideIcons.plus, size: 16),
onPressed: _openCreateApprovalDialog,
child: const Text('신규 결재'),
),
],
toolbar: FilterBar(
actionConfig: FilterBarActionConfig(
onApply: _applyFilters,
onReset: _resetFilters,
hasPendingChanges: false,
hasActiveFilters: _hasFilters(),
applyEnabled: !_controller.isLoadingList,
resetLabel: '필터 초기화',
resetKey: const ValueKey('approval_filter_reset'),
resetEnabled: !_controller.isLoadingList && _hasFilters(),
showReset: true,
),
children: [
SizedBox(
width: 180,
child: ShadInput(
controller: _transactionController,
focusNode: _transactionFocus,
enabled: !_controller.isLoadingList,
keyboardType: TextInputType.number,
placeholder: const Text('트랜잭션 ID 입력'),
leading: const Icon(lucide.LucideIcons.hash, size: 16),
onChanged: (_) => setState(() {}),
onSubmitted: (_) => _applyFilters(),
),
),
SizedBox(
width: 280,
child: InventoryEmployeeAutocompleteField(
controller: _requesterController,
initialSuggestion: _selectedRequester,
enabled: !_controller.isLoadingList,
onSuggestionSelected: (suggestion) {
setState(() => _selectedRequester = suggestion);
},
onChanged: () {
setState(() {
final selectedLabel = _selectedRequester == null
? ''
: '${_selectedRequester!.name} (${_selectedRequester!.employeeNo})';
if (_requesterController.text.trim() != selectedLabel) {
_selectedRequester = null;
}
});
},
),
),
SizedBox(
width: 200,
child: Tooltip(
message: '전체 상태 선택 시 임시저장·상신·진행중 결재까지 함께 조회합니다.',
waitDuration: const Duration(milliseconds: 200),
child: ShadSelect<ApprovalStatusFilter>(
key: ValueKey(_controller.statusFilter),
initialValue: _controller.statusFilter,
selectedOptionBuilder: (context, value) =>
Text(_statusLabel(value)),
onChanged: (value) {
if (value == null) return;
_controller.updateStatusFilter(value);
_controller.fetch(page: 1);
},
options: ApprovalStatusFilter.values
.map(
(filter) => ShadOption(
value: filter,
child: Text(_statusLabel(filter)),
),
)
.toList(),
),
),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ShadCard(
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('결재 목록', style: theme.textTheme.h3),
Text('$totalCount건', style: theme.textTheme.muted),
],
),
footer: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'페이지 $currentPage / $totalPages',
style: theme.textTheme.small,
),
SuperportPaginationControls(
currentPage: currentPage,
totalPages: totalPages,
isBusy: _controller.isLoadingList,
onPageSelected: (page) => _controller.fetch(page: page),
),
],
),
child: _controller.isLoadingList
? const Padding(
padding: EdgeInsets.all(48),
child: Center(child: CircularProgressIndicator()),
)
: approvals.isEmpty
? Padding(
padding: const EdgeInsets.all(32),
child: Text(
'조건에 맞는 결재 내역이 없습니다.',
style: theme.textTheme.muted,
),
)
: _ApprovalTable(
approvals: approvals,
dateFormat: _dateTimeFormat,
onView: (approval) {
unawaited(_openApprovalDetailDialog(approval));
},
),
),
const SizedBox(height: 24),
],
),
);
},
);
}
/// 신규 결재 등록 다이얼로그를 열어 UI 단계에서 필요한 필드와 안내를 제공한다.
Future<void> _openCreateApprovalDialog() async {
final transactionController = TextEditingController();
final requesterController = TextEditingController();
final noteController = TextEditingController();
String? createdApprovalNo;
InventoryEmployeeSuggestion? requesterSelection;
int? statusId = _controller.defaultApprovalStatusId;
String? transactionError;
String? statusError;
String? requesterError;
final created = await showDialog<bool>(
context: context,
builder: (_) {
return StatefulBuilder(
builder: (context, setState) {
return AnimatedBuilder(
animation: _controller,
builder: (context, _) {
final shadTheme = ShadTheme.of(context);
final materialTheme = Theme.of(context);
final statusOptions = _controller.approvalStatusOptions;
final isSubmitting = _controller.isSubmitting;
statusId ??= _controller.defaultApprovalStatusId;
return SuperportDialog(
title: '신규 결재 등록',
description: '트랜잭션과 결재 정보를 입력하면 즉시 생성됩니다.',
constraints: const BoxConstraints(maxWidth: 540),
actions: [
ShadButton.ghost(
onPressed: isSubmitting
? null
: () => Navigator.of(
context,
rootNavigator: true,
).pop(false),
child: const Text('취소'),
),
ShadButton(
key: const ValueKey('approval_create_submit'),
onPressed: isSubmitting
? null
: () async {
final transactionText = transactionController.text
.trim();
final transactionId = int.tryParse(
transactionText,
);
final note = noteController.text.trim();
final hasStatuses = statusOptions.isNotEmpty;
setState(() {
transactionError = transactionText.isEmpty
? '트랜잭션 ID를 입력하세요.'
: (transactionId == null
? '트랜잭션 ID는 숫자만 입력하세요.'
: null);
statusError = (!hasStatuses || statusId == null)
? '결재 상태를 선택하세요.'
: null;
requesterError = requesterSelection == null
? '상신자를 선택하세요.'
: null;
});
if (transactionError != null ||
statusError != null ||
requesterError != null) {
return;
}
final input = ApprovalCreateInput(
transactionId: transactionId!,
approvalStatusId: statusId!,
requestedById: requesterSelection!.id,
note: note.isEmpty ? null : note,
);
final result = await _controller.createApproval(
input,
);
if (!mounted || !context.mounted) {
return;
}
if (result != null) {
createdApprovalNo = result.approvalNo;
Navigator.of(
context,
rootNavigator: true,
).pop(true);
}
},
child: isSubmitting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('등록'),
),
],
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: shadTheme.colorScheme.mutedForeground
.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'결재번호는 저장 시 자동으로 생성됩니다.',
style: shadTheme.textTheme.muted,
),
),
const SizedBox(height: 16),
Text('트랜잭션 ID', style: shadTheme.textTheme.small),
const SizedBox(height: 8),
ShadInput(
key: const ValueKey('approval_create_transaction'),
controller: transactionController,
enabled: !isSubmitting,
placeholder: const Text('예: 9001'),
onChanged: (_) {
if (transactionError != null) {
setState(() => transactionError = null);
}
},
),
if (transactionError != null)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
transactionError!,
style: shadTheme.textTheme.small.copyWith(
color: materialTheme.colorScheme.error,
),
),
),
const SizedBox(height: 16),
Text('결재 상태', style: shadTheme.textTheme.small),
const SizedBox(height: 8),
if (statusOptions.isEmpty)
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: shadTheme.colorScheme.border,
),
),
child: Text(
'결재 상태 정보를 불러오지 못했습니다. 다시 시도해주세요.',
style: shadTheme.textTheme.muted,
),
)
else
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ShadSelect<int>(
key: ValueKey(statusOptions.length),
initialValue: statusId,
enabled: !isSubmitting,
placeholder: const Text('결재 상태 선택'),
onChanged: (value) {
setState(() {
statusId = value;
statusError = null;
});
},
selectedOptionBuilder: (context, value) {
final selected = _controller.approvalStatusById(
value,
);
return Text(selected?.name ?? '결재 상태 선택');
},
options: statusOptions
.map(
(item) => ShadOption<int>(
value: item.id,
child: Text(item.name),
),
)
.toList(),
),
if (statusError != null)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
statusError!,
style: shadTheme.textTheme.small.copyWith(
color: materialTheme.colorScheme.error,
),
),
),
],
),
const SizedBox(height: 16),
Text('상신자', style: shadTheme.textTheme.small),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: InventoryEmployeeAutocompleteField(
controller: requesterController,
initialSuggestion: requesterSelection,
onSuggestionSelected: (suggestion) {
setState(() {
requesterSelection = suggestion;
requesterError = null;
});
},
onChanged: () {
if (requesterController.text.trim().isEmpty) {
setState(() {
requesterSelection = null;
});
}
},
enabled: !isSubmitting,
placeholder: '상신자 이름 또는 사번 검색',
),
),
const SizedBox(width: 8),
ShadButton.ghost(
onPressed:
requesterSelection == null &&
requesterController.text.isEmpty
? null
: () {
setState(() {
requesterSelection = null;
requesterController.clear();
requesterError = null;
});
},
child: const Text('초기화'),
),
],
),
if (requesterError != null)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
requesterError!,
style: shadTheme.textTheme.small.copyWith(
color: materialTheme.colorScheme.error,
),
),
),
const SizedBox(height: 16),
Text('비고 (선택)', style: shadTheme.textTheme.small),
const SizedBox(height: 8),
ShadTextarea(
key: const ValueKey('approval_create_note'),
controller: noteController,
enabled: !isSubmitting,
minHeight: 120,
maxHeight: 220,
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: shadTheme.colorScheme.mutedForeground
.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
Text(
'저장 안내',
style: TextStyle(fontWeight: FontWeight.w600),
),
SizedBox(height: 6),
Text(
'저장 시 결재가 생성되고 첫 단계와 현재 상태가 API 규격에 맞춰 초기화됩니다. 등록 후 목록이 자동으로 갱신됩니다.',
),
],
),
),
],
),
);
},
);
},
);
},
);
transactionController.dispose();
requesterController.dispose();
noteController.dispose();
if (created == true && mounted) {
final number = createdApprovalNo ?? '-';
SuperportToast.success(context, '결재를 생성했습니다. ($number)');
}
}
void _applyFilters() {
final transactionText = _transactionController.text.trim();
if (transactionText.isNotEmpty) {
final transactionId = int.tryParse(transactionText);
if (transactionId == null) {
SuperportToast.error(context, '트랜잭션 ID는 숫자만 입력해야 합니다.');
return;
}
_controller.updateTransactionFilter(transactionId);
} else {
_controller.updateTransactionFilter(null);
}
final requester = _selectedRequester;
if (requester != null) {
_controller.updateRequestedByFilter(
id: requester.id,
name: requester.name,
employeeNo: requester.employeeNo,
);
} else if (_requesterController.text.trim().isEmpty) {
_controller.updateRequestedByFilter(id: null);
}
_controller.fetch(page: 1);
}
void _resetFilters() {
_transactionController.clear();
_requesterController.clear();
setState(() => _selectedRequester = null);
_transactionFocus.requestFocus();
_controller.clearFilters();
_controller.fetch(page: 1);
}
bool _hasFilters() {
return _transactionController.text.trim().isNotEmpty ||
_requesterController.text.trim().isNotEmpty ||
_selectedRequester != null ||
_controller.transactionIdFilter != null ||
_controller.requestedById != null ||
_controller.statusFilter != ApprovalStatusFilter.all;
}
String _statusLabel(ApprovalStatusFilter filter) {
return _controller.statusLabel(filter);
}
}
class _ApprovalTable extends StatelessWidget {
const _ApprovalTable({
required this.approvals,
required this.dateFormat,
required this.onView,
});
final List<Approval> approvals;
final intl.DateFormat dateFormat;
final void Function(Approval approval) onView;
@override
Widget build(BuildContext context) {
final header = [
'ID',
'결재번호',
'트랜잭션번호',
'상태',
'상신자',
'요청일시',
'최종결정일시',
'비고',
].map((text) => ShadTableCell.header(child: Text(text))).toList();
final rows = <List<ShadTableCell>>[];
for (var index = 0; index < approvals.length; index++) {
final approval = approvals[index];
final cells = <ShadTableCell>[
ShadTableCell(
child: GestureDetector(
key: ValueKey('approval_row_${approval.id ?? index}'),
behavior: HitTestBehavior.opaque,
onTap: () => onView(approval),
child: Text(approval.id?.toString() ?? '-'),
),
),
ShadTableCell(child: Text(approval.approvalNo)),
ShadTableCell(child: Text(approval.transactionNo)),
ShadTableCell(child: Text(approval.status.name)),
ShadTableCell(child: Text(approval.requester.name)),
ShadTableCell(
child: Text(dateFormat.format(approval.requestedAt.toLocal())),
),
ShadTableCell(
child: Text(
approval.decidedAt == null
? '-'
: dateFormat.format(approval.decidedAt!.toLocal()),
),
),
ShadTableCell(
child: Text(approval.note?.isEmpty ?? true ? '-' : approval.note!),
),
];
rows.add(cells);
}
return SuperportTable.fromCells(
header: header,
rows: rows,
rowHeight: 56,
maxHeight: 520,
onRowTap: (index) {
if (index < 0 || index >= approvals.length) {
return;
}
onView(approvals[index]);
},
columnSpanExtent: (index) {
switch (index) {
case 1:
case 2:
return const FixedTableSpanExtent(180);
case 3:
case 4:
return const FixedTableSpanExtent(140);
case 7:
return const FixedTableSpanExtent(220);
default:
return const FixedTableSpanExtent(140);
}
},
);
}
}