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

@@ -2,29 +2,62 @@ 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.dart';
import '../../../domain/entities/approval_flow.dart';
import '../../../domain/repositories/approval_repository.dart';
import '../../../domain/usecases/recall_approval_use_case.dart';
import '../../../domain/usecases/resubmit_approval_use_case.dart';
import '../../domain/entities/approval_history_record.dart';
import '../../domain/repositories/approval_history_repository.dart';
/// 결재 이력에서 필터링 가능한 행위 타입.
enum ApprovalHistoryActionFilter { all, approve, reject, comment }
/// 결재 이력 화면 탭 종류.
enum ApprovalHistoryTab { flow, audit }
/// 결재 이력 화면의 목록/필터 상태를 관리하는 컨트롤러.
///
/// 기간, 검색어, 행위 타입에 따라 목록을 조회하고 페이지 사이즈를 조절한다.
class ApprovalHistoryController extends ChangeNotifier {
ApprovalHistoryController({required ApprovalHistoryRepository repository})
: _repository = repository;
ApprovalHistoryController({
required ApprovalHistoryRepository repository,
ApprovalRepository? approvalRepository,
RecallApprovalUseCase? recallUseCase,
ResubmitApprovalUseCase? resubmitUseCase,
}) : _repository = repository,
_approvalRepository = approvalRepository,
_recallUseCase = recallUseCase,
_resubmitUseCase = resubmitUseCase;
final ApprovalHistoryRepository _repository;
final ApprovalRepository? _approvalRepository;
final RecallApprovalUseCase? _recallUseCase;
final ResubmitApprovalUseCase? _resubmitUseCase;
final Map<int, ApprovalFlow> _flowCache = <int, ApprovalFlow>{};
PaginatedResult<ApprovalHistoryRecord>? _result;
PaginatedResult<ApprovalHistory>? _auditResult;
bool _isLoading = false;
bool _isLoadingAudit = false;
bool _isPerformingAction = false;
bool _isLoadingFlow = false;
ApprovalHistoryTab _activeTab = ApprovalHistoryTab.flow;
String _query = '';
ApprovalHistoryActionFilter _actionFilter = ApprovalHistoryActionFilter.all;
DateTime? _from;
DateTime? _to;
String? _errorMessage;
int _pageSize = 20;
int _auditPageSize = 20;
int? _selectedApprovalId;
ApprovalFlow? _selectedFlow;
int? _auditActorId;
String? _auditActionCode;
DateTime? _auditFrom;
DateTime? _auditTo;
final Map<String, ApprovalAction> _auditActions = <String, ApprovalAction>{};
bool _isSelectionForbidden = false;
PaginatedResult<ApprovalHistoryRecord>? get result => _result;
bool get isLoading => _isLoading;
@@ -34,6 +67,30 @@ class ApprovalHistoryController extends ChangeNotifier {
DateTime? get to => _to;
String? get errorMessage => _errorMessage;
int get pageSize => _result?.pageSize ?? _pageSize;
PaginatedResult<ApprovalHistory>? get auditResult => _auditResult;
bool get isLoadingAudit => _isLoadingAudit;
bool get isPerformingAction => _isPerformingAction;
ApprovalHistoryTab get activeTab => _activeTab;
int get auditPageSize => _auditResult?.pageSize ?? _auditPageSize;
int? get selectedApprovalId => _selectedApprovalId;
bool get hasAuditSelection => _selectedApprovalId != null;
bool get hasAuditResults => _auditResult?.items.isNotEmpty ?? false;
bool get isLoadingFlow => _isLoadingFlow;
ApprovalFlow? get selectedFlow => _selectedFlow;
int? get auditActorId => _auditActorId;
String? get auditActionCode => _auditActionCode;
DateTime? get auditFrom => _auditFrom;
DateTime? get auditTo => _auditTo;
List<ApprovalAction> get auditActions {
if (_auditActions.isEmpty) {
return const <ApprovalAction>[];
}
final items = _auditActions.values.toList(growable: false);
items.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
return items;
}
bool get isSelectionForbidden => _isSelectionForbidden;
/// 현재 필터 조건에 맞춰 결재 이력 목록을 불러온다.
///
@@ -43,17 +100,7 @@ class ApprovalHistoryController extends ChangeNotifier {
_errorMessage = null;
notifyListeners();
try {
final previous = _result;
final int resolvedPage;
if (page < 1) {
resolvedPage = 1;
} else if (previous != null && previous.pageSize > 0) {
final calculated = (previous.total / previous.pageSize).ceil();
final maxPage = calculated < 1 ? 1 : calculated;
resolvedPage = page > maxPage ? maxPage : page;
} else {
resolvedPage = page;
}
final resolvedPage = _resolvePage(page, _result);
final action = switch (_actionFilter) {
ApprovalHistoryActionFilter.all => null,
ApprovalHistoryActionFilter.approve => 'approve',
@@ -80,6 +127,125 @@ class ApprovalHistoryController extends ChangeNotifier {
}
}
/// 지정한 결재의 감사 로그를 조회한다.
Future<void> fetchAuditLogs({required int approvalId, int page = 1}) async {
final approvalRepository = _approvalRepository;
if (approvalRepository == null) {
throw StateError('ApprovalRepository가 주입되지 않았습니다.');
}
_isLoadingAudit = true;
_errorMessage = null;
_selectedApprovalId = approvalId;
notifyListeners();
try {
final resolvedPage = _resolvePage(page, _auditResult);
final response = await approvalRepository.listHistory(
approvalId: approvalId,
page: resolvedPage,
pageSize: auditPageSize,
from: _auditFrom ?? _from,
to: _auditTo ?? _to,
actorId: _auditActorId,
approvalActionId: _resolveAuditActionId(),
);
_auditResult = response;
_auditPageSize = response.pageSize;
if (response.items.isNotEmpty) {
final actionMap = <String, ApprovalAction>{};
for (final log in response.items) {
final code = log.action.code?.trim();
if (code == null || code.isEmpty) {
continue;
}
actionMap.putIfAbsent(code, () => log.action);
}
if (actionMap.isNotEmpty) {
_auditActions
..clear()
..addAll(actionMap);
}
}
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
if (failure.statusCode == 403) {
_isSelectionForbidden = true;
_auditResult = null;
}
} finally {
_isLoadingAudit = false;
notifyListeners();
}
}
/// 결재 상세와 타임라인 정보를 조회해 선택 상태를 갱신한다.
Future<void> loadApprovalFlow(int approvalId, {bool force = false}) async {
final approvalRepository = _approvalRepository;
if (approvalRepository == null) {
throw StateError('ApprovalRepository가 주입되지 않았습니다.');
}
if (!force && _flowCache.containsKey(approvalId)) {
_selectedApprovalId = approvalId;
_selectedFlow = _flowCache[approvalId];
notifyListeners();
return;
}
_isLoadingFlow = true;
_errorMessage = null;
_selectedApprovalId = approvalId;
_isSelectionForbidden = false;
_selectedFlow = null;
notifyListeners();
try {
final detail = await approvalRepository.fetchDetail(
approvalId,
includeSteps: true,
includeHistories: true,
);
final flow = ApprovalFlow.fromApproval(detail);
_flowCache[approvalId] = flow;
_selectedFlow = flow;
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
if (failure.statusCode == 403) {
_isSelectionForbidden = true;
_selectedFlow = null;
_auditResult = null;
}
} finally {
_isLoadingFlow = false;
notifyListeners();
}
}
/// 선택된 결재 흐름을 최신 상태로 갱신한다.
Future<ApprovalFlow?> refreshFlow(int approvalId) async {
final approvalRepository = _approvalRepository;
if (approvalRepository == null) {
throw StateError('ApprovalRepository가 주입되지 않았습니다.');
}
try {
final detail = await approvalRepository.fetchDetail(
approvalId,
includeSteps: true,
includeHistories: true,
);
final flow = ApprovalFlow.fromApproval(detail);
_flowCache[approvalId] = flow;
if (_selectedApprovalId == approvalId) {
_selectedFlow = flow;
notifyListeners();
}
return flow;
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
notifyListeners();
return null;
}
}
/// 검색어를 업데이트해 다음 조회 시 적용될 수 있도록 한다.
void updateQuery(String value) {
_query = value;
@@ -99,6 +265,82 @@ class ApprovalHistoryController extends ChangeNotifier {
notifyListeners();
}
/// 활성 탭을 변경한다.
void updateActiveTab(ApprovalHistoryTab tab) {
if (_activeTab == tab) {
return;
}
_activeTab = tab;
notifyListeners();
}
/// 감사 로그 페이지 사이즈를 변경한다.
void updateAuditPageSize(int value) {
if (value <= 0) {
return;
}
_auditPageSize = value;
notifyListeners();
}
/// 감사 로그 행위자를 필터링한다.
void updateAuditActor(int? actorId) {
final normalized = actorId != null && actorId > 0 ? actorId : null;
if (_auditActorId == normalized) {
return;
}
_auditActorId = normalized;
notifyListeners();
}
/// 감사 로그 행위 타입을 필터링한다.
void updateAuditAction(String? actionCode) {
final normalized = actionCode?.trim();
if (normalized != null && normalized.isEmpty) {
_auditActionCode = null;
} else {
if (_auditActionCode == normalized) {
return;
}
_auditActionCode = normalized;
}
notifyListeners();
}
/// 감사 로그 기간 필터를 설정한다.
void updateAuditDateRange(DateTime? from, DateTime? to) {
DateTime? normalizedFrom = from;
DateTime? normalizedTo = to;
if (normalizedFrom != null && normalizedTo != null) {
if (normalizedFrom.isAfter(normalizedTo)) {
final temp = normalizedFrom;
normalizedFrom = normalizedTo;
normalizedTo = temp;
}
}
if (_auditFrom == normalizedFrom && _auditTo == normalizedTo) {
return;
}
_auditFrom = normalizedFrom;
_auditTo = normalizedTo;
notifyListeners();
}
/// 감사 로그 필터를 초기화한다.
void clearAuditFilters() {
if (_auditActorId == null &&
(_auditActionCode == null || _auditActionCode!.isEmpty) &&
_auditFrom == null &&
_auditTo == null) {
return;
}
_auditActorId = null;
_auditActionCode = null;
_auditFrom = null;
_auditTo = null;
notifyListeners();
}
/// 검색어/행위/기간 필터를 초기화한다.
void clearFilters() {
_query = '';
@@ -108,6 +350,20 @@ class ApprovalHistoryController extends ChangeNotifier {
notifyListeners();
}
/// 감사 로그 선택 상태를 초기화한다.
void clearAuditSelection() {
clearSelection();
}
/// 선택된 결재의 감사 로그를 새로고침한다.
Future<void> refreshAudit() async {
final approvalId = _selectedApprovalId;
if (approvalId == null) {
return;
}
await fetchAuditLogs(approvalId: approvalId, page: _auditResult?.page ?? 1);
}
/// 축적된 오류 메시지를 초기화한다.
void clearError() {
_errorMessage = null;
@@ -123,9 +379,124 @@ class ApprovalHistoryController extends ChangeNotifier {
notifyListeners();
}
/// 결재를 회수한다.
Future<ApprovalFlow?> recallApproval(ApprovalRecallInput input) async {
final useCase = _recallUseCase;
if (useCase == null) {
throw StateError('RecallApprovalUseCase가 주입되지 않았습니다.');
}
_isPerformingAction = true;
_errorMessage = null;
notifyListeners();
try {
final flow = await useCase.call(input);
final targetId = flow.approval.id ?? input.approvalId;
await _refreshAfterAction(targetId, flow: flow);
return flow;
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
notifyListeners();
return null;
} finally {
_isPerformingAction = false;
notifyListeners();
}
}
/// 결재를 재상신한다.
Future<ApprovalFlow?> resubmitApproval(
ApprovalResubmissionInput input,
) async {
final useCase = _resubmitUseCase;
if (useCase == null) {
throw StateError('ResubmitApprovalUseCase가 주입되지 않았습니다.');
}
_isPerformingAction = true;
_errorMessage = null;
notifyListeners();
try {
final flow = await useCase.call(input);
final targetId = flow.approval.id ?? input.approvalId;
await _refreshAfterAction(targetId, flow: flow);
return flow;
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
notifyListeners();
return null;
} finally {
_isPerformingAction = false;
notifyListeners();
}
}
bool get hasActiveFilters =>
_query.trim().isNotEmpty ||
_actionFilter != ApprovalHistoryActionFilter.all ||
_from != null ||
_to != null;
bool get hasActiveAuditFilters =>
(_auditActorId ?? 0) > 0 ||
(_auditActionCode != null && _auditActionCode!.trim().isNotEmpty) ||
_auditFrom != null ||
_auditTo != null;
int? _resolveAuditActionId() {
final code = _auditActionCode?.trim();
if (code == null || code.isEmpty) {
return null;
}
final action = _auditActions[code];
return action?.id;
}
/// 현재 선택 상태와 캐시를 초기화한다.
void clearSelection() {
if (_selectedApprovalId == null &&
_auditResult == null &&
_selectedFlow == null) {
return;
}
_selectedApprovalId = null;
_selectedFlow = null;
_auditResult = null;
_isSelectionForbidden = false;
notifyListeners();
}
int _resolvePage(int requested, PaginatedResult<dynamic>? current) {
if (requested < 1) {
return 1;
}
if (current != null && current.pageSize > 0) {
final calculated = (current.total / current.pageSize).ceil();
final maxPage = calculated < 1 ? 1 : calculated;
return requested > maxPage ? maxPage : requested;
}
return requested;
}
Future<void> _refreshAfterAction(int approvalId, {ApprovalFlow? flow}) async {
await fetch(page: _result?.page ?? 1);
if (flow != null) {
_flowCache[approvalId] = flow;
if (_selectedApprovalId == approvalId) {
_selectedFlow = flow;
notifyListeners();
}
}
if (_selectedApprovalId == approvalId) {
if (flow == null && _approvalRepository != null) {
await loadApprovalFlow(approvalId, force: true);
}
if (_approvalRepository != null) {
await fetchAuditLogs(
approvalId: approvalId,
page: _auditResult?.page ?? 1,
);
}
}
}
}