1828 lines
66 KiB
Dart
1828 lines
66 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 approvalNoController = TextEditingController();
|
|
final transactionController = TextEditingController();
|
|
final requesterController = TextEditingController();
|
|
final noteController = TextEditingController();
|
|
InventoryEmployeeSuggestion? requesterSelection;
|
|
int? statusId = _controller.defaultApprovalStatusId;
|
|
String? transactionError;
|
|
String? approvalNoError;
|
|
String? statusError;
|
|
String? requesterError;
|
|
|
|
final created = await showDialog<bool>(
|
|
context: context,
|
|
builder: (_) {
|
|
return StatefulBuilder(
|
|
builder: (context, setState) {
|
|
return AnimatedBuilder(
|
|
animation: _controller,
|
|
builder: (context, _) {
|
|
final shadTheme = ShadTheme.of(context);
|
|
final materialTheme = Theme.of(context);
|
|
final statusOptions = _controller.approvalStatusOptions;
|
|
final isSubmitting = _controller.isSubmitting;
|
|
statusId ??= _controller.defaultApprovalStatusId;
|
|
|
|
return SuperportDialog(
|
|
title: '신규 결재 등록',
|
|
description: '트랜잭션과 결재 정보를 입력하면 즉시 생성됩니다.',
|
|
constraints: const BoxConstraints(maxWidth: 540),
|
|
actions: [
|
|
ShadButton.ghost(
|
|
onPressed: isSubmitting
|
|
? null
|
|
: () => Navigator.of(context).pop(false),
|
|
child: const Text('취소'),
|
|
),
|
|
ShadButton(
|
|
key: const ValueKey('approval_create_submit'),
|
|
onPressed: isSubmitting
|
|
? null
|
|
: () async {
|
|
final approvalNo = approvalNoController.text
|
|
.trim();
|
|
final transactionText = transactionController.text
|
|
.trim();
|
|
final transactionId = int.tryParse(
|
|
transactionText,
|
|
);
|
|
final note = noteController.text.trim();
|
|
final hasStatuses = statusOptions.isNotEmpty;
|
|
|
|
setState(() {
|
|
approvalNoError = approvalNo.isEmpty
|
|
? '결재번호를 입력하세요.'
|
|
: null;
|
|
transactionError = transactionText.isEmpty
|
|
? '트랜잭션 ID를 입력하세요.'
|
|
: (transactionId == null
|
|
? '트랜잭션 ID는 숫자만 입력하세요.'
|
|
: null);
|
|
statusError = (!hasStatuses || statusId == null)
|
|
? '결재 상태를 선택하세요.'
|
|
: null;
|
|
requesterError = requesterSelection == null
|
|
? '상신자를 선택하세요.'
|
|
: null;
|
|
});
|
|
|
|
if (approvalNoError != null ||
|
|
transactionError != null ||
|
|
statusError != null ||
|
|
requesterError != null) {
|
|
return;
|
|
}
|
|
|
|
final input = ApprovalCreateInput(
|
|
transactionId: transactionId!,
|
|
approvalNo: approvalNo,
|
|
approvalStatusId: statusId!,
|
|
requestedById: requesterSelection!.id,
|
|
note: note.isEmpty ? null : note,
|
|
);
|
|
final result = await _controller.createApproval(
|
|
input,
|
|
);
|
|
if (!mounted || !context.mounted) {
|
|
return;
|
|
}
|
|
if (result != null) {
|
|
Navigator.of(context).pop(true);
|
|
}
|
|
},
|
|
child: isSubmitting
|
|
? const SizedBox(
|
|
width: 16,
|
|
height: 16,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Text('등록'),
|
|
),
|
|
],
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text('결재번호', style: shadTheme.textTheme.small),
|
|
const SizedBox(height: 8),
|
|
ShadInput(
|
|
controller: approvalNoController,
|
|
enabled: !isSubmitting,
|
|
placeholder: const Text('예: APP-2025-0001'),
|
|
onChanged: (_) {
|
|
if (approvalNoError != null) {
|
|
setState(() => approvalNoError = null);
|
|
}
|
|
},
|
|
),
|
|
if (approvalNoError != null)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 6),
|
|
child: Text(
|
|
approvalNoError!,
|
|
style: shadTheme.textTheme.small.copyWith(
|
|
color: materialTheme.colorScheme.error,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text('트랜잭션 ID', style: shadTheme.textTheme.small),
|
|
const SizedBox(height: 8),
|
|
ShadInput(
|
|
key: const ValueKey('approval_create_transaction'),
|
|
controller: transactionController,
|
|
enabled: !isSubmitting,
|
|
placeholder: const Text('예: 9001'),
|
|
onChanged: (_) {
|
|
if (transactionError != null) {
|
|
setState(() => transactionError = null);
|
|
}
|
|
},
|
|
),
|
|
if (transactionError != null)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 6),
|
|
child: Text(
|
|
transactionError!,
|
|
style: shadTheme.textTheme.small.copyWith(
|
|
color: materialTheme.colorScheme.error,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text('결재 상태', style: shadTheme.textTheme.small),
|
|
const SizedBox(height: 8),
|
|
if (statusOptions.isEmpty)
|
|
Container(
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(
|
|
color: shadTheme.colorScheme.border,
|
|
),
|
|
),
|
|
child: Text(
|
|
'결재 상태 정보를 불러오지 못했습니다. 다시 시도해주세요.',
|
|
style: shadTheme.textTheme.muted,
|
|
),
|
|
)
|
|
else
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
ShadSelect<int>(
|
|
key: ValueKey(statusOptions.length),
|
|
initialValue: statusId,
|
|
enabled: !isSubmitting,
|
|
placeholder: const Text('결재 상태 선택'),
|
|
onChanged: (value) {
|
|
setState(() {
|
|
statusId = value;
|
|
statusError = null;
|
|
});
|
|
},
|
|
selectedOptionBuilder: (context, value) {
|
|
final selected = _controller.approvalStatusById(
|
|
value,
|
|
);
|
|
return Text(selected?.name ?? '결재 상태 선택');
|
|
},
|
|
options: statusOptions
|
|
.map(
|
|
(item) => ShadOption<int>(
|
|
value: item.id,
|
|
child: Text(item.name),
|
|
),
|
|
)
|
|
.toList(),
|
|
),
|
|
if (statusError != null)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 6),
|
|
child: Text(
|
|
statusError!,
|
|
style: shadTheme.textTheme.small.copyWith(
|
|
color: materialTheme.colorScheme.error,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text('상신자', style: shadTheme.textTheme.small),
|
|
const SizedBox(height: 8),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: InventoryEmployeeAutocompleteField(
|
|
controller: requesterController,
|
|
initialSuggestion: requesterSelection,
|
|
onSuggestionSelected: (suggestion) {
|
|
setState(() {
|
|
requesterSelection = suggestion;
|
|
requesterError = null;
|
|
});
|
|
},
|
|
onChanged: () {
|
|
if (requesterController.text.trim().isEmpty) {
|
|
setState(() {
|
|
requesterSelection = null;
|
|
});
|
|
}
|
|
},
|
|
enabled: !isSubmitting,
|
|
placeholder: '상신자 이름 또는 사번 검색',
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
ShadButton.ghost(
|
|
onPressed:
|
|
requesterSelection == null &&
|
|
requesterController.text.isEmpty
|
|
? null
|
|
: () {
|
|
setState(() {
|
|
requesterSelection = null;
|
|
requesterController.clear();
|
|
requesterError = null;
|
|
});
|
|
},
|
|
child: const Text('초기화'),
|
|
),
|
|
],
|
|
),
|
|
if (requesterError != null)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 6),
|
|
child: Text(
|
|
requesterError!,
|
|
style: shadTheme.textTheme.small.copyWith(
|
|
color: materialTheme.colorScheme.error,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text('비고 (선택)', style: shadTheme.textTheme.small),
|
|
const SizedBox(height: 8),
|
|
ShadTextarea(
|
|
key: const ValueKey('approval_create_note'),
|
|
controller: noteController,
|
|
enabled: !isSubmitting,
|
|
minHeight: 120,
|
|
maxHeight: 220,
|
|
),
|
|
const SizedBox(height: 16),
|
|
Container(
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: shadTheme.colorScheme.mutedForeground
|
|
.withValues(alpha: 0.08),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: const [
|
|
Text(
|
|
'저장 안내',
|
|
style: TextStyle(fontWeight: FontWeight.w600),
|
|
),
|
|
SizedBox(height: 6),
|
|
Text(
|
|
'저장 시 결재가 생성되고 첫 단계와 현재 상태가 API 규격에 맞춰 초기화됩니다. 등록 후 목록이 자동으로 갱신됩니다.',
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
|
|
approvalNoController.dispose();
|
|
transactionController.dispose();
|
|
requesterController.dispose();
|
|
noteController.dispose();
|
|
|
|
if (created == true && mounted) {
|
|
SuperportToast.success(context, '결재를 생성했습니다.');
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|