1487 lines
50 KiB
Dart
1487 lines
50 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 '../../../../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/spec_page.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 SpecPage(
|
|
title: '결재 관리',
|
|
summary: '결재 요청 상태와 단계/이력을 모니터링합니다.',
|
|
trailing: ShadBadge(
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: const [
|
|
Icon(lucide.LucideIcons.info, size: 14),
|
|
SizedBox(width: 6),
|
|
Text('비활성화 (백엔드 준비 중)'),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
sections: const [
|
|
SpecSection(
|
|
title: '입력 폼',
|
|
items: [
|
|
'트랜잭션번호 [Dropdown]',
|
|
'상신자 [ReadOnly]',
|
|
'결재상태 [Dropdown]',
|
|
'비고 [Textarea]',
|
|
],
|
|
),
|
|
SpecSection(
|
|
title: '상세 패널',
|
|
items: [
|
|
'개요 탭: 현재 상태/단계/요청·결정 일시',
|
|
'단계 탭: 단계 리스트 + 템플릿 불러오기',
|
|
'이력 탭: 행위/상태 변경/일시/비고',
|
|
],
|
|
),
|
|
SpecSection(
|
|
title: '테이블 리스트',
|
|
description: '1행 예시',
|
|
table: SpecTable(
|
|
columns: [
|
|
'번호',
|
|
'결재번호',
|
|
'트랜잭션번호',
|
|
'상태',
|
|
'상신자',
|
|
'요청일시',
|
|
'최종결정일시',
|
|
'비고',
|
|
],
|
|
rows: [
|
|
[
|
|
'1',
|
|
'AP-24001',
|
|
'TRX-202404-01',
|
|
'대기',
|
|
'김철수',
|
|
'2024-04-01 09:12',
|
|
'-',
|
|
'-',
|
|
],
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
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<ApprovalRepository>(),
|
|
templateRepository: GetIt.I<ApprovalTemplateRepository>(),
|
|
)..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 <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,
|
|
);
|
|
|
|
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<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(),
|
|
),
|
|
),
|
|
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,
|
|
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<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) {
|
|
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<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.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 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,
|
|
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.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 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: canPerformStepActions,
|
|
onSelectTemplate: onSelectTemplate,
|
|
onApplyTemplate: onApplyTemplate,
|
|
onReload: onReloadTemplates,
|
|
),
|
|
),
|
|
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<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) {
|
|
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}';
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|