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

@@ -1,9 +1,14 @@
import 'dart:collection';
import 'package:flutter/foundation.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/core/network/failure.dart';
import '../../../domain/entities/approval_flow.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';
/// 결재 템플릿 목록에서 사용할 상태 필터.
enum ApprovalTemplateStatusFilter { all, activeOnly, inactiveOnly }
@@ -12,14 +17,25 @@ enum ApprovalTemplateStatusFilter { all, activeOnly, inactiveOnly }
///
/// - 목록/검색/필터 상태와 생성·수정·삭제 요청을 관리한다.
class ApprovalTemplateController extends ChangeNotifier {
ApprovalTemplateController({required ApprovalTemplateRepository repository})
: _repository = repository;
ApprovalTemplateController({
required ApprovalTemplateRepository repository,
SaveApprovalTemplateUseCase? saveTemplateUseCase,
ApplyApprovalTemplateUseCase? applyTemplateUseCase,
}) : _repository = repository,
_saveTemplateUseCase = saveTemplateUseCase,
_applyTemplateUseCase = applyTemplateUseCase;
final ApprovalTemplateRepository _repository;
final SaveApprovalTemplateUseCase? _saveTemplateUseCase;
final ApplyApprovalTemplateUseCase? _applyTemplateUseCase;
final Map<int, DateTime?> _templateVersions = <int, DateTime?>{};
final Map<int, List<ApprovalTemplateStep>> _templateStepSummaries =
<int, List<ApprovalTemplateStep>>{};
PaginatedResult<ApprovalTemplate>? _result;
bool _isLoading = false;
bool _isSubmitting = false;
bool _isApplyingTemplate = false;
String _query = '';
ApprovalTemplateStatusFilter _statusFilter = ApprovalTemplateStatusFilter.all;
String? _errorMessage;
@@ -32,6 +48,37 @@ class ApprovalTemplateController extends ChangeNotifier {
ApprovalTemplateStatusFilter get statusFilter => _statusFilter;
String? get errorMessage => _errorMessage;
int get pageSize => _result?.pageSize ?? _pageSize;
bool get isApplyingTemplate => _isApplyingTemplate;
UnmodifiableMapView<int, DateTime?> get templateVersions =>
UnmodifiableMapView(_templateVersions);
UnmodifiableMapView<int, List<ApprovalTemplateStep>>
get templateStepSummaries => UnmodifiableMapView(_templateStepSummaries);
/// 캐시된 템플릿 버전 정보를 반환한다.
DateTime? versionOf(int templateId) => _templateVersions[templateId];
/// 캐시된 단계 요약을 반환한다.
List<ApprovalTemplateStep>? stepSummaryOf(int templateId) =>
_templateStepSummaries[templateId];
/// 단계 요약이 없으면 상세를 조회해 캐시한다.
Future<List<ApprovalTemplateStep>?> ensureStepSummary(int templateId) async {
final cached = _templateStepSummaries[templateId];
if (cached != null && cached.isNotEmpty) {
return cached;
}
final detail = await fetchDetail(templateId);
return detail?.steps;
}
/// 서버 업데이트 일시와 비교해 로컬 버전이 뒤처졌는지 확인한다.
bool isTemplateStale(int templateId, DateTime? remoteUpdatedAt) {
final local = _templateVersions[templateId];
if (local == null || remoteUpdatedAt == null) {
return false;
}
return local.isBefore(remoteUpdatedAt);
}
/// 템플릿 목록을 조회해 캐시에 저장한다.
///
@@ -66,6 +113,10 @@ class ApprovalTemplateController extends ChangeNotifier {
);
_result = response;
_pageSize = response.pageSize;
_recordTemplateVersions(response.items);
for (final template in response.items) {
_cacheTemplateSteps(template);
}
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
@@ -95,6 +146,9 @@ class ApprovalTemplateController extends ChangeNotifier {
notifyListeners();
try {
final detail = await _repository.fetchDetail(id, includeSteps: true);
_recordTemplateVersion(detail);
_cacheTemplateSteps(detail);
notifyListeners();
return detail;
} catch (error) {
final failure = Failure.from(error);
@@ -108,20 +162,13 @@ class ApprovalTemplateController extends ChangeNotifier {
Future<ApprovalTemplate?> create(
ApprovalTemplateInput input,
List<ApprovalTemplateStepInput> steps,
) async {
_setSubmitting(true);
try {
final created = await _repository.create(input, steps: steps);
await fetch(page: 1);
return created;
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
notifyListeners();
return null;
} finally {
_setSubmitting(false);
}
) {
return _saveTemplate(
templateId: null,
input: input,
steps: steps,
refreshPage: 1,
);
}
/// 기존 템플릿을 수정하고 현재 페이지를 유지한 채 목록을 다시 가져온다.
@@ -129,20 +176,28 @@ class ApprovalTemplateController extends ChangeNotifier {
int id,
ApprovalTemplateInput input,
List<ApprovalTemplateStepInput>? steps,
) async {
_setSubmitting(true);
try {
final updated = await _repository.update(id, input, steps: steps);
await fetch(page: _result?.page ?? 1);
return updated;
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
notifyListeners();
return null;
} finally {
_setSubmitting(false);
}
) {
return _saveTemplate(
templateId: id,
input: input,
steps: steps,
refreshPage: _result?.page ?? 1,
);
}
/// 템플릿을 저장(create/update)하는 공통 진입점.
Future<ApprovalTemplate?> save({
int? templateId,
required ApprovalTemplateInput input,
List<ApprovalTemplateStepInput>? steps,
}) {
final refreshPage = templateId == null ? 1 : _result?.page ?? 1;
return _saveTemplate(
templateId: templateId,
input: input,
steps: steps,
refreshPage: refreshPage,
);
}
/// 템플릿을 삭제(비활성화)한 뒤 목록을 재조회한다.
@@ -167,6 +222,7 @@ class ApprovalTemplateController extends ChangeNotifier {
_setSubmitting(true);
try {
final restored = await _repository.restore(id);
_recordTemplateVersion(restored);
await fetch(page: _result?.page ?? 1);
return restored;
} catch (error) {
@@ -179,6 +235,44 @@ class ApprovalTemplateController extends ChangeNotifier {
}
}
/// 템플릿을 지정한 결재에 적용한다.
Future<ApprovalFlow?> applyToApproval({
required int approvalId,
required int templateId,
}) async {
final useCase = _applyTemplateUseCase;
if (useCase == null) {
throw StateError('ApplyApprovalTemplateUseCase가 주입되지 않았습니다.');
}
_errorMessage = null;
_isApplyingTemplate = true;
notifyListeners();
try {
final flow = await useCase.call(
approvalId: approvalId,
templateId: templateId,
);
try {
final template = await _repository.fetchDetail(
templateId,
includeSteps: false,
);
_recordTemplateVersion(template);
} catch (_) {
// 최신 템플릿 버전 조회 실패는 무시한다.
}
return flow;
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
notifyListeners();
return null;
} finally {
_isApplyingTemplate = false;
notifyListeners();
}
}
/// 오류 메시지를 초기화한다.
void clearError() {
_errorMessage = null;
@@ -210,4 +304,61 @@ class ApprovalTemplateController extends ChangeNotifier {
_isSubmitting = value;
notifyListeners();
}
Future<ApprovalTemplate?> _saveTemplate({
int? templateId,
required ApprovalTemplateInput input,
List<ApprovalTemplateStepInput>? steps,
required int refreshPage,
}) async {
_errorMessage = null;
_setSubmitting(true);
try {
final template = await _performSave(templateId, input, steps);
_recordTemplateVersion(template);
await fetch(page: refreshPage);
return template;
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
notifyListeners();
return null;
} finally {
_setSubmitting(false);
}
}
Future<ApprovalTemplate> _performSave(
int? templateId,
ApprovalTemplateInput input,
List<ApprovalTemplateStepInput>? steps,
) {
final useCase = _saveTemplateUseCase;
if (useCase != null) {
return useCase.call(templateId: templateId, input: input, steps: steps);
}
if (templateId == null) {
return _repository.create(input, steps: steps ?? const []);
}
return _repository.update(templateId, input, steps: steps);
}
void _recordTemplateVersions(Iterable<ApprovalTemplate> templates) {
for (final template in templates) {
_recordTemplateVersion(template);
}
}
void _recordTemplateVersion(ApprovalTemplate template) {
_templateVersions[template.id] = template.updatedAt;
}
void _cacheTemplateSteps(ApprovalTemplate template) {
if (template.steps.isEmpty) {
return;
}
_templateStepSummaries[template.id] = List<ApprovalTemplateStep>.from(
template.steps,
);
}
}

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});