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