feat(approvals): Approval Flow v2 프런트엔드 전면 개편

- 환경/라우터 모듈에 approval_flow_v2 토글을 추가하고 FeatureFlags 초기화를 연결 (.env*, lib/core/**)
- ApiClient 빌더·ApiRoutes 확장과 ApprovalRepositoryRemote 리팩터링으로 include·액션 시그니처를 정합화
- ApprovalFlow·ApprovalDraft 엔티티/레포/유즈케이스를 도입해 서버 초안과 단계 액션(승인·회수·재상신)을 지원
- Approval 컨트롤러·히스토리·템플릿 페이지와 공유 위젯을 재작성해 감사 로그·회수 UX·템플릿 CRUD를 반영
- Inbound/Outbound/Rental 컨트롤러·페이지에 결재 섹션을 삽입하고 대시보드 pending 카드 요약을 갱신
- SuperportDialog·FormField 등 공통 위젯을 보강하고 승인 위젯 가이드를 추가해 UI 가이드를 정리
- 결재/재고 테스트 픽스처와 단위·위젯·통합 테스트를 확장하고 flutter_test_config로 스테이징 호스트를 허용
- Approval Flow 레포트/플랜 문서를 업데이트하고 ApprovalFlow_System_Integration_and_ChangePlan.md를 추가
- 실행: flutter analyze, flutter test
This commit is contained in:
JiWoong Sul
2025-10-31 01:05:39 +09:00
parent 259b056072
commit d76f765814
133 changed files with 13878 additions and 947 deletions

View File

@@ -0,0 +1,564 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../../../widgets/components/feedback.dart';
import '../../../../../widgets/components/superport_dialog.dart';
import '../../../shared/approver_catalog.dart';
import '../../../shared/widgets/approver_autocomplete_field.dart';
import '../controllers/approval_request_controller.dart';
import 'approval_step_row.dart';
import 'approval_template_picker.dart';
/// 결재 단계 구성을 요약하고 모달을 통해 편집할 수 있는 UI 섹션.
class ApprovalStepConfigurator extends StatefulWidget {
const ApprovalStepConfigurator({
super.key,
required this.controller,
this.readOnly = false,
});
/// 결재 단계 상태를 제어하는 컨트롤러.
final ApprovalRequestController controller;
/// 읽기 전용 모드 여부.
final bool readOnly;
@override
State<ApprovalStepConfigurator> createState() =>
_ApprovalStepConfiguratorState();
}
class _ApprovalStepConfiguratorState extends State<ApprovalStepConfigurator> {
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: widget.controller,
builder: (context, _) {
final theme = ShadTheme.of(context);
final steps = widget.controller.steps;
final requester = widget.controller.requester;
final finalApprover = widget.controller.finalApprover;
final templateSnapshot = widget.controller.templateSnapshot;
return ShadCard(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'결재 단계 구성',
style: theme.textTheme.h4.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
'상신자, 중간 승인자, 최종 승인자를 정의하고 템플릿으로 저장할 수 있습니다.',
style: theme.textTheme.muted,
),
],
),
),
ShadButton(
onPressed: widget.readOnly
? null
: () => _openConfiguratorDialog(context),
leading: const Icon(lucide.LucideIcons.settings2, size: 16),
child: const Text('단계 구성 편집'),
),
],
),
const SizedBox(height: 20),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_InfoBadge(
icon: lucide.LucideIcons.user,
label: '상신자',
value: requester?.name ?? '미지정',
),
_InfoBadge(
icon: lucide.LucideIcons.badgeCheck,
label: '최종 승인자',
value: finalApprover?.name ?? '미지정',
),
_InfoBadge(
icon: lucide.LucideIcons.listOrdered,
label: '총 단계',
value: '${steps.length}',
),
if (templateSnapshot != null)
_InfoBadge(
icon: lucide.LucideIcons.bookmarkCheck,
label: '적용 템플릿',
value: '#${templateSnapshot.templateId}',
),
],
),
const SizedBox(height: 16),
if (steps.isEmpty)
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 20,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: theme.colorScheme.border),
),
child: Text(
'등록된 결재 단계가 없습니다. 단계 구성 편집을 눌러 승인자를 추가하세요.',
style: theme.textTheme.muted,
),
)
else
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
..._buildStepSummary(theme, steps),
if (steps.length > 4)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
'+ ${steps.length - 4}개 단계 더 있음',
style: theme.textTheme.muted,
),
),
],
),
if (widget.controller.errorMessage != null) ...[
const SizedBox(height: 16),
Text(
widget.controller.errorMessage!,
style: theme.textTheme.small.copyWith(
color: theme.colorScheme.destructive,
),
),
],
],
),
);
},
);
}
List<Widget> _buildStepSummary(
ShadThemeData theme,
List<ApprovalRequestStep> steps,
) {
final limit = steps.length > 4 ? 4 : steps.length;
return [
for (var index = 0; index < limit; index++)
Padding(
padding: EdgeInsets.only(top: index == 0 ? 0 : 8),
child: Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
color: theme.colorScheme.secondary.withValues(alpha: 0.12),
),
alignment: Alignment.center,
child: Text(
'${steps[index].stepOrder}',
style: theme.textTheme.small.copyWith(
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
'${steps[index].approver.name} · ${steps[index].approver.employeeNo}',
style: theme.textTheme.small,
),
),
],
),
),
];
}
Future<void> _openConfiguratorDialog(BuildContext context) {
return SuperportDialog.show<void>(
context: context,
barrierDismissible: true,
dialog: SuperportDialog(
title: '결재 단계 구성',
description: '승인자 목록을 편집하고 템플릿을 적용하거나 저장합니다.',
child: _ConfiguratorDialogBody(
controller: widget.controller,
readOnly: widget.readOnly,
),
),
);
}
}
class _ConfiguratorDialogBody extends StatefulWidget {
const _ConfiguratorDialogBody({
required this.controller,
required this.readOnly,
});
final ApprovalRequestController controller;
final bool readOnly;
@override
State<_ConfiguratorDialogBody> createState() =>
_ConfiguratorDialogBodyState();
}
class _ConfiguratorDialogBodyState extends State<_ConfiguratorDialogBody> {
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
return AnimatedBuilder(
animation: widget.controller,
builder: (context, _) {
final steps = widget.controller.steps;
final duplicates = _collectDuplicateApproverIds(steps);
final isApplyingTemplate = widget.controller.isApplyingTemplate;
final hasReachedLimit = widget.controller.hasReachedStepLimit;
final requester = widget.controller.requester;
final hasRequesterConflict = widget.controller.hasRequesterConflict;
return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 720),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'상신자',
style: theme.textTheme.small.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 6),
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 14,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: theme.colorScheme.border),
),
child: Text(
requester == null
? '상신자가 아직 지정되지 않았습니다.'
: '${requester.name} · ${requester.employeeNo}',
style: theme.textTheme.small,
),
),
const SizedBox(height: 20),
IgnorePointer(
ignoring: widget.readOnly,
child: Opacity(
opacity: widget.readOnly ? 0.6 : 1,
child: ApprovalTemplatePicker(
controller: widget.controller,
),
),
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
'결재 단계 목록',
style: theme.textTheme.small.copyWith(
fontWeight: FontWeight.w700,
),
),
Row(
children: [
if (isApplyingTemplate) ...[
SizedBox(
width: 16,
height: 16,
child: const CircularProgressIndicator(
strokeWidth: 2,
),
),
const SizedBox(width: 8),
Text(
'템플릿을 적용하는 중입니다...',
style: theme.textTheme.muted,
),
const SizedBox(width: 16),
],
ShadButton.outline(
onPressed: widget.readOnly || hasReachedLimit
? null
: _openAddStepDialog,
child: Row(
mainAxisSize: MainAxisSize.min,
children: const [
Icon(lucide.LucideIcons.plus, size: 16),
SizedBox(width: 8),
Text('단계 추가'),
],
),
),
],
),
],
),
const SizedBox(height: 12),
if (steps.isEmpty)
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 24,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: theme.colorScheme.border),
),
child: Text(
'결재 단계를 추가해 주세요. 마지막 단계가 자동으로 최종 승인자로 사용됩니다.',
style: theme.textTheme.muted,
),
)
else
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 420),
child: ListView.separated(
shrinkWrap: true,
itemCount: steps.length,
physics: const BouncingScrollPhysics(),
separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final step = steps[index];
final isFinal = index == steps.length - 1;
return ApprovalStepRow(
key: ValueKey('approval_step_row_$index'),
controller: widget.controller,
step: step,
index: index,
isFinal: isFinal,
readOnly: widget.readOnly,
hasDuplicateApprover: duplicates.contains(
step.approverId,
),
isRequesterConflict: requester?.id == step.approverId,
onRemove: widget.readOnly
? null
: () => widget.controller.removeStepAt(index),
);
},
),
),
if (steps.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 12),
child: Text(
'각 단계 오른쪽 화살표 버튼으로 순서를 조정할 수 있습니다.',
style: theme.textTheme.muted,
),
),
if (duplicates.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 12),
child: Text(
'동일한 승인자가 중복된 단계가 있습니다. 승인자를 조정해 주세요.',
style: theme.textTheme.small.copyWith(
color: theme.colorScheme.destructive,
),
),
),
if (hasRequesterConflict)
Padding(
padding: const EdgeInsets.only(top: 12),
child: Text(
'상신자와 동일한 승인자는 구성에 포함될 수 없습니다. 다른 승인자를 선택하세요.',
style: theme.textTheme.small.copyWith(
color: theme.colorScheme.destructive,
),
),
),
if (hasReachedLimit)
Padding(
padding: const EdgeInsets.only(top: 12),
child: Text(
'결재 단계는 최대 ${widget.controller.maxSteps}개까지 추가할 수 있습니다.',
style: theme.textTheme.small.copyWith(
color: theme.colorScheme.mutedForeground,
),
),
),
if (widget.controller.errorMessage != null) ...[
const SizedBox(height: 12),
Text(
widget.controller.errorMessage!,
style: theme.textTheme.small.copyWith(
color: theme.colorScheme.destructive,
),
),
],
],
),
),
);
},
);
}
Set<int> _collectDuplicateApproverIds(List<ApprovalRequestStep> steps) {
final seen = <int>{};
final duplicates = <int>{};
for (final step in steps) {
final id = step.approverId;
if (!seen.add(id)) {
duplicates.add(id);
}
}
return duplicates;
}
Future<void> _openAddStepDialog() async {
ApprovalApproverCatalogItem? selected;
final idController = TextEditingController();
final result = await SuperportDialog.show<bool>(
context: context,
barrierDismissible: false,
dialog: SuperportDialog(
title: '결재 단계 추가',
description: '승인자를 검색해 새로운 결재 단계를 추가합니다.',
primaryAction: ShadButton(
onPressed: () => Navigator.of(context, rootNavigator: true).pop(true),
child: const Text('추가'),
),
secondaryAction: ShadButton.ghost(
onPressed: () =>
Navigator.of(context, rootNavigator: true).pop(false),
child: const Text('취소'),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Text('승인자'),
const SizedBox(height: 8),
ApprovalApproverAutocompleteField(
idController: idController,
onSelected: (item) => selected = item,
),
const SizedBox(height: 12),
Text(
'상신자와 중복되지 않도록 다른 승인자를 선택해야 합니다.',
style: ShadTheme.of(context).textTheme.muted,
),
],
),
),
);
if (!mounted) {
idController.dispose();
return;
}
if (result != true) {
idController.dispose();
return;
}
final participant = _resolveParticipant(selected, idController.text.trim());
if (participant == null) {
SuperportToast.warning(context, '유효한 승인자를 선택해주세요.');
idController.dispose();
return;
}
final added = widget.controller.addStep(approver: participant);
if (!added) {
final message = widget.controller.errorMessage ?? '결재 단계를 추가하지 못했습니다.';
SuperportToast.error(context, message);
} else {
SuperportToast.success(
context,
'"${participant.name}" 님을 단계 ${widget.controller.totalSteps}에 추가했습니다.',
);
}
idController.dispose();
}
ApprovalRequestParticipant? _resolveParticipant(
ApprovalApproverCatalogItem? selected,
String manualInput,
) {
if (selected != null) {
return ApprovalRequestParticipant(
id: selected.id,
name: selected.name,
employeeNo: selected.employeeNo,
);
}
final manualId = int.tryParse(manualInput);
if (manualId == null) {
return null;
}
final match = ApprovalApproverCatalog.byId(manualId);
if (match == null) {
return null;
}
return ApprovalRequestParticipant(
id: match.id,
name: match.name,
employeeNo: match.employeeNo,
);
}
}
class _InfoBadge extends StatelessWidget {
const _InfoBadge({
required this.icon,
required this.label,
required this.value,
});
final IconData icon;
final String label;
final String value;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
border: Border.all(color: theme.colorScheme.border),
color: theme.colorScheme.muted.withValues(alpha: 0.12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 16, color: theme.colorScheme.mutedForeground),
const SizedBox(width: 6),
Text('$label: $value', style: theme.textTheme.small),
],
),
);
}
}

View File

@@ -0,0 +1,386 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../../../widgets/components/feedback.dart';
import '../../../shared/approver_catalog.dart';
import '../../../shared/widgets/approver_autocomplete_field.dart';
import '../controllers/approval_request_controller.dart';
/// 결재 단계 테이블에서 단일 행을 편집하기 위한 위젯.
///
/// - 순번, 승인자 자동완성, 역할/메모 입력, 삭제 버튼을 한 번에 제공한다.
class ApprovalStepRow extends StatefulWidget {
const ApprovalStepRow({
super.key,
required this.controller,
required this.step,
required this.index,
this.onRemove,
this.isFinal = false,
this.hasDuplicateApprover = false,
this.isRequesterConflict = false,
this.readOnly = false,
});
/// 결재 단계 상태를 관리하는 컨트롤러.
final ApprovalRequestController controller;
/// 현재 행에 해당하는 단계 데이터.
final ApprovalRequestStep step;
/// 행 인덱스(0-base).
final int index;
/// 행 삭제 시 실행할 콜백.
final VoidCallback? onRemove;
/// 마지막 단계(최종 승인자)인지 여부.
final bool isFinal;
/// 승인자 중복 오류가 있는지 여부.
final bool hasDuplicateApprover;
/// 상신자와 중복되는 승인자인지 여부.
final bool isRequesterConflict;
/// 읽기 전용 모드 여부.
final bool readOnly;
@override
State<ApprovalStepRow> createState() => _ApprovalStepRowState();
}
class _ApprovalStepRowState extends State<ApprovalStepRow> {
late final TextEditingController _approverIdController;
late final TextEditingController _noteController;
late ApprovalRequestParticipant _currentApprover;
int _fieldVersion = 0;
@override
void initState() {
super.initState();
_currentApprover = widget.step.approver;
_approverIdController = TextEditingController(
text: widget.step.approverId.toString(),
);
_noteController = TextEditingController(text: widget.step.note ?? '');
}
@override
void didUpdateWidget(covariant ApprovalStepRow oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.step.approverId != widget.step.approverId) {
_currentApprover = widget.step.approver;
_approverIdController.text = widget.step.approverId.toString();
_refreshAutocompleteField();
}
if (oldWidget.step.note != widget.step.note &&
widget.step.note != _noteController.text) {
_noteController.text = widget.step.note ?? '';
}
}
@override
void dispose() {
_approverIdController.dispose();
_noteController.dispose();
super.dispose();
}
void _refreshAutocompleteField() {
setState(() {
_fieldVersion += 1;
});
}
Future<void> _handleApproverSelected(
BuildContext context,
ApprovalApproverCatalogItem? item,
) async {
if (widget.readOnly) {
return;
}
ApprovalRequestParticipant? nextParticipant;
if (item != null) {
nextParticipant = ApprovalRequestParticipant(
id: item.id,
name: item.name,
employeeNo: item.employeeNo,
);
} else {
final manualId = int.tryParse(_approverIdController.text.trim());
if (manualId == null) {
SuperportToast.warning(context, '승인자를 다시 선택해주세요.');
_restorePreviousApprover();
return;
}
final catalogMatch = ApprovalApproverCatalog.byId(manualId);
if (catalogMatch == null) {
SuperportToast.warning(context, '등록되지 않은 승인자입니다.');
_restorePreviousApprover();
return;
}
nextParticipant = ApprovalRequestParticipant(
id: catalogMatch.id,
name: catalogMatch.name,
employeeNo: catalogMatch.employeeNo,
);
}
final updated = widget.controller.updateStep(
widget.index,
approver: nextParticipant,
);
if (!updated) {
SuperportToast.error(
context,
widget.controller.errorMessage ?? '승인자 변경에 실패했습니다.',
);
_restorePreviousApprover();
return;
}
setState(() {
_currentApprover = nextParticipant!;
_approverIdController.text = nextParticipant.id.toString();
});
if (widget.hasDuplicateApprover) {
SuperportToast.warning(context, '동일한 승인자가 존재하지 않도록 구성해주세요.');
} else {
SuperportToast.info(
context,
'단계 ${widget.step.stepOrder} 승인자를 ${nextParticipant.name} 님으로 변경했습니다.',
);
}
}
void _restorePreviousApprover() {
_approverIdController.text = _currentApprover.id.toString();
_refreshAutocompleteField();
}
void _handleNoteChanged(String value) {
if (widget.readOnly) {
return;
}
final trimmed = value.trim();
widget.controller.updateStep(
widget.index,
note: trimmed.isEmpty ? null : trimmed,
);
}
void _handleMove(int offset) {
if (widget.readOnly) {
return;
}
final targetIndex = widget.index + offset;
final total = widget.controller.totalSteps;
if (targetIndex < 0 || targetIndex >= total) {
return;
}
widget.controller.moveStep(widget.index, targetIndex);
final direction = offset < 0 ? '위로' : '아래로';
SuperportToast.info(context, '결재 단계 순서를 $direction 조정했습니다.');
}
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
final hasError = widget.hasDuplicateApprover || widget.isRequesterConflict;
final borderColor = hasError
? theme.colorScheme.destructive
: theme.colorScheme.border;
final badgeColor = hasError
? theme.colorScheme.destructive
: theme.colorScheme.secondary;
return Container(
decoration: BoxDecoration(
border: Border.all(color: borderColor, width: 1),
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_StepBadge(order: widget.step.stepOrder, badgeColor: badgeColor),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.isFinal ? '최종 승인자' : '승인자',
style: theme.textTheme.small.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
IgnorePointer(
ignoring: widget.readOnly,
child: Opacity(
opacity: widget.readOnly ? 0.6 : 1,
child: ApprovalApproverAutocompleteField(
key: ValueKey(
'approver_field_${widget.index}_$_fieldVersion',
),
idController: _approverIdController,
onSelected: (item) =>
_handleApproverSelected(context, item),
hintText: '승인자 이름 또는 사번 검색',
),
),
),
],
),
),
const SizedBox(width: 12),
SizedBox(
width: 220,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'역할/메모',
style: theme.textTheme.small.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
ShadInput(
controller: _noteController,
onChanged: _handleNoteChanged,
enabled: !widget.readOnly,
placeholder: const Text('예: 팀장 승인'),
),
],
),
),
const SizedBox(width: 12),
SizedBox(
width: 44,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
onPressed: widget.readOnly || widget.index == 0
? null
: () => _handleMove(-1),
tooltip: '위로 이동',
icon: const Icon(lucide.LucideIcons.chevronUp, size: 18),
),
IconButton(
onPressed:
widget.readOnly ||
widget.index >= widget.controller.totalSteps - 1
? null
: () => _handleMove(1),
tooltip: '아래로 이동',
icon: const Icon(
lucide.LucideIcons.chevronDown,
size: 18,
),
),
],
),
),
const SizedBox(width: 4),
IconButton(
onPressed: widget.readOnly || widget.onRemove == null
? null
: widget.onRemove,
tooltip: '단계 삭제',
icon: Icon(
lucide.LucideIcons.trash2,
color: widget.readOnly || widget.onRemove == null
? theme.colorScheme.mutedForeground
: theme.colorScheme.destructive,
),
),
],
),
if (hasError)
Padding(
padding: const EdgeInsets.only(top: 12),
child: Row(
children: [
Icon(
lucide.LucideIcons.triangleAlert,
size: 16,
color: theme.colorScheme.destructive,
),
const SizedBox(width: 8),
Expanded(
child: Text(
widget.hasDuplicateApprover
? '동일한 승인자가 중복되어 있습니다. 다른 승인자를 선택해주세요.'
: '상신자는 승인자로 지정할 수 없습니다. 다른 승인자를 선택해주세요.',
style: theme.textTheme.small.copyWith(
color: theme.colorScheme.destructive,
),
),
),
],
),
),
if (widget.isFinal && !hasError)
Padding(
padding: const EdgeInsets.only(top: 12),
child: Row(
children: [
Icon(
lucide.LucideIcons.circleCheck,
size: 16,
color: theme.colorScheme.primary,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'마지막 단계가 최종 승인자로 처리됩니다.',
style: theme.textTheme.small.copyWith(
color: theme.colorScheme.primary,
),
),
),
],
),
),
],
),
);
}
}
class _StepBadge extends StatelessWidget {
const _StepBadge({required this.order, required this.badgeColor});
final int order;
final Color badgeColor;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
return Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: badgeColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: badgeColor.withValues(alpha: 0.4)),
),
alignment: Alignment.center,
child: Text(
order.toString(),
style: theme.textTheme.large.copyWith(
fontWeight: FontWeight.w700,
color: badgeColor,
),
),
);
}
}

View File

@@ -0,0 +1,626 @@
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../../../core/network/failure.dart';
import '../../../../../widgets/components/feedback.dart';
import '../../../domain/entities/approval_template.dart';
import '../../../domain/repositories/approval_template_repository.dart';
import '../../../domain/usecases/save_approval_template_use_case.dart';
import '../controllers/approval_request_controller.dart';
/// 템플릿을 불러오고 적용/저장할 수 있는 결재 템플릿 선택 위젯.
class ApprovalTemplatePicker extends StatefulWidget {
const ApprovalTemplatePicker({
super.key,
required this.controller,
this.repository,
this.saveUseCase,
this.onTemplateApplied,
this.onTemplatesChanged,
});
/// 결재 단계 상태를 제어하는 컨트롤러.
final ApprovalRequestController controller;
/// 템플릿 저장소. 지정하지 않으면 [GetIt]에서 조회한다.
final ApprovalTemplateRepository? repository;
/// 템플릿 저장 유즈케이스. 지정하지 않으면 [GetIt]에서 조회한다.
final SaveApprovalTemplateUseCase? saveUseCase;
/// 템플릿 적용이 완료됐을 때 호출되는 콜백.
final void Function(ApprovalTemplate template)? onTemplateApplied;
/// 템플릿 목록이 갱신됐을 때 호출되는 콜백.
final void Function(List<ApprovalTemplate> templates)? onTemplatesChanged;
@override
State<ApprovalTemplatePicker> createState() => _ApprovalTemplatePickerState();
}
class _ApprovalTemplatePickerState extends State<ApprovalTemplatePicker> {
List<ApprovalTemplate> _templates = const <ApprovalTemplate>[];
int? _selectedTemplateId;
bool _isLoading = false;
bool _isSaving = false;
String? _error;
ApprovalTemplateRepository? get _repository =>
widget.repository ??
(GetIt.I.isRegistered<ApprovalTemplateRepository>()
? GetIt.I<ApprovalTemplateRepository>()
: null);
SaveApprovalTemplateUseCase? get _saveUseCase =>
widget.saveUseCase ??
(GetIt.I.isRegistered<SaveApprovalTemplateUseCase>()
? GetIt.I<SaveApprovalTemplateUseCase>()
: null);
@override
void initState() {
super.initState();
_selectedTemplateId = widget.controller.templateSnapshot?.templateId;
WidgetsBinding.instance.addPostFrameCallback((_) {
_loadTemplates();
});
}
@override
void didUpdateWidget(covariant ApprovalTemplatePicker oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.controller.templateSnapshot?.templateId !=
oldWidget.controller.templateSnapshot?.templateId) {
setState(() {
_selectedTemplateId = widget.controller.templateSnapshot?.templateId;
});
}
}
Future<void> _loadTemplates() async {
final repository = _repository;
if (repository == null) {
setState(() {
_error = '결재 템플릿 저장소가 등록되지 않아 목록을 불러올 수 없습니다.';
});
return;
}
setState(() {
_isLoading = true;
_error = null;
});
try {
final result = await repository.list(
page: 1,
pageSize: 30,
isActive: true,
);
setState(() {
_templates = result.items;
if (_selectedTemplateId != null &&
!_templates.any((template) => template.id == _selectedTemplateId)) {
_selectedTemplateId = null;
}
});
widget.onTemplatesChanged?.call(result.items);
} catch (error) {
final failure = Failure.from(error);
setState(() {
_error = failure.describe();
});
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
Future<void> _applyTemplate(BuildContext context) async {
final repository = _repository;
final templateId = _selectedTemplateId;
if (repository == null || templateId == null) {
return;
}
setState(() {
_isLoading = true;
_error = null;
});
try {
final detail = await repository.fetchDetail(
templateId,
includeSteps: true,
);
if (!context.mounted) {
return;
}
if (detail.steps.isEmpty) {
throw StateError('단계가 없는 템플릿은 적용할 수 없습니다.');
}
final steps = detail.steps
.map(
(step) => ApprovalRequestStep(
stepOrder: step.stepOrder,
approver: ApprovalRequestParticipant(
id: step.approver.id,
name: step.approver.name,
employeeNo: step.approver.employeeNo,
),
note: step.note,
),
)
.toList(growable: false);
widget.controller.applyTemplateSteps(steps);
widget.controller.setTemplateSnapshot(
ApprovalTemplateSnapshot(
templateId: detail.id,
updatedAt: detail.updatedAt,
),
);
widget.onTemplateApplied?.call(detail);
SuperportToast.success(context, '템플릿 "${detail.name}"을(를) 적용했습니다.');
} catch (error) {
final failure = Failure.from(error);
if (!mounted) {
return;
}
setState(() {
_error = failure.describe();
});
if (context.mounted) {
SuperportToast.error(context, failure.describe());
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
Future<void> _openTemplatePreview(BuildContext context) async {
final repository = _repository;
final templateId = _selectedTemplateId;
if (repository == null || templateId == null) {
SuperportToast.info(context, '미리볼 템플릿을 먼저 선택하세요.');
return;
}
try {
final detail = await repository.fetchDetail(
templateId,
includeSteps: true,
);
if (!context.mounted) return;
await showDialog<void>(
context: context,
builder: (_) {
final theme = ShadTheme.of(context);
return Dialog(
insetPadding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'템플릿 미리보기',
style: theme.textTheme.h4.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 12),
Text(detail.name, style: theme.textTheme.small),
if (detail.description?.isNotEmpty ?? false) ...[
const SizedBox(height: 4),
Text(detail.description!, style: theme.textTheme.muted),
],
const SizedBox(height: 16),
if (detail.steps.isEmpty)
Text('등록된 단계가 없습니다.', style: theme.textTheme.muted)
else
Column(
children: [
for (final step in detail.steps) ...[
Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
color: theme.colorScheme.secondary
.withValues(alpha: 0.12),
),
alignment: Alignment.center,
child: Text(
step.stepOrder.toString(),
style: theme.textTheme.small.copyWith(
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
step.approver.name,
style: theme.textTheme.small.copyWith(
fontWeight: FontWeight.w600,
),
),
Text(
'사번 ${step.approver.employeeNo}',
style: theme.textTheme.muted,
),
if (step.note?.isNotEmpty ?? false)
Text(
step.note!,
style: theme.textTheme.muted,
),
],
),
),
],
),
const SizedBox(height: 12),
],
],
),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('닫기'),
),
),
],
),
),
),
);
},
);
} catch (error) {
final failure = Failure.from(error);
if (!context.mounted) {
return;
}
SuperportToast.error(context, failure.describe());
}
}
Future<void> _openSaveTemplateDialog(BuildContext context) async {
final steps = widget.controller.steps;
if (steps.isEmpty) {
SuperportToast.warning(context, '저장할 결재 단계가 없습니다.');
return;
}
final saveUseCase = _saveUseCase;
if (saveUseCase == null) {
SuperportToast.error(context, '템플릿 저장 유즈케이스가 등록되지 않았습니다.');
return;
}
final nameController = TextEditingController();
final codeController = TextEditingController();
final descriptionController = TextEditingController();
final noteController = TextEditingController();
String? errorText;
await showDialog<bool>(
context: context,
barrierDismissible: !_isSaving,
builder: (dialogContext) {
final theme = ShadTheme.of(dialogContext);
return StatefulBuilder(
builder: (context, setModalState) {
Future<void> handleSubmit() async {
if (_isSaving) return;
final nameText = nameController.text.trim();
if (nameText.isEmpty) {
setModalState(() {
errorText = '템플릿명을 입력해주세요.';
});
return;
}
final stepInputs = steps
.map(
(step) => ApprovalTemplateStepInput(
stepOrder: step.stepOrder,
approverId: step.approverId,
note: step.note,
),
)
.toList(growable: false);
final input = ApprovalTemplateInput(
code: codeController.text.trim().isEmpty
? null
: codeController.text.trim(),
name: nameText,
description: descriptionController.text.trim().isEmpty
? null
: descriptionController.text.trim(),
note: noteController.text.trim().isEmpty
? null
: noteController.text.trim(),
isActive: true,
);
setModalState(() {
_isSaving = true;
errorText = null;
});
try {
final template = await saveUseCase.call(
templateId: null,
input: input,
steps: stepInputs,
);
if (!context.mounted) return;
Navigator.of(dialogContext).pop(true);
if (!context.mounted) return;
SuperportToast.success(
context,
'템플릿 "${template.name}"을(를) 저장했습니다.',
);
widget.controller.setTemplateSnapshot(
ApprovalTemplateSnapshot(
templateId: template.id,
updatedAt: template.updatedAt,
),
);
await _loadTemplates();
if (mounted) {
setState(() {
_selectedTemplateId = template.id;
_error = null;
});
}
} catch (error) {
final failure = Failure.from(error);
setModalState(() {
_isSaving = false;
errorText = failure.describe();
});
if (context.mounted) {
SuperportToast.error(context, failure.describe());
}
}
}
return Dialog(
insetPadding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 520),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'템플릿으로 저장',
style: theme.textTheme.h4.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 12),
ShadInput(
controller: nameController,
placeholder: const Text('템플릿명 (필수)'),
),
const SizedBox(height: 12),
ShadInput(
controller: codeController,
placeholder: const Text('템플릿 코드 (선택)'),
),
const SizedBox(height: 12),
ShadTextarea(
controller: descriptionController,
minHeight: 80,
maxHeight: 160,
placeholder: const Text('설명 (선택)'),
),
const SizedBox(height: 12),
ShadTextarea(
controller: noteController,
minHeight: 80,
maxHeight: 160,
placeholder: const Text('비고/안내 문구 (선택)'),
),
if (errorText != null) ...[
const SizedBox(height: 12),
Text(
errorText!,
style: theme.textTheme.small.copyWith(
color: theme.colorScheme.destructive,
),
),
],
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: _isSaving
? null
: () => Navigator.of(dialogContext).pop(false),
child: const Text('취소'),
),
const SizedBox(width: 12),
ShadButton(
onPressed: _isSaving ? null : handleSubmit,
child: _isSaving
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Text('저장'),
),
],
),
],
),
),
),
);
},
);
},
);
nameController.dispose();
codeController.dispose();
descriptionController.dispose();
noteController.dispose();
if (mounted) {
setState(() {
_isSaving = false;
});
}
}
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
final snapshot = widget.controller.templateSnapshot;
ApprovalTemplate? selectedTemplate;
for (final template in _templates) {
if (template.id == _selectedTemplateId) {
selectedTemplate = template;
break;
}
}
final isUpToDate = selectedTemplate == null
? true
: widget.controller.isTemplateUpToDate(selectedTemplate.updatedAt);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: ShadSelect<int?>(
placeholder: const Text('템플릿 선택'),
initialValue: selectedTemplate?.id,
enabled: !_isLoading,
onChanged: (value) {
setState(() {
_selectedTemplateId = value;
});
},
selectedOptionBuilder: (context, value) {
if (value == null) {
return const Text('템플릿 선택');
}
ApprovalTemplate? match;
for (final template in _templates) {
if (template.id == value) {
match = template;
break;
}
}
match ??= selectedTemplate;
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 : () => _loadTemplates(),
child: Row(
mainAxisSize: MainAxisSize.min,
children: const [
Icon(lucide.LucideIcons.refreshCw, size: 16),
SizedBox(width: 6),
Text('새로고침'),
],
),
),
const SizedBox(width: 12),
ShadButton.ghost(
onPressed: () => _openTemplatePreview(context),
child: const Text('미리보기'),
),
const SizedBox(width: 12),
ShadButton(
onPressed: (_isLoading || _selectedTemplateId == null)
? null
: () => _applyTemplate(context),
child: _isLoading && _selectedTemplateId != null
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('템플릿 적용'),
),
],
),
const SizedBox(height: 8),
Row(
children: [
ShadButton.outline(
onPressed: _isSaving
? null
: () => _openSaveTemplateDialog(context),
child: Row(
mainAxisSize: MainAxisSize.min,
children: const [
Icon(lucide.LucideIcons.save, size: 16),
SizedBox(width: 6),
Text('현재 단계를 템플릿으로 저장'),
],
),
),
if (snapshot != null) ...[
const SizedBox(width: 12),
Text(
'선택됨: #${snapshot.templateId}',
style: theme.textTheme.small,
),
],
],
),
if (_error != null) ...[
const SizedBox(height: 12),
Text(
_error!,
style: theme.textTheme.small.copyWith(
color: theme.colorScheme.destructive,
),
),
] else if (selectedTemplate != null) ...[
const SizedBox(height: 12),
Text(
isUpToDate
? '템플릿 "${selectedTemplate.name}"이(가) 적용 대기 중입니다.'
: '템플릿 "${selectedTemplate.name}"이(가) 서버 버전과 달라져 재적용이 필요합니다.',
style: theme.textTheme.small.copyWith(
color: isUpToDate
? theme.colorScheme.mutedForeground
: theme.colorScheme.destructive,
),
),
],
],
);
}
}

View File

@@ -0,0 +1,3 @@
export 'approval_step_configurator.dart';
export 'approval_step_row.dart';
export 'approval_template_picker.dart';