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:
@@ -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});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user