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