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

@@ -0,0 +1,78 @@
import 'package:dio/dio.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/core/network/api_client.dart';
import 'package:superport_v2/core/network/api_routes.dart';
import '../../domain/entities/approval_draft.dart';
import '../../domain/repositories/approval_draft_repository.dart';
import '../dtos/approval_draft_dto.dart';
/// 결재 초안을 원격 저장소로 관리한다.
class ApprovalDraftRepositoryRemote implements ApprovalDraftRepository {
ApprovalDraftRepositoryRemote({required ApiClient apiClient})
: _api = apiClient;
final ApiClient _api;
@override
Future<PaginatedResult<ApprovalDraftSummary>> list(
ApprovalDraftListFilter filter,
) async {
final query = ApiClient.buildQuery(
page: filter.page,
pageSize: filter.pageSize,
filters: {
'requester_id': filter.requesterId,
if (filter.transactionId != null)
'transaction_id': filter.transactionId,
if (filter.includeExpired) 'include_expired': filter.includeExpired,
},
);
final response = await _api.get<Map<String, dynamic>>(
ApiRoutes.approvalDrafts,
query: query,
options: Options(responseType: ResponseType.json),
);
return ApprovalDraftDto.parsePaginated(response.data);
}
@override
Future<ApprovalDraftDetail?> fetch({
required int id,
required int requesterId,
}) async {
final query = ApiClient.buildQuery(filters: {'requester_id': requesterId});
final response = await _api.get<Map<String, dynamic>>(
ApiClient.buildPath(ApiRoutes.approvalDrafts, [id]),
query: query,
options: Options(responseType: ResponseType.json),
);
return ApprovalDraftDto.parseDetail(response.data);
}
@override
Future<ApprovalDraftDetail> save(ApprovalDraftSaveInput input) async {
final payload = input.toJson();
final response = await _api.post<Map<String, dynamic>>(
ApiRoutes.approvalDrafts,
data: payload,
options: Options(responseType: ResponseType.json),
);
final detail = ApprovalDraftDto.parseDetail(response.data);
if (detail == null) {
throw const FormatException('초안 저장 응답이 비어 있습니다.');
}
return detail;
}
@override
Future<void> delete({required int id, required int requesterId}) async {
final query = ApiClient.buildQuery(filters: {'requester_id': requesterId});
await _api.delete<void>(
ApiClient.buildPath(ApiRoutes.approvalDrafts, [id]),
query: query,
options: Options(responseType: ResponseType.json),
);
}
}

View File

