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,58 @@
import '../entities/approval.dart';
import '../entities/approval_flow.dart';
import '../entities/approval_template.dart';
import '../repositories/approval_repository.dart';
import '../repositories/approval_template_repository.dart';
/// 결재 템플릿을 결재 요청에 적용하는 유즈케이스.
///
/// - 템플릿 단계를 정렬해 [ApprovalStepAssignmentInput]으로 변환한 뒤 저장소에 위임한다.
class ApplyApprovalTemplateUseCase {
ApplyApprovalTemplateUseCase({
required ApprovalTemplateRepository templateRepository,
required ApprovalRepository approvalRepository,
}) : _templateRepository = templateRepository,
_approvalRepository = approvalRepository;
final ApprovalTemplateRepository _templateRepository;
final ApprovalRepository _approvalRepository;
/// [templateId]에 해당하는 템플릿을 [approvalId] 결재에 적용한다.
///
/// 템플릿에 단계가 없으면 [StateError]를 던진다.
Future<ApprovalFlow> call({
required int approvalId,
required int templateId,
}) async {
final template = await _templateRepository.fetchDetail(
templateId,
includeSteps: true,
);
if (template.steps.isEmpty) {
throw StateError('단계가 없는 결재 템플릿은 적용할 수 없습니다.');
}
final steps = _mapTemplateSteps(template);
final assignment = ApprovalStepAssignmentInput(
approvalId: approvalId,
steps: steps,
);
final approval = await _approvalRepository.assignSteps(assignment);
return ApprovalFlow.fromApproval(approval);
}
List<ApprovalStepAssignmentItem> _mapTemplateSteps(
ApprovalTemplate template,
) {
final sorted = List<ApprovalTemplateStep>.of(template.steps)
..sort((a, b) => a.stepOrder.compareTo(b.stepOrder));
return sorted
.map(
(step) => ApprovalStepAssignmentItem(
stepOrder: step.stepOrder,
approverId: step.approver.id,
note: step.note,
),
)
.toList(growable: false);
}
}

View File

@@ -0,0 +1,33 @@
import '../entities/approval.dart';
import '../entities/approval_flow.dart';
import '../repositories/approval_repository.dart';
/// 결재를 승인하는 유즈케이스.
///
/// - 승인자는 [ApprovalDecisionInput]을 통해 필요한 정보를 전달한다.
class ApproveApprovalUseCase {
ApproveApprovalUseCase({required ApprovalRepository repository})
: _repository = repository;
final ApprovalRepository _repository;
/// 결재를 승인하고 최신 [ApprovalFlow]를 반환한다.
Future<ApprovalFlow> call(ApprovalDecisionInput input) async {
await _ensureCanProceed(input.approvalId);
final approval = await _repository.approve(input);
return ApprovalFlow.fromApproval(approval);
}
/// 결재 단계 진행 권한을 사전 확인한다.
Future<void> _ensureCanProceed(int approvalId) async {
final status = await _repository.canProceed(approvalId);
if (status.canProceed) {
return;
}
final reason = status.reason?.trim();
if (reason != null && reason.isNotEmpty) {
throw StateError(reason);
}
throw StateError('결재를 진행할 권한이 없습니다.');
}
}

View File

@@ -0,0 +1,13 @@
import '../repositories/approval_draft_repository.dart';
/// 결재 초안을 삭제하는 유즈케이스.
class DeleteApprovalDraftUseCase {
DeleteApprovalDraftUseCase({required ApprovalDraftRepository repository})
: _repository = repository;
final ApprovalDraftRepository _repository;
Future<void> call({required int id, required int requesterId}) {
return _repository.delete(id: id, requesterId: requesterId);
}
}

View File

@@ -0,0 +1,17 @@
import '../entities/approval_draft.dart';
import '../repositories/approval_draft_repository.dart';
/// 결재 초안 상세를 조회하는 유즈케이스.
class GetApprovalDraftUseCase {
GetApprovalDraftUseCase({required ApprovalDraftRepository repository})
: _repository = repository;
final ApprovalDraftRepository _repository;
Future<ApprovalDraftDetail?> call({
required int id,
required int requesterId,
}) {
return _repository.fetch(id: id, requesterId: requesterId);
}
}

View File

@@ -0,0 +1,18 @@
import 'package:superport_v2/core/common/models/paginated_result.dart';
import '../entities/approval_draft.dart';
import '../repositories/approval_draft_repository.dart';
/// 결재 초안 목록을 조회하는 유즈케이스.
class ListApprovalDraftsUseCase {
ListApprovalDraftsUseCase({required ApprovalDraftRepository repository})
: _repository = repository;
final ApprovalDraftRepository _repository;
Future<PaginatedResult<ApprovalDraftSummary>> call(
ApprovalDraftListFilter filter,
) {
return _repository.list(filter);
}
}

