import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.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 '../../../auth/application/auth_service.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 '../../../inventory/transactions/domain/repositories/stock_transaction_repository.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(), templateRepository: GetIt.I(), transactionRepository: GetIt.I(), lookupRepository: GetIt.I.isRegistered() ? GetIt.I() : null, saveDraftUseCase: GetIt.I.isRegistered() ? GetIt.I() : null, getDraftUseCase: GetIt.I.isRegistered() ? GetIt.I() : null, listDraftsUseCase: GetIt.I.isRegistered() ? GetIt.I() : null, )..addListener(_handleControllerUpdate); WidgetsBinding.instance.addPostFrameCallback((_) async { await Future.wait([ _controller.loadActionOptions(), _controller.loadTemplates(), _controller.loadStatusLookups(), ]); await _controller.fetch(); _applyRouteSelectionIfNeeded( _controller.result?.items ?? const [], ); }); } @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 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 _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, ); final currentUserId = GetIt.I.isRegistered() ? GetIt.I().session?.user.id : null; await showApprovalDetailDialog( context: context, controller: _controller, dateFormat: _dateTimeFormat, canPerformStepActions: canPerformStepActions, canApplyTemplate: canApplyTemplate, currentUserId: currentUserId, ); if (!mounted) { return; } _controller.clearSelection(); } void _handleRequesterFieldChanged() { void updateSelection() { final selectedLabel = _selectedRequester == null ? '' : '${_selectedRequester!.name} (${_selectedRequester!.employeeNo})'; if (_requesterController.text.trim() != selectedLabel) { _selectedRequester = null; } } if (!mounted) { return; } final phase = SchedulerBinding.instance.schedulerPhase; if (phase == SchedulerPhase.persistentCallbacks || phase == SchedulerPhase.transientCallbacks) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; setState(updateSelection); }); return; } setState(updateSelection); } @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 []; 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: _handleRequesterFieldChanged, ), ), SizedBox( width: 200, child: Tooltip( message: '전체 상태 선택 시 임시저장·상신·진행중 결재까지 함께 조회합니다.', waitDuration: const Duration(milliseconds: 200), child: ShadSelect( 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 _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( 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( 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( 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 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 = >[]; for (var index = 0; index < approvals.length; index++) { final approval = approvals[index]; final cells = [ 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); } }, ); } }