@@ -6,8 +6,10 @@ import 'package:superport_v2/core/network/api_routes.dart';
import '../../domain/entities/approval.dart';
import '../../domain/entities/approval_proceed_status.dart';
import '../../domain/repositories/approval_repository.dart';
import '../dtos/approval_audit_dto.dart';
import '../dtos/approval_dto.dart';
import '../dtos/approval_proceed_status_dto.dart';
import '../dtos/approval_request_dto.dart';
/// 결재 API 엔드포인트를 호출하는 원격 저장소 구현체.
///
@@ -18,7 +20,7 @@ class ApprovalRepositoryRemote implements ApprovalRepository {
final ApiClient _api;
static const _basePath = '${ApiRoutes.apiV1}/approvals';
static const _basePath = ApiRoutes.approvals;
/// 결재 목록을 조회한다. 필터 조건이 없으면 최신순 페이지를 반환한다.
@override
@@ -28,26 +30,34 @@ class ApprovalRepositoryRemote implements ApprovalRepository {
int? transactionId,
int? approvalStatusId,
int? requestedById,
List<String>? statusCodes,
bool includePending = false,
bool includeHistories = false,
bool includeSteps = false,
}) async {
final includeParts = <String>[];
final includeParts = <String>['requested_by', 'transaction'];
if (includeSteps) {
includeParts.add('steps');
}
if (includeHistories) {
includeParts.add('histories');
}
final response = await _api.get<Map<String, dynamic>>(
_basePath,
query: {
'page': page,
'page_size': pageSize,
final query = ApiClient.buildQuery(
page: page,
pageSize: pageSize,
include: includeParts,
filters: {
if (transactionId != null) 'transaction_id': transactionId,
if (approvalStatusId != null) 'approval_status_id': approvalStatusId,
if (requestedById != null) 'requested_by_id': requestedById,
if (includeParts.isNotEmpty) 'include': includeParts.join(','),
if (statusCodes != null && statusCodes.isNotEmpty)
'status': statusCodes,
if (includePending) 'include_pending': includePending,
},
);
final response = await _api.get<Map<String, dynamic>>(
_basePath,
query: query,
options: Options(responseType: ResponseType.json),
);
return ApprovalDto.parsePaginated(response.data ?? const {});
@@ -60,27 +70,114 @@ class ApprovalRepositoryRemote implements ApprovalRepository {
bool includeSteps = true,
bool includeHistories = true,
}) async {
final includeParts = <String>[];
final includeParts = <String>['transaction', 'requested_by'];
if (includeSteps) {
includeParts.add('steps');
}
if (includeHistories) {
includeParts.add('histories');
}
final query = ApiClient.buildQuery(include: includeParts);
final response = await _api.get<Map<String, dynamic>>(
'$_basePath/$id',
query: {if (includeParts.isNotEmpty) 'include': includeParts.join(',')},
ApiClient.buildPath(_basePath, [id]),
query: query.isEmpty ? null : query,
options: Options(responseType: ResponseType.json),
);
return ApprovalDto.fromJson(_api.unwrapAsMap(response)).toEntity();
return _mapApprovalFromResponse(response.data);
}
@override
Future<Approval> submit(ApprovalSubmissionInput input) async {
final payload = ApprovalSubmitRequestDto(
approval: ApprovalCreatePayloadDto.fromSubmission(input),
steps: _mapSteps(input.steps),
);
final response = await _api.post<Map<String, dynamic>>(
ApiRoutes.approvalAction('submit'),
data: payload.toJson(),
options: Options(responseType: ResponseType.json),
);
return _mapApprovalFromResponse(response.data);
}
@override
Future<Approval> resubmit(ApprovalResubmissionInput input) async {
final payload = ApprovalResubmitRequestDto(
approvalId: input.approvalId,
actorId: input.actorId,
steps: _mapSteps(input.submission.steps),
note: input.note,
expectedUpdatedAt: input.expectedUpdatedAt,
transactionExpectedUpdatedAt: input.transactionExpectedUpdatedAt,
);
final response = await _api.post<Map<String, dynamic>>(
ApiRoutes.approvalAction('resubmit'),
data: payload.toJson(),
options: Options(responseType: ResponseType.json),
);
return _mapApprovalFromResponse(response.data);
}
@override
Future<Approval> approve(ApprovalDecisionInput input) async {
final payload = ApprovalDecisionRequestDto(
approvalId: input.approvalId,
actorId: input.actorId,
note: input.note,
expectedUpdatedAt: input.expectedUpdatedAt,
);
final response = await _api.post<Map<String, dynamic>>(
ApiRoutes.approvalAction('approve'),
data: payload.toJson(),
options: Options(responseType: ResponseType.json),
);
return _mapApprovalFromResponse(response.data);
}
@override
Future<Approval> reject(ApprovalDecisionInput input) async {
final payload = ApprovalDecisionRequestDto(
approvalId: input.approvalId,
actorId: input.actorId,
note: input.note,
expectedUpdatedAt: input.expectedUpdatedAt,
);
final response = await _api.post<Map<String, dynamic>>(
ApiRoutes.approvalAction('reject'),
data: payload.toJson(),
options: Options(responseType: ResponseType.json),
);
return _mapApprovalFromResponse(response.data);
}
@override
Future<Approval> recall(ApprovalRecallInput input) async {
final payload = ApprovalRecallRequestDto(
approvalId: input.approvalId,
actorId: input.actorId,
note: input.note,
expectedUpdatedAt: input.expectedUpdatedAt,
transactionExpectedUpdatedAt: input.transactionExpectedUpdatedAt,
);
final response = await _api.post<Map<String, dynamic>>(
ApiRoutes.approvalAction('recall'),
data: payload.toJson(),
options: Options(responseType: ResponseType.json),
);
return _mapApprovalFromResponse(response.data);
}
/// 활성화된 결재 행위 목록을 조회한다.
@override
Future<List<ApprovalAction>> listActions({bool activeOnly = true}) async {
final query = ApiClient.buildQuery(
page: 1,
pageSize: 100,
filters: {if (activeOnly) 'active': true},
);
final response = await _api.get<Map<String, dynamic>>(
'${ApiRoutes.apiV1}/approval-actions',
query: {'page': 1, 'page_size': 100, if (activeOnly) 'active': true},
ApiRoutes.approvalActions,
query: query,
options: Options(responseType: ResponseType.json),
);
final items = (response.data?['items'] as List<dynamic>? ?? [])
@@ -91,11 +188,50 @@ class ApprovalRepositoryRemote implements ApprovalRepository {
return items;
}
@override
Future<PaginatedResult<ApprovalHistory>> listHistory({
required int approvalId,
int page = 1,
int pageSize = 20,
DateTime? from,
DateTime? to,
int? actorId,
int? approvalActionId,
}) async {
final query = ApiClient.buildQuery(
page: page,
pageSize: pageSize,
filters: {
'approval_id': approvalId,
if (from != null) 'action_from': from,
if (to != null) 'action_to': to,
if (actorId != null) 'approver_id': actorId,
if (approvalActionId != null) 'approval_action_id': approvalActionId,
},
);
final response = await _api.get<Map<String, dynamic>>(
ApiRoutes.approvalHistory,
query: query,
options: Options(responseType: ResponseType.json),
);
final dto = ApprovalAuditListDto.fromJson(response.data ?? const {});
return PaginatedResult<ApprovalHistory>(
items: dto.items.map((e) => e.toEntity()).toList(growable: false),
page: dto.page,
pageSize: dto.pageSize,
total: dto.total,
);
}
/// 결재 단계 행위를 수행하고 업데이트된 결재 정보를 반환한다.
@override
Future<Approval> performStepAction(ApprovalStepActionInput input) async {
final path = ApiClient.buildPath(ApiRoutes.approvalSteps, [
input.stepId,
'actions',
]);
final response = await _api.post<Map<String, dynamic>>(
'${ApiRoutes.apiV1}/approval-steps/${input.stepId}/actions',
path,
data: input.toPayload(),
options: Options(responseType: ResponseType.json),
);
@@ -111,8 +247,9 @@ class ApprovalRepositoryRemote implements ApprovalRepository {
/// 결재 단계들을 일괄로 생성하거나 재배치한다.
@override
Future<Approval> assignSteps(ApprovalStepAssignmentInput input) async {
final path = ApiClient.buildPath(_basePath, [input.approvalId, 'steps']);
final response = await _api.post<Map<String, dynamic>>(
'${ApiRoutes.apiV1}/approvals/${input.approvalId}/steps',
path,
data: input.toPayload(),
options: Options(responseType: ResponseType.json),
);
@@ -129,7 +266,7 @@ class ApprovalRepositoryRemote implements ApprovalRepository {
@override
Future<ApprovalProceedStatus> canProceed(int id) async {
final response = await _api.get<Map<String, dynamic>>(
'$_basePath/$id/can-proceed',
ApiClient.buildPath(_basePath, [id, 'can-proceed']),
options: Options(responseType: ResponseType.json),
);
return ApprovalProceedStatusDto.fromJson(
@@ -145,34 +282,66 @@ class ApprovalRepositoryRemote implements ApprovalRepository {
data: input.toPayload(),
options: Options(responseType: ResponseType.json),
);
return ApprovalDto.fromJson(_api.unwrapAsMap(response)).toEntity();
return _mapApprovalFromResponse(response.data);
}
/// 결재 기본 정보를 수정한다.
@override
Future<Approval> update(ApprovalUpdateInput input) async {
final response = await _api.patch<Map<String, dynamic>>(
'$_basePath/${input.id}',
ApiClient.buildPath(_basePath, [input.id]),
data: input.toPayload(),
options: Options(responseType: ResponseType.json),
);
return ApprovalDto.fromJson(_api.unwrapAsMap(response)).toEntity();
return _mapApprovalFromResponse(response.data);
}
/// 결재를 삭제(비활성화)한다.
@override
Future<void> delete(int id) async {
await _api.delete<void>('$_basePath/$id');
await _api.delete<void>(ApiClient.buildPath(_basePath, [id]));
}
/// 삭제된 결재를 복구한다.
@override
Future<Approval> restore(int id) async {
final response = await _api.post<Map<String, dynamic>>(
'$_basePath/$id/restore',
ApiClient.buildPath(_basePath, [id, 'restore']),
options: Options(responseType: ResponseType.json),
);
return ApprovalDto.fromJson(_api.unwrapAsMap(response)).toEntity();
return _mapApprovalFromResponse(response.data);
}
/// 결재 단계/행위 응답에서 결재 객체 JSON을 추출한다.
Approval _mapApprovalFromResponse(Map<String, dynamic>? body) {
final payload = _extractApprovalPayload(body);
if (payload.isEmpty) {
throw StateError('결재 응답에 결재 데이터가 없습니다.');
}
return ApprovalDto.fromJson(payload).toEntity();
}
Map<String, dynamic> _extractApprovalPayload(Map<String, dynamic>? body) {
if (body == null || body.isEmpty) {
return const <String, dynamic>{};
}
final data = body['data'];
if (data is Map<String, dynamic>) {
final approval = _selectApprovalPayload(data);
if (approval != null) {
return approval;
}
return Map<String, dynamic>.from(data);
}
final approval = _selectApprovalPayload(body);
if (approval != null) {
return approval;
}
return Map<String, dynamic>.from(body);
}
List<ApprovalStepInputDto> _mapSteps(List<ApprovalStepAssignmentItem> items) {
return items.map(ApprovalStepInputDto.fromDomain).toList(growable: false);
}
/// 결재 단계/행위 응답에서 결재 객체 JSON을 추출한다.