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