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