Files
superport_v2/lib/features/approvals/presentation/pages/approval_page.dart

1621 lines
56 KiB
Dart

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 '../../../../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/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 '../../../inventory/lookups/domain/repositories/inventory_lookup_repository.dart';
import '../../../inventory/shared/widgets/employee_autocomplete_field.dart';
import '../controllers/approval_controller.dart';
const _approvalsResourcePath = PermissionResources.approvals;
/// 결재 관리 최상위 페이지.
///
/// 기능 플래그에 따라 실제 화면 또는 비활성 안내 화면을 보여준다.
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 _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;
int? _selectedTemplateId;
@override
void initState() {
super.initState();
_controller = ApprovalController(
approvalRepository: GetIt.I<ApprovalRepository>(),
templateRepository: GetIt.I<ApprovalTemplateRepository>(),
lookupRepository: GetIt.I.isRegistered<InventoryLookupRepository>()
? GetIt.I<InventoryLookupRepository>()
: null,
)..addListener(_handleControllerUpdate);
WidgetsBinding.instance.addPostFrameCallback((_) async {
await Future.wait([
_controller.loadActionOptions(),
_controller.loadTemplates(),
_controller.loadStatusLookups(),
]);
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();
_transactionController.dispose();
_requesterController.dispose();
_transactionFocus.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 <Approval>[];
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: _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: 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,
),
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,
canProceed: _controller.canProceedSelected,
cannotProceedReason: _controller.cannotProceedReason,
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,
),
],
),
);
},
);
}
/// 신규 결재 등록 다이얼로그를 열어 UI 단계에서 필요한 필드와 안내를 제공한다.
Future<void> _openCreateApprovalDialog() async {
final transactionController = TextEditingController();
final noteController = TextEditingController();
var submitted = false;
final shouldShowToast = await showDialog<bool>(
context: context,
builder: (dialogContext) {
return StatefulBuilder(
builder: (context, setState) {
final shadTheme = ShadTheme.of(context);
final errorVisible =
submitted && transactionController.text.trim().isEmpty;
return SuperportDialog(
title: '신규 결재 등록',
description: '트랜잭션 정보를 입력하면 API 연동 시 자동 제출이 지원됩니다.',
constraints: const BoxConstraints(maxWidth: 480),
actions: [
ShadButton.ghost(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('닫기'),
),
ShadButton(
key: const ValueKey('approval_create_submit'),
onPressed: () {
final trimmed = transactionController.text.trim();
setState(() => submitted = true);
if (trimmed.isEmpty) {
return;
}
Navigator.of(dialogContext).pop(true);
},
child: const Text('임시 저장'),
),
],
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Text('트랜잭션 ID', style: shadTheme.textTheme.small),
const SizedBox(height: 8),
ShadInput(
key: const ValueKey('approval_create_transaction'),
controller: transactionController,
placeholder: const Text('예: 2404-TRX-001'),
onChanged: (_) => setState(() {}),
),
if (errorVisible)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
'트랜잭션 ID를 입력해야 결재 생성이 가능합니다.',
style: shadTheme.textTheme.small.copyWith(
color: Theme.of(context).colorScheme.error,
),
),
),
const SizedBox(height: 16),
Text('비고 (선택)', style: shadTheme.textTheme.small),
const SizedBox(height: 8),
ShadTextarea(
key: const ValueKey('approval_create_note'),
controller: noteController,
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(
'API 연동 준비 중',
style: TextStyle(fontWeight: FontWeight.w600),
),
SizedBox(height: 6),
Text(
'현재는 결재 생성 UI만 제공됩니다. 실제 저장은 백엔드 연동 이후 지원될 예정입니다.',
),
],
),
),
],
),
);
},
);
},
);
transactionController.dispose();
noteController.dispose();
if (shouldShowToast == true && mounted) {
SuperportToast.info(
context,
'결재 생성은 API 연동 이후 지원될 예정입니다. 입력한 값은 실제로 저장되지 않았습니다.',
);
}
}
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;
}
Future<void> _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<void> _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<bool> _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<bool>(
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) =>
_controller.statusLabel(filter);
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<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!),
),
];
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.canProceed,
required this.cannotProceedReason,
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<ApprovalTemplate> templates;
final bool isLoadingTemplates;
final bool isApplyingTemplate;
final int? applyingTemplateId;
final int? selectedTemplateId;
final bool canPerformStepActions;
final bool canApplyTemplate;
final bool canProceed;
final String? cannotProceedReason;
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,
canProceed: canProceed,
cannotProceedReason: cannotProceedReason,
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.canProceed,
required this.cannotProceedReason,
required this.onSelectTemplate,
required this.onApplyTemplate,
required this.onReloadTemplates,
required this.onAction,
});
final Approval approval;
final List<ApprovalStep> steps;
final intl.DateFormat dateFormat;
final bool hasActionOptions;
final bool isLoadingActions;
final bool isPerformingAction;
final int? processingStepId;
final List<ApprovalTemplate> templates;
final bool isLoadingTemplates;
final bool isApplyingTemplate;
final int? applyingTemplateId;
final int? selectedTemplateId;
final bool canPerformStepActions;
final bool canApplyTemplate;
final bool canProceed;
final String? cannotProceedReason;
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 (!canProceed)
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 12),
child: Text(
cannotProceedReason ?? '현재는 결재 단계를 진행할 수 없습니다.',
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,
canProceed,
cannotProceedReason,
);
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<Color>(
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,
bool canProceed,
String? cannotProceedReason,
) {
if (!canPerformStepActions) {
return '결재 행위를 수행할 권한이 없습니다.';
}
if (isLoadingActions) {
return '행위 목록을 불러오는 중입니다.';
}
if (!hasActionOptions) {
return '사용 가능한 결재 행위가 없습니다.';
}
if (!canProceed) {
return cannotProceedReason ?? '현재는 결재 단계를 진행할 수 없습니다.';
}
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<ApprovalTemplate> 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<int>(
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<int>(
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<ApprovalHistory> 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;
}