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

@@ -13,6 +13,8 @@ import '../../../../../widgets/components/feature_disabled_placeholder.dart';
import '../../../shared/widgets/approver_autocomplete_field.dart';
import '../../../domain/entities/approval_template.dart';
import '../../../domain/repositories/approval_template_repository.dart';
import '../../../domain/usecases/apply_approval_template_use_case.dart';
import '../../../domain/usecases/save_approval_template_use_case.dart';
import '../controllers/approval_template_controller.dart';
/// 결재 템플릿 관리 페이지. 기능 플래그에 따라 준비중 화면을 노출한다.
@@ -76,6 +78,8 @@ class _ApprovalTemplateEnabledPageState
super.initState();
_controller = ApprovalTemplateController(
repository: GetIt.I<ApprovalTemplateRepository>(),
saveTemplateUseCase: GetIt.I<SaveApprovalTemplateUseCase>(),
applyTemplateUseCase: GetIt.I<ApplyApprovalTemplateUseCase>(),
)..addListener(_handleControllerUpdate);
WidgetsBinding.instance.addPostFrameCallback((_) async {
await _controller.fetch();
@@ -208,6 +212,7 @@ class _ApprovalTemplateEnabledPageState
ShadTableCell.header(child: Text('ID')),
ShadTableCell.header(child: Text('템플릿코드')),
ShadTableCell.header(child: Text('템플릿명')),
ShadTableCell.header(child: Text('결재 단계 요약')),
ShadTableCell.header(child: Text('설명')),
ShadTableCell.header(child: Text('사용')),
ShadTableCell.header(child: Text('변경일시')),
@@ -218,6 +223,13 @@ class _ApprovalTemplateEnabledPageState
ShadTableCell(child: Text('${template.id}')),
ShadTableCell(child: Text(template.code)),
ShadTableCell(child: Text(template.name)),
ShadTableCell(
child: _TemplateStepSummaryCell(
key: ValueKey('template_steps_${template.id}'),
controller: _controller,
template: template,
),
),
ShadTableCell(
child: Text(
template.description?.isNotEmpty == true
@@ -243,7 +255,17 @@ class _ApprovalTemplateEnabledPageState
alignment: Alignment.centerRight,
child: Wrap(
spacing: 8,
runSpacing: 6,
children: [
ShadButton.ghost(
key: ValueKey(
'template_preview_${template.id}',
),
size: ShadButtonSize.sm,
onPressed: () =>
_openTemplatePreview(template.id),
child: const Text('보기'),
),
ShadButton.ghost(
key: ValueKey(
'template_edit_${template.id}',
@@ -274,20 +296,26 @@ class _ApprovalTemplateEnabledPageState
),
];
}).toList(),
rowHeight: 56,
maxHeight: 480,
rowHeight: 58,
maxHeight: 520,
columnSpanExtent: (index) {
switch (index) {
case 2:
return const FixedTableSpanExtent(220);
case 3:
return const FixedTableSpanExtent(260);
case 4:
return const FixedTableSpanExtent(100);
case 5:
return const FixedTableSpanExtent(180);
case 6:
case 0:
return const FixedTableSpanExtent(80);
case 1:
return const FixedTableSpanExtent(160);
case 2:
return const FixedTableSpanExtent(200);
case 3:
return const FixedTableSpanExtent(300);
case 4:
return const FixedTableSpanExtent(220);
case 5:
return const FixedTableSpanExtent(100);
case 6:
return const FixedTableSpanExtent(180);
case 7:
return const FixedTableSpanExtent(220);
default:
return const FixedTableSpanExtent(140);
}
@@ -326,6 +354,99 @@ class _ApprovalTemplateEnabledPageState
_searchFocus.requestFocus();
}
Future<void> _openTemplatePreview(int templateId) async {
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => const Center(child: CircularProgressIndicator()),
);
final detail = await _controller.fetchDetail(templateId);
if (mounted) {
Navigator.of(context, rootNavigator: true).pop();
}
if (!mounted) {
return;
}
if (detail == null) {
final messenger = ScaffoldMessenger.maybeOf(context);
messenger?.showSnackBar(
const SnackBar(content: Text('템플릿 정보를 불러오지 못했습니다. 다시 시도하세요.')),
);
return;
}
final theme = ShadTheme.of(context);
await SuperportDialog.show<void>(
context: context,
dialog: SuperportDialog(
title: detail.name,
description: detail.description,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 540),
child: detail.steps.isEmpty
? Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Text('등록된 결재 단계가 없습니다.', style: theme.textTheme.muted),
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
for (final step in detail.steps) ...[
Row(
crossAxisAlignment: CrossAxisAlignment.start,
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}',
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),
],
],
),
),
),
);
}
Future<void> _openEditTemplate(ApprovalTemplate template) async {
showDialog<void>(
context: context,
@@ -700,6 +821,96 @@ class _ApprovalTemplateEnabledPageState
}
}
class _TemplateStepSummaryCell extends StatefulWidget {
const _TemplateStepSummaryCell({
super.key,
required this.controller,
required this.template,
});
final ApprovalTemplateController controller;
final ApprovalTemplate template;
@override
State<_TemplateStepSummaryCell> createState() =>
_TemplateStepSummaryCellState();
}
class _TemplateStepSummaryCellState extends State<_TemplateStepSummaryCell> {
bool _isLoading = false;
Future<void> _loadSummary() async {
if (_isLoading) return;
setState(() {
_isLoading = true;
});
try {
await widget.controller.ensureStepSummary(widget.template.id);
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
return AnimatedBuilder(
animation: widget.controller,
builder: (context, _) {
final steps =
widget.controller.stepSummaryOf(widget.template.id) ??
widget.template.steps;
if (steps.isEmpty) {
return ShadButton.outline(
size: ShadButtonSize.sm,
onPressed: _isLoading ? null : _loadSummary,
child: _isLoading
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('단계 불러오기'),
);
}
final displaySteps = steps.take(3).toList();
final overflow = steps.length - displaySteps.length;
final summaryText = steps
.map((step) => '${step.stepOrder}. ${step.approver.name}')
.join('');
return Tooltip(
message: summaryText,
preferBelow: false,
child: Wrap(
spacing: 6,
runSpacing: 6,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
for (final step in displaySteps)
ShadBadge(
child: Text(
'${step.stepOrder}. ${step.approver.name}',
style: theme.textTheme.small,
),
),
if (overflow > 0)
ShadBadge.outline(
child: Text('+$overflow', style: theme.textTheme.small),
),
],
),
);
},
);
}
}
class _FormField extends StatelessWidget {
const _FormField({required this.label, required this.child});