import 'package:flutter/material.dart'; import 'package:get_it/get_it.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/constants/app_sections.dart'; import '../../../../core/permissions/permission_manager.dart'; import '../../../../widgets/app_layout.dart'; import '../../../../widgets/components/feedback.dart'; import '../../../../widgets/components/filter_bar.dart'; import '../../../../widgets/components/superport_date_picker.dart'; import '../../../../widgets/components/superport_dialog.dart'; import '../../../../widgets/components/superport_table.dart'; import '../../../../widgets/components/feature_disabled_placeholder.dart'; import '../../domain/entities/approval.dart'; import '../../domain/entities/approval_template.dart'; import '../../domain/repositories/approval_repository.dart'; import '../../domain/repositories/approval_template_repository.dart'; import '../controllers/approval_controller.dart'; const _approvalsResourcePath = '/approvals/requests'; /// 결재 관리 최상위 페이지. /// /// 기능 플래그에 따라 실제 화면 또는 비활성 안내 화면을 보여준다. class ApprovalPage extends StatelessWidget { const ApprovalPage({super.key}); @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 const _ApprovalEnabledPage(); } } /// 결재 기능이 활성화되었을 때 사용되는 실제 페이지 위젯. class _ApprovalEnabledPage extends StatefulWidget { const _ApprovalEnabledPage(); @override State<_ApprovalEnabledPage> createState() => _ApprovalEnabledPageState(); } class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> { late final ApprovalController _controller; final TextEditingController _searchController = TextEditingController(); final FocusNode _searchFocus = FocusNode(); final intl.DateFormat _dateFormat = intl.DateFormat('yyyy-MM-dd'); final intl.DateFormat _dateTimeFormat = intl.DateFormat('yyyy-MM-dd HH:mm'); DateTimeRange? _dateRange; String? _lastError; int? _selectedTemplateId; @override void initState() { super.initState(); _controller = ApprovalController( approvalRepository: GetIt.I(), templateRepository: GetIt.I(), )..addListener(_handleControllerUpdate); WidgetsBinding.instance.addPostFrameCallback((_) async { await Future.wait([ _controller.loadActionOptions(), _controller.loadTemplates(), ]); await _controller.fetch(); }); } void _handleControllerUpdate() { final error = _controller.errorMessage; if (error != null && error != _lastError && mounted) { _lastError = error; SuperportToast.error(context, error); _controller.clearError(); } } @override void dispose() { _controller.removeListener(_handleControllerUpdate); _controller.dispose(); _searchController.dispose(); _searchFocus.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final theme = ShadTheme.of(context); final permissionManager = PermissionScope.of(context); return AnimatedBuilder( animation: _controller, builder: (context, _) { final result = _controller.result; final approvals = result?.items ?? const []; final selectedApproval = _controller.selected; 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); final hasNext = result == null ? false : (result.page * result.pageSize) < result.total; final isLoadingActions = _controller.isLoadingActions; final isPerformingAction = _controller.isPerformingAction; final processingStepId = _controller.processingStepId; final hasActionOptions = _controller.hasActionOptions; final templates = _controller.templates; final isLoadingTemplates = _controller.isLoadingTemplates; final isApplyingTemplate = _controller.isApplyingTemplate; final applyingTemplateId = _controller.applyingTemplateId; final canPerformStepActions = permissionManager.can( _approvalsResourcePath, PermissionAction.approve, ); final canManageTemplates = permissionManager.can( _approvalsResourcePath, PermissionAction.edit, ); if (templates.isNotEmpty && _selectedTemplateId == null) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; setState(() => _selectedTemplateId = templates.first.id); }); } else if (_selectedTemplateId != null && templates.every((template) => template.id != _selectedTemplateId)) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; setState(() => _selectedTemplateId = null); }); } 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: () {}, 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: 260, child: ShadInput( controller: _searchController, focusNode: _searchFocus, placeholder: const Text('결재번호, 트랜잭션번호, 상신자 검색'), leading: const Icon(lucide.LucideIcons.search, size: 16), onChanged: (_) => setState(() {}), onSubmitted: (_) => _applyFilters(), ), ), SizedBox( width: 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(), ), ), SizedBox( width: 220, child: SuperportDateRangePickerButton( value: _dateRange, dateFormat: _dateFormat, enabled: !_controller.isLoadingList, firstDate: DateTime(DateTime.now().year - 5), lastDate: DateTime(DateTime.now().year + 1), initialDateRange: _dateRange ?? DateTimeRange( start: DateTime.now().subtract(const Duration(days: 7)), end: DateTime.now(), ), onChanged: (range) { if (range == null) return; setState(() => _dateRange = range); _controller.updateDateRange(range.start, range.end); _controller.fetch(page: 1); }, ), ), if (_dateRange != null) ShadButton.ghost( onPressed: () { setState(() => _dateRange = null); _controller.updateDateRange(null, null); _controller.fetch(page: 1); }, child: const Text('기간 초기화'), ), ], ), 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, ), Row( children: [ ShadButton.outline( size: ShadButtonSize.sm, onPressed: _controller.isLoadingList || currentPage <= 1 ? null : () => _controller.fetch(page: currentPage - 1), child: const Text('이전'), ), const SizedBox(width: 8), ShadButton.outline( size: ShadButtonSize.sm, onPressed: _controller.isLoadingList || !hasNext ? null : () => _controller.fetch(page: currentPage + 1), child: const Text('다음'), ), ], ), ], ), 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) { final id = approval.id; if (id != null) { _controller.selectApproval(id); } }, ), ), const SizedBox(height: 24), _DetailSection( approval: selectedApproval, isLoading: _controller.isLoadingDetail, isLoadingActions: isLoadingActions, isPerformingAction: isPerformingAction, processingStepId: processingStepId, hasActionOptions: hasActionOptions, templates: templates, isLoadingTemplates: isLoadingTemplates, isApplyingTemplate: isApplyingTemplate, applyingTemplateId: applyingTemplateId, selectedTemplateId: _selectedTemplateId, canPerformStepActions: canPerformStepActions, canApplyTemplate: canManageTemplates, dateFormat: _dateTimeFormat, onRefresh: () { final id = selectedApproval?.id; if (id != null) { _controller.selectApproval(id); } }, onClose: selectedApproval == null ? null : _controller.clearSelection, onSelectTemplate: _handleSelectTemplate, onApplyTemplate: _handleApplyTemplate, onReloadTemplates: () => _controller.loadTemplates(force: true), onAction: _handleStepAction, ), ], ), ); }, ); } void _applyFilters() { _controller.updateQuery(_searchController.text.trim()); if (_dateRange != null) { _controller.updateDateRange(_dateRange!.start, _dateRange!.end); } _controller.fetch(page: 1); } void _resetFilters() { _searchController.clear(); _searchFocus.requestFocus(); _dateRange = null; _controller.clearFilters(); _controller.fetch(page: 1); } bool _hasFilters() { return _searchController.text.isNotEmpty || _controller.statusFilter != ApprovalStatusFilter.all || _dateRange != null; } Future _handleStepAction( ApprovalStep step, ApprovalStepActionType type, ) async { final result = await _showStepActionDialog(step, type); if (result == null) { return; } final success = await _controller.performStepAction( step: step, type: type, note: result.note, ); if (!mounted || !success) { return; } SuperportToast.success(context, _successMessage(type)); } void _handleSelectTemplate(int? templateId) { setState(() => _selectedTemplateId = templateId); } Future _handleApplyTemplate(int templateId) async { ApprovalTemplate? template; for (final item in _controller.templates) { if (item.id == templateId) { template = item; break; } } if (template == null) { SuperportToast.error(context, '선택한 템플릿 정보를 찾을 수 없습니다.'); return; } final confirmed = await _showTemplateApplyConfirm(template); if (!confirmed) { return; } final success = await _controller.applyTemplate(templateId); if (!mounted || !success) { return; } SuperportToast.success(context, '템플릿 "${template.name}"을(를) 적용했습니다.'); } Future<_StepActionDialogResult?> _showStepActionDialog( ApprovalStep step, ApprovalStepActionType type, ) async { final noteController = TextEditingController(); final requireNote = type == ApprovalStepActionType.comment; final dialogResult = await showDialog<_StepActionDialogResult>( context: context, builder: (dialogContext) { String? errorText; return StatefulBuilder( builder: (context, setState) { final materialTheme = Theme.of(context); final shadTheme = ShadTheme.of(context); return SuperportDialog( title: _dialogTitle(type), constraints: const BoxConstraints(maxWidth: 420), actions: [ ShadButton.ghost( onPressed: () => Navigator.of(dialogContext).pop(), child: const Text('취소'), ), ShadButton( onPressed: () { final note = noteController.text.trim(); if (requireNote && note.isEmpty) { setState(() => errorText = '비고를 입력하세요.'); return; } Navigator.of(dialogContext).pop( _StepActionDialogResult(note: note.isEmpty ? null : note), ); }, child: Text(_dialogConfirmLabel(type)), ), ], child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '선택 단계: Step ${step.stepOrder}', style: shadTheme.textTheme.small, ), const SizedBox(height: 4), Text( '승인자: ${step.approver.name}', style: shadTheme.textTheme.small, ), const SizedBox(height: 4), Text( '현재 상태: ${step.status.name}', style: shadTheme.textTheme.small, ), const SizedBox(height: 16), Text('비고', style: shadTheme.textTheme.small), const SizedBox(height: 8), ShadTextarea( controller: noteController, minHeight: 120, maxHeight: 220, ), if (requireNote) Padding( padding: const EdgeInsets.only(top: 6), child: Text( '코멘트에는 비고 입력이 필요합니다.', style: shadTheme.textTheme.muted, ), ), if (errorText != null) Padding( padding: const EdgeInsets.only(top: 8), child: Text( errorText!, style: shadTheme.textTheme.small.copyWith( color: materialTheme.colorScheme.error, ), ), ), ], ), ); }, ); }, ); noteController.dispose(); return dialogResult; } Future _showTemplateApplyConfirm(ApprovalTemplate template) async { final stepCount = template.steps.length; final description = template.description?.trim(); final buffer = StringBuffer() ..writeln('선택한 템플릿을 적용하면 기존 단계 구성이 템플릿 순서로 교체됩니다.') ..write('템플릿: ${template.name} (단계 $stepCount개)'); if (description != null && description.isNotEmpty) { buffer.write('\n설명: $description'); } final confirmed = await SuperportDialog.show( context: context, dialog: SuperportDialog( title: '템플릿 적용 확인', actions: [ ShadButton.ghost( onPressed: () => Navigator.of(context).pop(false), child: const Text('취소'), ), ShadButton( onPressed: () => Navigator.of(context).pop(true), child: const Text('적용'), ), ], child: Text(buffer.toString()), ), ); return confirmed ?? false; } String _statusLabel(ApprovalStatusFilter filter) { switch (filter) { case ApprovalStatusFilter.all: return '전체 상태'; case ApprovalStatusFilter.pending: return '대기'; case ApprovalStatusFilter.inProgress: return '진행중'; case ApprovalStatusFilter.onHold: return '보류'; case ApprovalStatusFilter.approved: return '승인'; case ApprovalStatusFilter.rejected: return '반려'; } } String _dialogTitle(ApprovalStepActionType type) { switch (type) { case ApprovalStepActionType.approve: return '단계 승인'; case ApprovalStepActionType.reject: return '단계 반려'; case ApprovalStepActionType.comment: return '코멘트 등록'; } } String _dialogConfirmLabel(ApprovalStepActionType type) { switch (type) { case ApprovalStepActionType.approve: return '승인'; case ApprovalStepActionType.reject: return '반려'; case ApprovalStepActionType.comment: return '등록'; } } String _successMessage(ApprovalStepActionType type) { switch (type) { case ApprovalStepActionType.approve: return '결재 단계를 승인했습니다.'; case ApprovalStepActionType.reject: return '결재 단계를 반려했습니다.'; case ApprovalStepActionType.comment: return '코멘트를 등록했습니다.'; } } } 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!), ), ]; cells.add( ShadTableCell( child: Align( alignment: Alignment.centerRight, child: ShadButton.ghost( key: ValueKey('approval_view_${approval.id ?? index}'), size: ShadButtonSize.sm, onPressed: () => onView(approval), child: const Text('자세히'), ), ), ), ); rows.add(cells); } return SuperportTable.fromCells( header: header, rows: rows, rowHeight: 56, maxHeight: 520, 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); case 8: return const FixedTableSpanExtent(120); default: return const FixedTableSpanExtent(140); } }, ); } } /// 결재 상세 탭 전체를 감싸는 카드 위젯. /// /// 선택 상태와 로딩 여부에 따라 안내 문구 또는 상세 정보를 노출한다. class _DetailSection extends StatelessWidget { const _DetailSection({ required this.approval, required this.isLoading, required this.isLoadingActions, required this.isPerformingAction, required this.processingStepId, required this.hasActionOptions, required this.templates, required this.isLoadingTemplates, required this.isApplyingTemplate, required this.applyingTemplateId, required this.selectedTemplateId, required this.canPerformStepActions, required this.canApplyTemplate, required this.dateFormat, required this.onRefresh, required this.onClose, required this.onSelectTemplate, required this.onApplyTemplate, required this.onReloadTemplates, required this.onAction, }); final Approval? approval; final bool isLoading; final bool isLoadingActions; final bool isPerformingAction; final int? processingStepId; final bool hasActionOptions; final List templates; final bool isLoadingTemplates; final bool isApplyingTemplate; final int? applyingTemplateId; final int? selectedTemplateId; final bool canPerformStepActions; final bool canApplyTemplate; final intl.DateFormat dateFormat; final VoidCallback onRefresh; final VoidCallback? onClose; final void Function(int?) onSelectTemplate; final void Function(int templateId) onApplyTemplate; final VoidCallback onReloadTemplates; final void Function(ApprovalStep step, ApprovalStepActionType type) onAction; @override Widget build(BuildContext context) { final theme = ShadTheme.of(context); if (isLoading) { return const Center(child: CircularProgressIndicator()); } if (approval == null) { return ShadCard( child: Padding( padding: const EdgeInsets.all(24), child: Text( '좌측 목록에서 결재를 선택하면 상세 정보를 확인할 수 있습니다.', style: theme.textTheme.muted, ), ), ); } return DefaultTabController( length: 3, child: ShadCard( title: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text('결재 상세', style: theme.textTheme.h3), Row( mainAxisSize: MainAxisSize.min, children: [ ShadButton.ghost( onPressed: onRefresh, child: const Icon(lucide.LucideIcons.refreshCw, size: 16), ), const SizedBox(width: 8), ShadButton.ghost( onPressed: onClose, child: const Icon(lucide.LucideIcons.x, size: 16), ), ], ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ TabBar( labelStyle: theme.textTheme.small, tabs: const [ Tab(text: '개요'), Tab(text: '단계'), Tab(text: '이력'), ], ), SizedBox( height: 340, child: TabBarView( children: [ _OverviewTab(approval: approval!, dateFormat: dateFormat), _StepTab( approval: approval!, steps: approval!.steps, dateFormat: dateFormat, hasActionOptions: hasActionOptions, isLoadingActions: isLoadingActions, isPerformingAction: isPerformingAction, processingStepId: processingStepId, templates: templates, isLoadingTemplates: isLoadingTemplates, isApplyingTemplate: isApplyingTemplate, applyingTemplateId: applyingTemplateId, selectedTemplateId: selectedTemplateId, canPerformStepActions: canPerformStepActions, canApplyTemplate: canApplyTemplate, onSelectTemplate: onSelectTemplate, onApplyTemplate: onApplyTemplate, onReloadTemplates: onReloadTemplates, onAction: onAction, ), _HistoryTab( histories: approval!.histories, dateFormat: dateFormat, ), ], ), ), ], ), ), ); } } class _OverviewTab extends StatelessWidget { const _OverviewTab({required this.approval, required this.dateFormat}); final Approval approval; final intl.DateFormat dateFormat; @override Widget build(BuildContext context) { final theme = ShadTheme.of(context); final rows = [ ('결재번호', approval.approvalNo), ('트랜잭션번호', approval.transactionNo), ('현재 상태', approval.status.name), ( '현재 단계', approval.currentStep == null ? '-' : 'Step ${approval.currentStep!.stepOrder} · ${approval.currentStep!.approver.name}', ), ('상신자', approval.requester.name), ('상신일시', dateFormat.format(approval.requestedAt.toLocal())), ( '최종결정일시', approval.decidedAt == null ? '-' : dateFormat.format(approval.decidedAt!.toLocal()), ), ('비고', approval.note?.isEmpty ?? true ? '-' : approval.note!), ]; return Padding( padding: const EdgeInsets.all(20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: rows .map( (entry) => Padding( padding: const EdgeInsets.only(bottom: 12), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: 140, child: Text( entry.$1, style: theme.textTheme.small.copyWith( fontWeight: FontWeight.w600, ), ), ), Expanded( child: Text(entry.$2, style: theme.textTheme.small), ), ], ), ), ) .toList(), ), ); } } /// 결재 단계 목록과 템플릿 적용 컨트롤을 묶어 보여주는 탭. class _StepTab extends StatelessWidget { const _StepTab({ required this.approval, required this.steps, required this.dateFormat, required this.hasActionOptions, required this.isLoadingActions, required this.isPerformingAction, required this.processingStepId, required this.templates, required this.isLoadingTemplates, required this.isApplyingTemplate, required this.applyingTemplateId, required this.selectedTemplateId, required this.canPerformStepActions, required this.canApplyTemplate, required this.onSelectTemplate, required this.onApplyTemplate, required this.onReloadTemplates, required this.onAction, }); final Approval approval; final List steps; final intl.DateFormat dateFormat; final bool hasActionOptions; final bool isLoadingActions; final bool isPerformingAction; final int? processingStepId; final List templates; final bool isLoadingTemplates; final bool isApplyingTemplate; final int? applyingTemplateId; final int? selectedTemplateId; final bool canPerformStepActions; final bool canApplyTemplate; final void Function(int?) onSelectTemplate; final void Function(int templateId) onApplyTemplate; final VoidCallback onReloadTemplates; final void Function(ApprovalStep step, ApprovalStepActionType type) onAction; @override Widget build(BuildContext context) { final theme = ShadTheme.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.fromLTRB(20, 20, 20, 12), child: _TemplateToolbar( templates: templates, isLoading: isLoadingTemplates, selectedTemplateId: selectedTemplateId, isApplyingTemplate: isApplyingTemplate, applyingTemplateId: applyingTemplateId, canApplyTemplate: canApplyTemplate, onSelectTemplate: onSelectTemplate, onApplyTemplate: onApplyTemplate, onReload: onReloadTemplates, ), ), if (!canApplyTemplate) Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: Text( '템플릿 적용 권한이 없어 단계 구성을 변경할 수 없습니다.', style: theme.textTheme.muted, ), ), if (!isLoadingTemplates && templates.isEmpty) Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: Text( '사용 가능한 결재 템플릿이 없습니다. 템플릿을 등록하면 단계 일괄 구성이 가능합니다.', style: theme.textTheme.muted, ), ), if (!canPerformStepActions) Padding( padding: const EdgeInsets.fromLTRB(20, 12, 20, 8), child: Text( '결재 권한이 없어 단계 행위를 실행할 수 없습니다.', style: theme.textTheme.muted, ), ), if (steps.isEmpty) Expanded( child: Center( child: Text('등록된 결재 단계가 없습니다.', style: theme.textTheme.muted), ), ) else Expanded( child: ListView.separated( padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), itemBuilder: (context, index) { final step = steps[index]; final disabledReason = _disabledReason( step, canPerformStepActions, ); final isProcessingStep = isPerformingAction && processingStepId == step.id; final isEnabled = disabledReason == null && !isProcessingStep; return ShadCard( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( 'Step ${step.stepOrder}', style: theme.textTheme.small.copyWith( fontWeight: FontWeight.w600, ), ), Text( step.status.name, style: theme.textTheme.small, ), ], ), const SizedBox(height: 8), Text( '승인자: ${step.approver.name}', style: theme.textTheme.small, ), const SizedBox(height: 4), Text( '배정: ${dateFormat.format(step.assignedAt.toLocal())}', style: theme.textTheme.small, ), const SizedBox(height: 4), Text( '결정: ${step.decidedAt == null ? '-' : dateFormat.format(step.decidedAt!.toLocal())}', style: theme.textTheme.small, ), if (step.note?.isNotEmpty ?? false) ...[ const SizedBox(height: 8), Text( '비고: ${step.note}', style: theme.textTheme.small, ), ], const SizedBox(height: 12), if (isLoadingActions) Padding( padding: const EdgeInsets.only(bottom: 8), child: Row( children: [ SizedBox( width: 16, height: 16, child: const CircularProgressIndicator( strokeWidth: 2, ), ), const SizedBox(width: 8), Text( '행위 목록을 불러오는 중입니다.', style: theme.textTheme.small, ), ], ), ), Wrap( spacing: 12, runSpacing: 8, children: ApprovalStepActionType.values .map( (type) => _buildActionButton( context: context, step: step, type: type, enabled: isEnabled, isProcessing: isProcessingStep, disabledReason: disabledReason, ), ) .toList(), ), if (!isEnabled && disabledReason != null) Padding( padding: const EdgeInsets.only(top: 8), child: Text( disabledReason, style: theme.textTheme.muted, ), ), ], ), ), ); }, separatorBuilder: (_, __) => const SizedBox(height: 12), itemCount: steps.length, ), ), ], ); } Widget _buildActionButton({ required BuildContext context, required ApprovalStep step, required ApprovalStepActionType type, required bool enabled, required bool isProcessing, required String? disabledReason, }) { final theme = ShadTheme.of(context); final actionKey = ValueKey(_actionKey(step, type)); final label = _actionLabel(type); final icon = _actionIcon(type); final child = Row( mainAxisSize: MainAxisSize.min, children: [ if (isProcessing) ...[ SizedBox( width: 16, height: 16, child: CircularProgressIndicator( strokeWidth: 2, valueColor: AlwaysStoppedAnimation( theme.colorScheme.primary, ), ), ), const SizedBox(width: 8), Text('$label 처리 중...'), ] else ...[ Icon(icon, size: 16), const SizedBox(width: 8), Text(label), ], ], ); final onPressed = enabled ? () => onAction(step, type) : null; Widget button; switch (type) { case ApprovalStepActionType.approve: button = ShadButton(key: actionKey, onPressed: onPressed, child: child); break; case ApprovalStepActionType.reject: button = ShadButton.outline( key: actionKey, onPressed: onPressed, child: child, ); break; case ApprovalStepActionType.comment: button = ShadButton.ghost( key: actionKey, onPressed: onPressed, child: child, ); break; } if (!enabled && disabledReason != null) { return Tooltip(message: disabledReason, child: button); } return button; } String? _disabledReason(ApprovalStep step, bool canPerformStepActions) { if (!canPerformStepActions) { return '결재 행위를 수행할 권한이 없습니다.'; } if (isLoadingActions) { return '행위 목록을 불러오는 중입니다.'; } if (!hasActionOptions) { return '사용 가능한 결재 행위가 없습니다.'; } if (isPerformingAction && processingStepId != step.id) { return '다른 결재 단계를 처리 중입니다.'; } if (step.decidedAt != null) { return '이미 처리된 단계입니다.'; } final current = approval.currentStep; if (current == null) { return '현재 진행할 단계가 지정되지 않았습니다.'; } final matchesId = current.id != null && current.id == step.id; final matchesOrder = current.id == null && step.id == null && current.stepOrder == step.stepOrder; if (!matchesId && !matchesOrder) { return '현재 진행 중인 단계가 아닙니다.'; } return null; } String _actionLabel(ApprovalStepActionType type) { switch (type) { case ApprovalStepActionType.approve: return '승인'; case ApprovalStepActionType.reject: return '반려'; case ApprovalStepActionType.comment: return '코멘트'; } } IconData _actionIcon(ApprovalStepActionType type) { switch (type) { case ApprovalStepActionType.approve: return lucide.LucideIcons.check; case ApprovalStepActionType.reject: return lucide.LucideIcons.x; case ApprovalStepActionType.comment: return lucide.LucideIcons.messageCircle; } } String _actionKey(ApprovalStep step, ApprovalStepActionType type) { if (step.id != null) { return 'step_action_${step.id}_${type.code}'; } return 'step_action_order_${step.stepOrder}_${type.code}'; } } /// 템플릿 목록을 선택·적용하고 재조회할 수 있는 툴바 UI. class _TemplateToolbar extends StatelessWidget { const _TemplateToolbar({ required this.templates, required this.isLoading, required this.selectedTemplateId, required this.isApplyingTemplate, required this.applyingTemplateId, required this.canApplyTemplate, required this.onSelectTemplate, required this.onApplyTemplate, required this.onReload, }); final List templates; final bool isLoading; final int? selectedTemplateId; final bool isApplyingTemplate; final int? applyingTemplateId; final bool canApplyTemplate; final void Function(int?) onSelectTemplate; final void Function(int templateId) onApplyTemplate; final VoidCallback onReload; @override Widget build(BuildContext context) { final theme = ShadTheme.of(context); final selectedTemplate = _findTemplate(selectedTemplateId); final isApplyingCurrent = isApplyingTemplate && applyingTemplateId == selectedTemplateId; final canApply = canApplyTemplate && templates.isNotEmpty && !isLoading && selectedTemplateId != null && !isApplyingTemplate; Widget applyButton = ShadButton( onPressed: canApply ? () { final templateId = selectedTemplateId; if (templateId != null) { onApplyTemplate(templateId); } } : null, child: Row( mainAxisSize: MainAxisSize.min, children: [ if (isApplyingCurrent) ...[ SizedBox( width: 16, height: 16, child: const CircularProgressIndicator(strokeWidth: 2), ), const SizedBox(width: 8), const Text('적용 중...'), ] else ...[ const Icon(lucide.LucideIcons.layoutList, size: 16), const SizedBox(width: 8), const Text('템플릿 적용'), ], ], ), ); if (!canApplyTemplate) { applyButton = Tooltip(message: '템플릿을 적용할 권한이 없습니다.', child: applyButton); } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: ShadSelect( key: ValueKey(templates.length), placeholder: const Text('템플릿 선택'), initialValue: selectedTemplateId, onChanged: canApplyTemplate ? onSelectTemplate : null, selectedOptionBuilder: (context, value) { final match = _findTemplate(value); return Text(match?.name ?? '템플릿 선택'); }, options: templates .map( (template) => ShadOption( value: template.id, child: Text(template.name), ), ) .toList(), ), ), const SizedBox(width: 12), ShadButton.outline( onPressed: isLoading ? null : onReload, child: Row( mainAxisSize: MainAxisSize.min, children: const [ Icon(lucide.LucideIcons.refreshCw, size: 16), SizedBox(width: 6), Text('새로고침'), ], ), ), const SizedBox(width: 12), applyButton, ], ), if (!canApplyTemplate) Padding( padding: const EdgeInsets.only(top: 8), child: Text('결재 템플릿 적용 권한이 없습니다.', style: theme.textTheme.muted), ), if (isLoading) Padding( padding: const EdgeInsets.only(top: 8), child: Row( children: [ SizedBox( width: 16, height: 16, child: const CircularProgressIndicator(strokeWidth: 2), ), const SizedBox(width: 8), Text('템플릿을 불러오는 중입니다.', style: theme.textTheme.small), ], ), ) else if (selectedTemplate != null) Padding( padding: const EdgeInsets.only(top: 8), child: Text( _templateSummary(selectedTemplate), style: theme.textTheme.small, ), ), ], ); } ApprovalTemplate? _findTemplate(int? id) { if (id == null) { return null; } for (final template in templates) { if (template.id == id) { return template; } } return null; } String _templateSummary(ApprovalTemplate template) { final stepCount = template.steps.length; final description = template.description?.trim(); final buffer = StringBuffer() ..write('선택된 템플릿: ${template.name} (단계 $stepCount개)'); if (description != null && description.isNotEmpty) { buffer.write(' · $description'); } return buffer.toString(); } } class _HistoryTab extends StatelessWidget { const _HistoryTab({required this.histories, required this.dateFormat}); final List histories; final intl.DateFormat dateFormat; @override Widget build(BuildContext context) { final theme = ShadTheme.of(context); if (histories.isEmpty) { return Center(child: Text('결재 이력이 없습니다.', style: theme.textTheme.muted)); } return ListView.separated( padding: const EdgeInsets.all(20), itemBuilder: (context, index) { final history = histories[index]; return ShadCard( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( history.action.name, style: theme.textTheme.small.copyWith( fontWeight: FontWeight.w600, ), ), Text( dateFormat.format(history.actionAt.toLocal()), style: theme.textTheme.small, ), ], ), const SizedBox(height: 6), Text( '승인자: ${history.approver.name}', style: theme.textTheme.small, ), const SizedBox(height: 4), Text( '상태: ${history.fromStatus?.name ?? '-'} → ${history.toStatus.name}', style: theme.textTheme.small, ), if (history.note?.isNotEmpty ?? false) ...[ const SizedBox(height: 6), Text('비고: ${history.note}', style: theme.textTheme.small), ], ], ), ), ); }, separatorBuilder: (_, __) => const SizedBox(height: 12), itemCount: histories.length, ); } } class _StepActionDialogResult { const _StepActionDialogResult({this.note}); final String? note; }