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,3 +1,5 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:superport_v2/core/network/failure.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
@@ -6,13 +8,18 @@ import 'package:superport_v2/core/common/utils/pagination_utils.dart';
import '../../../inventory/lookups/domain/entities/lookup_item.dart';
import '../../../inventory/lookups/domain/repositories/inventory_lookup_repository.dart';
import '../../domain/entities/approval.dart';
import '../../domain/entities/approval_draft.dart';
import '../../domain/entities/approval_proceed_status.dart';
import '../../domain/entities/approval_template.dart';
import '../../domain/repositories/approval_repository.dart';
import '../../domain/repositories/approval_template_repository.dart';
import '../../domain/usecases/get_approval_draft_use_case.dart';
import '../../domain/usecases/list_approval_drafts_use_case.dart';
import '../../domain/usecases/save_approval_draft_use_case.dart';
enum ApprovalStatusFilter {
all,
draft,
pending,
inProgress,
onHold,
@@ -29,6 +36,7 @@ const Map<ApprovalStepActionType, List<String>> _actionAliases = {
};
const Map<ApprovalStatusFilter, String> _defaultStatusCodes = {
ApprovalStatusFilter.draft: 'draft',
ApprovalStatusFilter.pending: 'pending',
ApprovalStatusFilter.inProgress: 'in_progress',
ApprovalStatusFilter.onHold: 'on_hold',
@@ -36,6 +44,12 @@ const Map<ApprovalStatusFilter, String> _defaultStatusCodes = {
ApprovalStatusFilter.rejected: 'rejected',
};
const List<String> _pendingFallbackStatusCodes = [
'draft',
'submitted',
'in_progress',
];
/// 결재 목록 및 상세 화면 상태 컨트롤러
///
/// - 목록 조회/필터 상태와 선택된 결재 상세 데이터를 관리한다.
@@ -45,13 +59,22 @@ class ApprovalController extends ChangeNotifier {
required ApprovalRepository approvalRepository,
required ApprovalTemplateRepository templateRepository,
InventoryLookupRepository? lookupRepository,
SaveApprovalDraftUseCase? saveDraftUseCase,
GetApprovalDraftUseCase? getDraftUseCase,
ListApprovalDraftsUseCase? listDraftsUseCase,
}) : _repository = approvalRepository,
_templateRepository = templateRepository,
_lookupRepository = lookupRepository;
_lookupRepository = lookupRepository,
_saveDraftUseCase = saveDraftUseCase,
_getDraftUseCase = getDraftUseCase,
_listDraftsUseCase = listDraftsUseCase;
final ApprovalRepository _repository;
final ApprovalTemplateRepository _templateRepository;
final InventoryLookupRepository? _lookupRepository;
final SaveApprovalDraftUseCase? _saveDraftUseCase;
final GetApprovalDraftUseCase? _getDraftUseCase;
final ListApprovalDraftsUseCase? _listDraftsUseCase;
PaginatedResult<Approval>? _result;
Approval? _selected;
@@ -65,6 +88,7 @@ class ApprovalController extends ChangeNotifier {
bool _isApplyingTemplate = false;
int? _applyingTemplateId;
ApprovalProceedStatus? _proceedStatus;
ApprovalSubmissionInput? _submissionDraft;
String? _errorMessage;
ApprovalStatusFilter _statusFilter = ApprovalStatusFilter.all;
int? _transactionIdFilter;
@@ -111,6 +135,9 @@ class ApprovalController extends ChangeNotifier {
return reason;
}
ApprovalSubmissionInput? get submissionDraft => _submissionDraft;
bool get hasSubmissionDraft => _submissionDraft != null;
List<LookupItem> get approvalStatusOptions => _statusOptions;
int? get defaultApprovalStatusId {
@@ -163,12 +190,15 @@ class ApprovalController extends ChangeNotifier {
resolvedPage = page;
}
final statusId = _statusIdFor(_statusFilter);
final statusCodes = _statusCodesFor(_statusFilter);
final response = await _repository.list(
page: resolvedPage,
pageSize: _result?.pageSize ?? 20,
transactionId: _transactionIdFilter,
approvalStatusId: statusId,
requestedById: _requestedById,
statusCodes: statusCodes.isEmpty ? null : statusCodes,
includePending: _statusFilter == ApprovalStatusFilter.all,
includeSteps: false,
includeHistories: false,
);
@@ -255,7 +285,7 @@ class ApprovalController extends ChangeNotifier {
String statusLabel(ApprovalStatusFilter filter) {
if (filter == ApprovalStatusFilter.all) {
return '전체 상태';
return '전체 상태 (임시저장·진행 포함)';
}
final code = _statusCodeFor(filter);
if (code != null) {
@@ -271,6 +301,7 @@ class ApprovalController extends ChangeNotifier {
ApprovalStatusFilter.onHold => '보류',
ApprovalStatusFilter.approved => '승인완료',
ApprovalStatusFilter.rejected => '반려',
ApprovalStatusFilter.draft => '임시저장',
ApprovalStatusFilter.all => '전체 상태',
};
}
@@ -295,6 +326,22 @@ class ApprovalController extends ChangeNotifier {
return lookup?.id;
}
List<String> _statusCodesFor(ApprovalStatusFilter filter) {
if (filter == ApprovalStatusFilter.all) {
return const <String>[];
}
final code = _statusCodeFor(filter);
if (filter == ApprovalStatusFilter.pending) {
if (code == null || code.toLowerCase() == 'pending') {
return List<String>.unmodifiable(_pendingFallbackStatusCodes);
}
}
if (code == null || code.isEmpty) {
return const <String>[];
}
return List<String>.unmodifiable(<String>[code]);
}
/// 활성화된 결재 템플릿 목록을 조회해 캐싱한다.
///
/// 템플릿이 비어 있거나 [force]가 `true`이면 API를 다시 호출한다.
@@ -362,6 +409,141 @@ class ApprovalController extends ChangeNotifier {
notifyListeners();
}
/// 결재 상신 초안을 보관한다.
void cacheSubmissionDraft(ApprovalSubmissionInput draft) {
_submissionDraft = draft;
notifyListeners();
_persistSubmissionDraft(draft);
}
/// 저장된 결재 상신 초안을 반환하고 초기화한다.
ApprovalSubmissionInput? consumeSubmissionDraft() {
final draft = _submissionDraft;
if (draft == null) {
return null;
}
_submissionDraft = null;
notifyListeners();
return draft;
}
/// 결재 상신 초안을 초기화한다.
void clearSubmissionDraft() {
if (_submissionDraft == null) {
return;
}
_submissionDraft = null;
notifyListeners();
}
Future<ApprovalSubmissionInput?> restoreSubmissionDraft({
required int requesterId,
int? transactionId,
}) async {
final listUseCase = _listDraftsUseCase;
final getUseCase = _getDraftUseCase;
if (listUseCase == null || getUseCase == null) {
return null;
}
try {
final filter = ApprovalDraftListFilter(
requesterId: requesterId,
transactionId: transactionId,
pageSize: 10,
);
final result = await listUseCase.call(filter);
if (result.items.isEmpty) {
return null;
}
final sessionKey = _submissionSessionKey(requesterId);
final summary = result.items.firstWhere(
(item) => item.sessionKey == sessionKey,
orElse: () => result.items.first,
);
final detail = await getUseCase.call(
id: summary.id,
requesterId: requesterId,
);
if (detail == null) {
return null;
}
final submission = detail.toSubmissionInput(
defaultStatusId: _defaultSubmissionStatusId(),
transactionIdOverride: transactionId ?? detail.transactionId,
);
_submissionDraft = submission;
notifyListeners();
return submission;
} catch (error, stackTrace) {
debugPrint('[ApprovalController] 초안 복구 실패: $error\n$stackTrace');
return null;
}
}
void _persistSubmissionDraft(ApprovalSubmissionInput draft) {
final useCase = _saveDraftUseCase;
if (useCase == null) {
return;
}
if (draft.steps.isEmpty) {
return;
}
final input = _buildSubmissionDraftInput(draft);
if (!input.hasSteps) {
return;
}
unawaited(
Future<void>(() async {
try {
await useCase.call(input);
} catch (error, stackTrace) {
debugPrint('[ApprovalController] 초안 저장 실패: $error\n$stackTrace');
}
}),
);
}
ApprovalDraftSaveInput _buildSubmissionDraftInput(
ApprovalSubmissionInput draft,
) {
final steps = draft.steps
.map(
(step) => ApprovalDraftStep(
stepOrder: step.stepOrder,
approverId: step.approverId,
note: step.note,
),
)
.toList(growable: false);
return ApprovalDraftSaveInput(
requesterId: draft.requesterId,
transactionId: draft.transactionId,
templateId: draft.templateId,
title: draft.title,
summary: draft.summary,
note: draft.note,
metadata: draft.metadata,
sessionKey: _submissionSessionKey(draft.requesterId),
statusId: draft.statusId,
steps: steps,
);
}
int? _defaultSubmissionStatusId() {
final pendingId = _statusIdFor(ApprovalStatusFilter.pending);
if (pendingId != null && pendingId > 0) {
return pendingId;
}
final draftId = _statusIdFor(ApprovalStatusFilter.draft);
if (draftId != null && draftId > 0) {
return draftId;
}
return null;
}
String _submissionSessionKey(int requesterId) =>
'approval_submission_$requesterId';
/// 결재를 생성하고 목록/상세 상태를 최신화한다.
Future<Approval?> createApproval(ApprovalCreateInput input) async {
_setSubmitting(true);

View File

@@ -19,6 +19,9 @@ 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 '../../domain/usecases/get_approval_draft_use_case.dart';
import '../../domain/usecases/list_approval_drafts_use_case.dart';
import '../../domain/usecases/save_approval_draft_use_case.dart';
import '../../../inventory/lookups/domain/repositories/inventory_lookup_repository.dart';
import '../../../inventory/shared/widgets/employee_autocomplete_field.dart';
import '../controllers/approval_controller.dart';
@@ -98,6 +101,15 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
lookupRepository: GetIt.I.isRegistered<InventoryLookupRepository>()
? GetIt.I<InventoryLookupRepository>()
: null,
saveDraftUseCase: GetIt.I.isRegistered<SaveApprovalDraftUseCase>()
? GetIt.I<SaveApprovalDraftUseCase>()
: null,
getDraftUseCase: GetIt.I.isRegistered<GetApprovalDraftUseCase>()
? GetIt.I<GetApprovalDraftUseCase>()
: null,
listDraftsUseCase: GetIt.I.isRegistered<ListApprovalDraftsUseCase>()
? GetIt.I<ListApprovalDraftsUseCase>()
: null,
)..addListener(_handleControllerUpdate);
WidgetsBinding.instance.addPostFrameCallback((_) async {
await Future.wait([
@@ -307,24 +319,28 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
),
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: Tooltip(
message: '전체 상태 선택 시 임시저장·상신·진행중 결재까지 함께 조회합니다.',
waitDuration: const Duration(milliseconds: 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(),
),
),
),
],
@@ -875,6 +891,11 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
style: shadTheme.textTheme.small,
),
const SizedBox(height: 16),
Text(
_dialogDescription(type),
style: shadTheme.textTheme.muted,
),
const SizedBox(height: 12),
Text('비고', style: shadTheme.textTheme.small),
const SizedBox(height: 8),
ShadTextarea(
@@ -950,11 +971,11 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
String _dialogTitle(ApprovalStepActionType type) {
switch (type) {
case ApprovalStepActionType.approve:
return '단계 승인';
return '결재 단계 승인';
case ApprovalStepActionType.reject:
return '단계 반려';
return '결재 단계 반려';
case ApprovalStepActionType.comment:
return '코멘트 등록';
return '결재 단계 코멘트';
}
}
@@ -979,6 +1000,17 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
return '코멘트를 등록했습니다.';
}
}
String _dialogDescription(ApprovalStepActionType type) {
switch (type) {
case ApprovalStepActionType.approve:
return '승인하면 다음 단계로 진행합니다. 필요 시 비고를 남길 수 있습니다.';
case ApprovalStepActionType.reject:
return '반려 사유를 입력해 단계를 반려합니다. 비고는 선택 사항입니다.';
case ApprovalStepActionType.comment:
return '코멘트를 등록하면 결재 참여자에게 공유됩니다. 비고 입력이 필요합니다.';
}
}
}
class _ApprovalTable extends StatelessWidget {