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