View File

@@ -0,0 +1,19 @@
import '../entities/approval.dart';
import '../entities/approval_flow.dart';
import '../repositories/approval_repository.dart';
/// 결재를 회수(recall)하는 유즈케이스.
///
/// - 회수 가능 여부는 별도의 선행 검증으로 확인해야 한다.
class RecallApprovalUseCase {
RecallApprovalUseCase({required ApprovalRepository repository})
: _repository = repository;
final ApprovalRepository _repository;
/// 결재를 회수하고 최신 [ApprovalFlow]를 반환한다.
Future<ApprovalFlow> call(ApprovalRecallInput input) async {
final approval = await _repository.recall(input);
return ApprovalFlow.fromApproval(approval);
}
}

View File

@@ -0,0 +1,33 @@
import '../entities/approval.dart';
import '../entities/approval_flow.dart';
import '../repositories/approval_repository.dart';
/// 결재를 반려하는 유즈케이스.
///
/// - 반려 사유 및 코멘트는 [ApprovalDecisionInput.note]로 전달한다.
class RejectApprovalUseCase {
RejectApprovalUseCase({required ApprovalRepository repository})
: _repository = repository;
final ApprovalRepository _repository;
/// 결재를 반려하고 최신 [ApprovalFlow]를 반환한다.
Future<ApprovalFlow> call(ApprovalDecisionInput input) async {
await _ensureCanProceed(input.approvalId);
final approval = await _repository.reject(input);
return ApprovalFlow.fromApproval(approval);
}
/// 결재 단계 진행 권한을 사전 확인한다.
Future<void> _ensureCanProceed(int approvalId) async {
final status = await _repository.canProceed(approvalId);
if (status.canProceed) {
return;
}
final reason = status.reason?.trim();
if (reason != null && reason.isNotEmpty) {
throw StateError(reason);
}
throw StateError('결재를 진행할 권한이 없습니다.');
}
}

View File

@@ -0,0 +1,19 @@
import '../entities/approval.dart';
import '../entities/approval_flow.dart';
import '../repositories/approval_repository.dart';
/// 결재를 재상신(resubmit)하는 유즈케이스.
///
/// - 재상신 시 수정된 단계 정보와 메모를 함께 전달한다.
class ResubmitApprovalUseCase {
ResubmitApprovalUseCase({required ApprovalRepository repository})
: _repository = repository;
final ApprovalRepository _repository;
/// 결재를 재상신하고 최신 [ApprovalFlow]를 반환한다.
Future<ApprovalFlow> call(ApprovalResubmissionInput input) async {
final approval = await _repository.resubmit(input);
return ApprovalFlow.fromApproval(approval);
}
}

View File

@@ -0,0 +1,14 @@
import '../entities/approval_draft.dart';
import '../repositories/approval_draft_repository.dart';
/// 결재 초안을 서버에 저장하는 유즈케이스.
class SaveApprovalDraftUseCase {
SaveApprovalDraftUseCase({required ApprovalDraftRepository repository})
: _repository = repository;
final ApprovalDraftRepository _repository;
Future<ApprovalDraftDetail> call(ApprovalDraftSaveInput input) {
return _repository.save(input);
}
}

View File

@@ -0,0 +1,24 @@
import '../entities/approval_template.dart';
import '../repositories/approval_template_repository.dart';
/// 결재 템플릿을 생성/수정하는 유즈케이스.
///
/// - [templateId]가 null이면 신규 생성, 값이 있으면 수정으로 처리한다.
class SaveApprovalTemplateUseCase {
SaveApprovalTemplateUseCase({required ApprovalTemplateRepository repository})
: _repository = repository;
final ApprovalTemplateRepository _repository;
/// 템플릿을 저장하고 최신 [ApprovalTemplate]을 반환한다.
Future<ApprovalTemplate> call({
int? templateId,
required ApprovalTemplateInput input,
List<ApprovalTemplateStepInput>? steps,
}) {
if (templateId == null) {
return _repository.create(input, steps: steps ?? const []);
}
return _repository.update(templateId, input, steps: steps);
}
}

View File

@@ -0,0 +1,19 @@
import '../entities/approval.dart';
import '../entities/approval_flow.dart';
import '../repositories/approval_repository.dart';
/// 결재를 상신(submit)하는 유즈케이스.
///
/// - 입력 파라미터는 [ApprovalSubmissionInput]을 사용한다.
class SubmitApprovalUseCase {
SubmitApprovalUseCase({required ApprovalRepository repository})
: _repository = repository;
final ApprovalRepository _repository;
/// 결재를 상신하고 갱신된 [ApprovalFlow]를 반환한다.
Future<ApprovalFlow> call(ApprovalSubmissionInput input) async {
final approval = await _repository.submit(input);
return ApprovalFlow.fromApproval(approval);
}
}