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