diff --git a/.env.development.example b/.env.development.example index da608c5..ae123c0 100644 --- a/.env.development.example +++ b/.env.development.example @@ -12,6 +12,8 @@ FEATURE_MENUS_ENABLED=false FEATURE_GROUP_PERMISSIONS_ENABLED=false # 결재 기능은 개발/운영 기본값이 true이지만, 백엔드 미준비 시 false로 전환 FEATURE_APPROVALS_ENABLED=true +# Approval Flow v2 기능 토글 (feature.approval_flow_v2) +FEATURE_APPROVAL_FLOW_V2=false FEATURE_ZIPCODE_SEARCH_ENABLED=false # 재고 상태 전이 버튼 제어 (운영 기본값 false) FEATURE_STOCK_TRANSITIONS_ENABLED=true diff --git a/.env.production.example b/.env.production.example index c9a814f..c3044cb 100644 --- a/.env.production.example +++ b/.env.production.example @@ -10,5 +10,6 @@ FEATURE_GROUPS_ENABLED=true FEATURE_MENUS_ENABLED=true FEATURE_GROUP_PERMISSIONS_ENABLED=true FEATURE_APPROVALS_ENABLED=true +FEATURE_APPROVAL_FLOW_V2=false FEATURE_ZIPCODE_SEARCH_ENABLED=true FEATURE_STOCK_TRANSITIONS_ENABLED=false diff --git a/AGENTS.md b/AGENTS.md index 44dd1d2..d5805ab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,7 @@ Place all Flutter source in `lib/`, keeping cross-cutting pieces in `lib/core/` - `flutter test` — run the unit/widget suite; add `--coverage` when validating overall health. - `flutter run -d chrome --web-renderer canvaskit` — local web run matching production rendering. - `dart run build_runner build --delete-conflicting-outputs` — regenerate freezed/json_serializable files when models change. +- After every code addition, modification, or deletion, run both `flutter analyze` and `flutter test` before considering the task complete. ## Coding Style & Naming Conventions Use Flutter’s two-space indentation and run `dart format .` before committing. Follow the Clean Architecture layering: DTOs/remote in `data`, domain interfaces/use cases in `domain`, controllers/widgets in presentation. File names use `snake_case.dart`; classes use `UpperCamelCase`; methods and fields use `lowerCamelCase`. Prefer `const` constructors/widgets, and use `shadcn_ui` components (especially `ShadTable`) for new screens. Register dependencies in `lib/injection_container.dart` via `get_it`. @@ -20,7 +21,7 @@ Each feature ships with unit tests (`*_test.dart`) living beside the source modu Commits follow the existing Superport convention: Korean imperative summaries with optional English technical nouns, e.g., `"대여 상세 테이블 정렬 수정"`. For PRs, include (1) a concise summary of user-visible impact, (2) screenshots or GIFs for UI changes, (3) linked issue or JIRA reference, and (4) verification notes (commands run, tests passing). Squash before merge unless release tagging requires history. ## Change Comment Guidelines -- Document every change with a Conventional Commit style summary line: `type(scope): 요약`. Use Korean imperatives for the message body while keeping the scope in English (module/package). +- Document every change with a Conventional Commit style summary line: `type(scope): `. Use Korean imperatives for the message body while keeping the scope in English (module/package). - Follow the summary with a blank line and bullet points that detail the concrete modifications, each starting with `- ` and phrased in past-tense or imperative sentences that mention impacted modules. - Include tests, scripts, and document updates in the bullet list so reviewers understand coverage. - When multiple subsystems change, group bullets logically (e.g., backend, frontend, docs) and keep each bullet under 120 characters. @@ -35,7 +36,7 @@ Commits follow the existing Superport convention: Korean imperative summaries wi ## Architecture & Environment Notes Initialize environments via `.env.development` / `.env.production` and load them through `Environment.initialize()` before bootstrapping DI. New data sources should expose repository interfaces in `domain/` and rely on the shared `ApiClient` instance. Do not use mock data in the application; always call the real backend (staging/production as appropriate). If an endpoint is not available, mark the feature as disabled behind a feature flag rather than mocking. -- Frontend behaviour/data models must strictly follow the deployed backend contract. (프론트엔드는 백엔드 API 계약을 절대 우선으로 준수해야 하며, 누락된 기능은 백엔드 수정 요청 후 진행한다.) +- Frontend behaviour/data models must strictly follow the deployed backend contract. If a required endpoint is missing, request a backend fix rather than introducing mock data. --- @@ -51,7 +52,9 @@ Initialize environments via `.env.development` / `.env.production` and load them ## Notification Policy -- Every task completion must trigger a notification via the configured `notify.py` workflow so users are consistently alerted. +- Run `notify.py` right before delivering the final wrap-up report or ending the conversation so the notification is sent at task completion. +- Use the `notify.py` script located at `/Users/maximilian.j.sul/.codex/notify.py`. +- Update relevant progress documents when a task is completed (e.g., `doc/approval_flow_frontend_task_plan.md`). --- diff --git a/doc/ApprovalFlow_System_Integration_and_ChangePlan.md b/doc/ApprovalFlow_System_Integration_and_ChangePlan.md new file mode 100644 index 0000000..2bc468b --- /dev/null +++ b/doc/ApprovalFlow_System_Integration_and_ChangePlan.md @@ -0,0 +1,202 @@ +## Approval Step Creation and Management System – Full Revision Plan + +--- + +### 🎯 Objective + +Add an **Approval Step Configuration** feature to the *Inbound, Outbound,* and *Rental Registration* screens, +and expand the existing “Approval Management” and “Approval History” menus into a unified, fully functional approval workflow. + +Both **backend and frontend modifications are permitted** as needed, +but all changes must be designed and deployed carefully to **avoid any side effects** and ensure **system stability**. + +--- + +## 1. Core Principles + +* Existing **data structures, responses, and UI** may be changed if necessary. + However, every change must be **controlled and validated** to ensure compatibility and prevent unintended side effects. +* Prefer **append-only** design, but allow structural refactoring when the current design causes inefficiency or redundancy. +* Every change must include **test coverage, rollback strategy, and observability** before release. +* Draft approvals must persist across browser sessions so submitters can resume from Approval Management, and pending transactions remain hidden from the primary lists until final approval. + +--- + +## 2. Approval Step Logic and Workflow + +1. **Approval Flow** + + * The **submitter** is automatically set as the logged-in user (not editable). + * **Intermediate approvers** must be other users (the submitter cannot approve their own request). + * Up to **98 approval steps** can be added dynamically. + * **Final approver** is mandatory and always visible. + * **Admins** (highest privilege users) can designate themselves as the final approver. + +2. **Status Transitions** + + * SUBMIT → IN_PROGRESS → APPROVED / REJECTED / RECALLED + * Rejection immediately terminates the process. + * The submitter can **recall** a request only if the **first approver** has not acted yet. + * Recalled requests can be **edited and resubmitted (RESUBMIT)**. + * All state transitions must be logged in an **Audit Table** with timestamp, actor, and action. + +3. **User Roles & Permissions** + + * Each approver can approve/reject only their assigned step. + * Earlier steps are visible for reference but cannot be modified. + * Notes or memos visibility follows permission tiers (own, higher-level, or approvers only). + * Submitters and approvers who have already completed their step retain read access; future-step approvers (including the final approver before their turn) cannot see the document in lists or detail views and receive a `403` when attempting access. + * **terabits (Super Admin)** can view all approvals and logs but cannot modify them. + +--- + +## 3. Frontend Revision Plan + +1. **UI Additions** + + * Add an **Approval Step Configuration** section (or modal) to the existing Inbound / Outbound / Rental registration forms. + * Default fields: + + * Submitter (auto-filled, read-only) + * Final Approver (search/dropdown input) + * Intermediate approvers added dynamically via **“+ Add Approver”** button (max 98). + * Each row displays: Order, Approver, Role, Delete(X). + * Include **Template Selector** to load previously saved approval configurations. + * On save, send both registration data and approval configuration together. + * Draft submissions must be saved server-side and restorable from the Approval Management menu even after the browser window is closed. + +2. **Approval Management Menu** + + * Show user-created approval templates with step summaries (e.g., “1: Team Lead → 2: Director → Final: Executive”). + * Allow template creation, modification, and deletion. + * During submission, a user can choose a template to auto-populate steps. + * Provide `Draft`/`Pending` filters so submitters can reopen saved approvals while restricting visibility for future-step approvers. + +3. **Approval History Menu** + + * Display list of submitted approvals with current state, responsible approver, and timestamp. + * In detail view, show all steps with status, timestamp, and memo timeline. + * If eligible, show **Recall** button; after recall, allow **Resubmit**. + * **terabits (Super Admin)** can view all approvals globally (read-only). + * Enforce visibility so only the submitter and already-acting approvers can open pending documents; final approvers gain access only when their step is activated. + +4. **Validation & Integration** + + * Validation must match the current frontend logic and framework conventions. + * Reuse existing UI components (dropdowns, toasts, validation rules). + * Enforce constraints: + + * No duplicate approvers + * Submitter cannot self-approve + * Final approver is mandatory + * Default inventory lists surface only fully approved transactions; drafts and submitted items live in dedicated "Pending Approvals" views. + +--- + +## 4. Backend Revision Plan + +1. **Data Model** + + * Existing schemas and API responses **can be modified** when justified, + but every change must maintain backward compatibility and prevent side effects. + * Key entities (if not already present): + + * `approval_requests`: Approval request header + * `approval_steps`: Per-step status tracking + * `approval_templates`: Saved approval flow templates + * `approval_template_steps`: Template step definitions + * `approval_audits`: Activity and state transition logs + +2. **Core Logic** + + * Enforce strict sequential flow between steps. + * Each approval/rejection triggers a state change and audit log entry. + * Recall allowed only if **step 1 approver** has not acted. + * Resubmission allowed only from recalled/rejected states. + * All transitions must be **atomic** and **transaction-safe**. + +3. **API Design** + + * Existing endpoints may be extended with new parameters or response fields if needed. + * Main APIs: + + * `POST /approval/submit` + * `POST /approval/approve` + * `POST /approval/reject` + * `POST /approval/recall` + * `POST /approval/resubmit` + * `GET /approval/history` + * `GET /approval/templates` + * Responses should remain backward-compatible while exposing additional fields (e.g., `approvalStatus`, `currentStep`). + +4. **Auditing and Monitoring** + + * Log all actions (create, submit, approve, reject, recall, resubmit) in `approval_audits`. + * Each entry records user ID, timestamp, action type, and metadata. + * Emit state-change events (e.g., Kafka or WebSocket) for real-time tracking. + +--- + +## 5. Change Control and Stability + +1. **Side-Effect Prevention** + + * Schema updates must include **pre-migration validation, backup, and rollback scripts**. + * Deploy via **staging → production**, with feature toggles for controlled rollout. + * Critical operations (approval/rejection) must be **idempotent** and transaction-safe. + +2. **Consistency and Concurrency** + + * Prevent duplicate approvers or skipped steps. + * Apply **optimistic locking/version control** for step updates. + * Centralize all approval state changes in a single service (e.g., `ApprovalService`). + +3. **Testing Criteria** + + * Existing modules (inbound/outbound/rental) must work with or without approval data. + * Verify all state transitions: submit → approve → final approve, reject, recall, resubmit. + * terabits can view all but not modify. + * Audit log integrity check for every state transition. + +--- + +## 6. Operations and Collaboration + +* **Frontend and Backend may both be modified** if agreed upon during design review. + + * All changes must be documented and version-controlled. + * Updated response specs must be reflected in OpenAPI/Swagger documentation. +* **Feature Toggles** must allow enabling/disabling approval features per environment. +* **Monitoring and Alerts:** + + * Track approval error rates, delays, and recall/resubmission frequency. + +--- + +## 7. Deliverables + +* **Frontend:** + + * `ApprovalStepUI` Component + * `ApprovalTemplateManager`, `ApprovalHistoryViewer` Modules +* **Backend:** + + * `ApprovalController`, `ApprovalService`, `ApprovalAuditService` + * Migration + Rollback Scripts + * Updated OpenAPI Documentation +* **Task Plans:** + + * Backend 세부 작업 계획 — `../superport_api_v2/doc/approval_flow_backend_task_plan.md` + * Frontend 세부 작업 계획 — `doc/approval_flow_frontend_task_plan.md` + +--- + +## 8. Final Directive + +* Backend and frontend **may be modified freely when necessary**, + but every modification must be **safely designed to prevent side effects**. +* The approval system must support **real workflow tracking** — including + role-based visibility, note sharing, audit logging, recall/resubmission, + and seamless integration with existing registration processes. +* The guiding principles are: + **Controlled Change**, **Traceability**, and **Operational Safety**. diff --git a/doc/IMPLEMENTATION_TASKS.md b/doc/IMPLEMENTATION_TASKS.md index 8dcf8e2..28520fd 100644 --- a/doc/IMPLEMENTATION_TASKS.md +++ b/doc/IMPLEMENTATION_TASKS.md @@ -69,6 +69,13 @@ - [x] 이력(`/approval-histories`): 조회 전용 테이블 (현황: AppLayout 기반 필터·페이지네이션 테이블과 기간 선택/엑셀 비활성 버튼까지 구현, 다운로드 API 연동은 후속 예정) - [x] 템플릿(`/approval-templates`): 목록/헤더+단계 반복 폼 (현황: AppLayout + FilterBar + 페이지네이션 테이블과 생성/수정/삭제/복구 플로우를 구현했고 단계 등록 API까지 연동 완료, 승인자 자동완성·권한 제어 등 추가 UX는 후속 예정) +### Approval Flow v2 (신규) +- [ ] 입고/출고/대여 등록 폼에 결재 단계 구성 섹션 추가 (`ApprovalStepConfigurator` 모달/섹션, `ShadTable` 기반 리스트) +- [ ] 결재 템플릿 관리자 UI 리디자인(단계 요약 칼럼, 템플릿 버전/적용 흐름, CRUD 모달 연동) +- [ ] 결재 이력 뷰어 확장(감사 로그, 회수/재상신 버튼, 권한 기반 노출, 타임라인 뷰) +- [ ] DTO/Repository/UseCase 개편으로 `/approval/submit|approve|reject|recall|resubmit`, `/approval/templates` 엔드포인트 연동 (DTO/Repository 확장 1차 완료, UseCase/컨트롤러 전환 대기) +- [ ] 위젯/통합 테스트 추가: 단계 98개 제한, 중복 승인자 검증, 회수/재상신 플로우 (`test/features/approvals/`, `integration_test/approvals_flow_test.dart`) + ## 8) 우편번호 검색 모달(UI) - [x] 입력: 검색어 텍스트 (현황: `/utilities/postal-search` 화면이 AppLayout 미리보기로 전환되어 `ShadInput`·검색 버튼으로 모달을 열고 초기 키워드 자동 검색까지 지원) - [x] 결과 테이블: 우편번호/시도/시군구/도로명/건물번호 (현황: 모달에서 `SuperportTable`로 컬럼을 렌더링하며 로딩/오류/빈 상태 메시지를 처리) diff --git a/doc/approval_flow_alignment_review.md b/doc/approval_flow_alignment_review.md new file mode 100644 index 0000000..0c52168 --- /dev/null +++ b/doc/approval_flow_alignment_review.md @@ -0,0 +1,79 @@ +# Approval Flow 정합성 점검 + +## 개요 +- 점검 일시: 2025-10-31 (KST) +- 대상 저장소: `superport_v2`(프런트엔드), `superport_api_v2`(백엔드) +- 범위: Approval Flow v2 도입 이후 프런트·백엔드 계약 준수 여부 + +## 발견 사항 (2025-10-30) + +### 1. 결재 상세 조회에 전표 동기화 정보 누락 +- 프런트의 결재 상세 API 호출이 `include=transaction`을 전달하지 않아 전표 `updated_at` 정보를 수신하지 못한다 (`lib/features/approvals/data/repositories/approval_repository_remote.dart:68-81`, `lib/features/approvals/presentation/controllers/approval_controller.dart:344-348`, `lib/features/approvals/history/presentation/controllers/approval_history_controller.dart:200-235`). +- 회수·재상신 UI는 `transactionUpdatedAt` 값을 필수로 확인하며 없을 경우 즉시 토스트를 띄우고 작업을 중단한다 (`lib/features/approvals/history/presentation/pages/approval_history_page.dart:1153-1238`). +- 백엔드는 회수/재상신 시 결재와 전표의 최종 수정 시각이 모두 일치해야 한다고 검증한다 (`backend/src/app/services/approvals.rs:760-784`) ; `transaction_expected_updated_at`이 빠지면 `TRANSACTION_VERSION_MISMATCH`가 발생한다. +- 영향: 사용자는 실제로 최신 데이터를 보고 있어도 전표 타임스탬프를 확보할 방법이 없어 회수·재상신을 실행할 수 없다. +- 권장 조치: + - 상세 조회 기본 include에 `transaction`(및 필요 시 `requested_by`)을 추가하도록 `ApprovalRepositoryRemote.fetchDetail`을 수정하고, 동일 로직을 사용하는 컨트롤러들이 별도 옵션 없이 최신 값을 받도록 한다. + - 회수/재상신 전 UI가 자동으로 재조회하면서 실패 시 재시도 안내를 제공하도록 낙관적 잠금 UX를 보완한다. + +#### 작업 항목 +- **프런트엔드** + - [x] `ApprovalRepositoryRemote.fetchDetail`에서 `includeParts` 기본값에 `transaction`을 추가하고, 필요 시 `requested_by`까지 묶어서 전달한다 (`lib/features/approvals/data/repositories/approval_repository_remote.dart`). + - [x] `ApprovalController.selectApproval` 및 `ApprovalHistoryController.loadApprovalFlow`가 옵션 없이 전표 정보를 수신하도록 fetch 호출부를 점검하고, `ApprovalFlow.transactionUpdatedAt` 캐시 로직을 재검증한다. + - [x] 회수/재상신 트리거 시 `refreshFlow` 재조회가 실패하면 재시도 문구를 안내하도록 토스트 메시지를 보완하고, 낙관적 잠금 시나리오 위젯 테스트를 추가한다 (`lib/features/approvals/history/presentation/pages/approval_history_page.dart`). +- **백엔드** + - [x] `ApprovalDetailResponse` 직렬화에 `transaction.updated_at`이 항상 포함되는지 통합 테스트로 보증하고(`backend/tests/api/approvals_flow.rs`), 누락 시 `ApprovalRepository::find_by_id` 결과 매핑을 점검한다. + +### 2. 결재 목록 “전체 상태” 조회에서 `include_pending` 누락 +- 프런트 목록 API 호출은 상태 필터가 `all`일 때도 `include_pending=true`를 전달하지 않고 있다 (`lib/features/approvals/data/repositories/approval_repository_remote.dart:36-53`). +- 스펙과 도메인 모델은 기본값이 승인·완료 상태만 반환하도록 정의하며, 대기/진행 중 건을 포함하려면 `include_pending=true` 또는 `status=draft,submitted,in_progress`를 명시해야 한다 (`stock_approval_system_api_v4.md:1231-1236`, `backend/src/domain/approvals/models.rs:146-189`). +- 영향: 백엔드가 스펙대로 기본 필터를 적용하면 UI의 “전체 상태” 목록이 승인·완료 건만 노출되어 사용자 기대와 불일치가 발생한다. +- 권장 조치: + - 컨트롤러에서 상태 필터가 `all`일 때 `include_pending=true`를 전달하도록 쿼리 파라미터를 확장하고, 필요 시 `status` 문자열 필터로 명시적인 다중 상태 조회를 지원한다. + - 목록 헤더/필터 라벨이 실제 반환 범위와 일치하도록 UX 문구도 함께 재검토한다. + +#### 작업 항목 +- **프런트엔드** + - [x] `ApprovalRepositoryRemote.list` 호출 시 `ApprovalStatusFilter.all`이면 `include_pending=true`를 쿼리에 추가하고, 필터에 따라 `status` 문자열을 조립하도록 로직을 갱신한다. + - [x] `ApprovalController` 필터 상태(`_statusIdFor`, `_statusCodeFor`)가 새 쿼리 파라미터에 맞춰 동작하도록 단위 테스트를 추가하고, 목록/필터 위젯 테스트를 보완한다. + - [x] “전체 상태” UI 라벨과 도움말이 실제 반환 범위를 설명하도록 변경한다 (`lib/features/approvals/presentation/pages` 관련 위젯). +- **백엔드** + - [x] `GET /approvals`에서 `include_pending` 동작이 스펙과 일치하는지 e2e 테스트를 추가하고, 요청 파라미터가 누락될 경우 기본 필터가 승인·완료로 제한됨을 문서(`stock_approval_system_api_v4.md`)에 재확인한다. (저장소 상태 필터 적용 및 정규화 테스트 보강) + +## 발견 사항 (2025-10-31) + +### 3. 결재 목록 조회 시 상신자·전표 요약 누락 +- 프런트 목록 API는 기본 include에 `steps`/`histories`만 추가하고 있어 `requester`·`transaction` 정보를 요청하지 않는다 (`lib/features/approvals/data/repositories/approval_repository_remote.dart:27-57`). 이에 따라 DTO가 비어 있는 맵을 파싱하면서 상신자 ID가 0, 이름이 `-`로 대체되고 전표 번호도 누락된다 (`lib/features/approvals/data/dtos/approval_dto.dart:85-143`, `lib/features/approvals/data/dtos/approval_dto.dart:195-204`). +- 백엔드는 `include.requested_by` / `include.transaction` 플래그가 켜졌을 때만 해당 요약을 조인하므로, 목록 응답에 최소 정보조차 제공되지 않는다 (`backend/src/adapters/repositories/approvals.rs:162-210`). +- 영향: 결재 목록의 “상신자/전표번호” 열이 항상 `-`로 표시되고, 선택 항목에서 상신자 ID가 0으로 초기화되어 재상신·필터 유지 등 후속 동작에서 상신자 정보를 잃는다. +- 권장 조치: + - 목록 조회 기본 include에 `requested_by`, `transaction`을 추가해 UI가 필요한 요약 데이터를 항상 수신하도록 한다. + - DTO 파싱 시에도 최악의 경우를 대비해 `requested_by_id` 등 기본 필드로 최소한의 ID 정보를 보존한다. + +#### 작업 항목 +- **프런트엔드** + - [x] `ApprovalRepositoryRemote.list` 기본 include에 `requested_by`, `transaction`을 더하고 관련 위젯/단위 테스트를 갱신한다 (`lib/features/approvals/data/repositories/approval_repository_remote.dart`). + - [x] `ApprovalDto.fromJson`이 `requester_id` 등 단일 필드를 이용해 ID를 보강하도록 로직을 보완한다 (`lib/features/approvals/data/dtos/approval_dto.dart`). +- **백엔드** + - [x] (선택) 하위 호환을 위해 include 미지정 시 최소 `requester` 요약을 포함할지 검토한다 (`backend/src/adapters/repositories/approvals.rs`). + +### 4. 서버 임시저장(Approval Draft) API 미연동 +- 백엔드는 `/api/v1/approval-drafts`에서 초안 목록/조회/저장/삭제 기능을 제공하지만 (`backend/src/api/v1/approval_drafts.rs:12-99`, `backend/src/app/services/approvals.rs:1196-1286`), 프런트에는 해당 엔드포인트를 호출하는 레포지토리나 use case가 없다. +- 현재 프런트 컨트롤러는 메모리 내 `_submissionDraft`만 유지하며 세션이 끊기거나 다른 기기로 이동하면 초안을 복구할 방법이 없다 (`lib/features/approvals/presentation/controllers/approval_controller.dart:398-415`, `lib/features/inventory/inbound/presentation/controllers/inbound_controller.dart:145-151`). +- 영향: 서버 기반 임시저장 기능을 활용하지 못해 다중 기기/장시간 작업 시 초안 복구 요구사항을 충족하지 못한다. +- 권장 조치: + - Approval Draft 전용 경로/DTO/레포지토리를 추가하고, 결재 작성 및 인벤토리 폼 컨트롤러가 서버 초안을 저장·복원할 수 있도록 통합한다. + - 초안 저장/복원 흐름에 대한 위젯·통합 테스트와 문서화를 추가한다. + +#### 작업 항목 +- **프런트엔드** + - [x] `ApiRoutes`에 `/approval-drafts` 경로를 추가하고 원격 레포지토리/DTO/유즈케이스를 구현한다 (신규 파일, `lib/core/network/api_routes.dart`). + - [x] `ApprovalRequestController` 및 인벤토리 컨트롤러에 서버 초안 저장·복구 흐름을 연결하고 의존성 주입을 갱신한다 (`lib/features/approvals/request/presentation/controllers/approval_request_controller.dart` 등). + - [x] 초안 관련 위젯/통합 테스트를 추가해 회귀를 방지한다 (`test/features/approvals/**`). +- **백엔드** + - [ ] 초안 엔드포인트 사용 예시를 스펙 문서에 보강하고 프런트 연동용 샘플을 공유한다 (`stock_approval_system_api_v4.md`, `doc/ApprovalFlow_System_Integration_and_ChangePlan.md`). + +## 권장 후속 절차 +- 위 항목 개선 후 `flutter analyze`, `flutter test`, `cargo test`를 실행해 회 regressions 여부를 확인한다. +- 낙관적 잠금 관련 시나리오는 `backend/tests/api/approvals_flow.rs` 및 대응 위젯 테스트를 추가/보강해 재현 가능성을 확보한다. +- 작업 완료 시 본 문서를 업데이트하고 관련 QA 체크리스트에 반영 상황을 기록한다. diff --git a/doc/approval_flow_frontend_task_plan.md b/doc/approval_flow_frontend_task_plan.md new file mode 100644 index 0000000..b28fcb7 --- /dev/null +++ b/doc/approval_flow_frontend_task_plan.md @@ -0,0 +1,82 @@ +# Approval Flow 프런트엔드 작업 계획 + +- 기준 문서: `doc/ApprovalFlow_System_Integration_and_ChangePlan.md`, 백엔드 계획(`../superport_api_v2/doc/approval_flow_backend_task_plan.md`) +- 범위: 입고/출고/대여 등록 화면 결재 단계 구성 UI, 결재 템플릿/이력 메뉴 전면 개편, 감사 로그/회수/재상신 UX 정비 +- 작업 순서: 사전 정비 → 데이터 계층 → UI/상호작용 → 검증/UI/테스트 → 문서/배포 + +--- + +## 0. 킥오프 & 환경 준비 +- [x] **F0-1** 백엔드 스키마/엔드포인트 변경 리뷰 및 DTO 영향 범위 표 정리 (`doc/frontend_backend_alignment_report.md` 갱신) +- [x] **F0-2** 기능 토글(`feature.approval_flow_v2`) 주입 경로 설계 (`lib/core/config/feature_flags.dart`, `Environment.initialize`) +- [x] **F0-3** QA용 샘플 데이터(결재 단계 1~5단계, 회수/반려 케이스) 확보 후 `test/fixtures/approvals/`에 JSON 추가 + +## 1. 데이터 계층 업데이트 +- [x] **F1-1** `ApprovalRequestDto`/`ApprovalStepDto`/`ApprovalAuditDto` 추가 및 기존 DTO 개편 (`lib/features/approvals/data/dtos/`) +- [x] **F1-2** `ApprovalRepositoryRemote` API 시그니처 확장: 제출/승인/반려/회수/재상신/템플릿 CRUD(`/approval/*`) 연동 (유즈케이스 연결은 F2 단계에서 후속 진행) +- [x] **F1-3** `StockTransactionDto`/`StockTransactionInput`에 결재 구성 필드 추가 (`lib/features/inventory/data/dtos/`) +- [x] **F1-4** `ApiClient` 요청 헬퍼에 새 엔드포인트 경로/쿼리 빌더 추가 (`lib/core/network/api_client.dart`) +- [x] **F1-5** `lib/injection_container.dart` 의존성 갱신: 신규 레포지터리/유즈케이스 바인딩 + +## 2. 도메인 & 유즈케이스 +- [x] **F2-1** `ApprovalFlowEntity` 정의: 제출자/최종 승인자/단계/이력/상태 요약 포함 (`lib/features/approvals/domain/entities/approval_flow.dart`) +- [x] **F2-2** 제출/승인/반려/회수/재상신 유즈케이스 구현 (`lib/features/approvals/domain/usecases/submit_approval_use_case.dart` 등) +- [x] **F2-3** 템플릿 CRUD 유즈케이스 분리(`SaveApprovalTemplate`, `ApplyApprovalTemplate`) +- [x] **F2-4** `Inventory` 도메인에 결재 설정 전달용 값 객체 추가 (`lib/features/inventory/*/domain/entities/create_*_request_input.dart`) + +## 3. 상태관리 & 컨트롤러 +- [x] **F3-1** `ApprovalRequestController` 재구성: 단계 98개 제한, 중복 승인자 검사, 제출자/최종 승인자 바인딩 +- [x] **F3-2** `ApprovalTemplateController` 확장: 템플릿 목록/저장/삭제/적용, 버전 체크 +- [x] **F3-3** `ApprovalHistoryController` 개선: recall/resubmit 액션, 감사 로그 탭 분리 +- [x] **F3-4** `Inbound/Outbound/Rental` 페이지 컨트롤러에서 결재 구성 상태 저장 및 제출 요청 병합 +- [x] **F3-5** `AuthGuard`/`Router`에 결재 템플릿/이력 메뉴 권한 플래그 연결 +- [x] **F3-6** 임시저장/재개 플로우 구현: 결재 관리 목록에 `draft` 필터 추가, 세션 종료 후 초안 복구 UX 설계 + +## 4. UI 구성 요소 +- [x] **F4-1** `ApprovalStepConfigurator` 모달/섹션 구현 (`lib/features/approvals/request/presentation/widgets/`) +- [x] **F4-2** `ApprovalStepRow` 컴포넌트: 순번, 승인자 검색, 역할, 삭제 버튼, 오류 표시 +- [x] **F4-3** `ApprovalTemplatePicker` UI: 템플릿 선택/미리보기/적용/새로 저장 플로우 +- [x] **F4-4** `ApprovalTemplateManagerPage` 리디자인: `ShadTable` 적용, 단계 요약 칼럼, CRUD 모달 연동 +- [x] **F4-5** `ApprovalHistoryPage` 리디자인: 상태 타임라인, 감사 로그, 회수/재상신 버튼 상태 표시 +- [x] **F4-6** 입고/출고/대여 등록 폼에 Approval 섹션 삽입(`lib/features/inventory/*/presentation/pages/`) +- [x] **F4-7** `SuperportDialog`/`ShadTable` 커스텀 컬럼 추가: 승인자 아바타, 상태 뱃지, 메모 툴팁 + +## 5. 검증 & UX 개선 +- [x] **F5-1** 제출 폼 검증: 최종 승인자 필수, 제출자 자기 승인 금지, 중복 승인자 방지 +- [x] **F5-2** 단계 정렬/Drag & Drop 옵션 검토(필수 아님) 및 순서 변경 UX 결정 +- [x] **F5-3** 회수 가능 조건(첫 승인자 미행동) UI 표시 및 비활성화 처리 +- [x] **F5-4** 반려/회수/재상신 토스트/다이얼로그 메시지 표준화 (한국어) +- [x] **F5-5** 감사 로그 뷰어에 필터(행위자, 액션, 기간) 추가 +- [x] **F5-6** 대시보드 `pending approvals` 카드가 새 상태/요약을 노출하도록 업데이트 +- [x] **F5-7** 결재 열람 권한/최종대기 노출 제한 UX: 상신자·기결재자만 상세 접근, 최종 승인 대기 전표 기본 목록 비노출 및 대기 섹션 분리 + +## 6. 테스트 +- [x] **F6-1** 단위 테스트: DTO 직렬화/역직렬화, 유즈케이스 권한 체크 (`test/features/approvals/domain/`) +- [x] **F6-2** 위젯 테스트: 결재 구성 모달, 템플릿 적용, 회수/재상신 흐름 (`test/features/approvals/presentation/`) +- [x] **F6-3** 통합(골든) 테스트: 입고 등록 → 결재 제출 → 승인자 전환 UI (`integration_test/approvals_flow_test.dart`) + - [x] **F6-4** 모킹 대신 스테이징 API 더블 사용을 위한 HttpOverrides 정비(토글 기반) + ↳ `flutter_test_config.dart`에서 `USE_APPROVAL_STAGING_DOUBLE` 토글 시 허용 호스트 기반 HttpOverrides를 주입하도록 구성 + - [x] **F6-5** 테스트 데이터 정비: 승인자 목록/권한/템플릿 샘플 업데이트 + ↳ `test/helpers/fixture_loader.dart` 추가, `test/fixtures/approvals/*.json` 및 `ApprovalApproverCatalog`를 스테이징 샘플과 동기화 + - [x] **F6-6** 권한/노출 테스트: 초안 복구, 비도달 승인자 403, 최종 승인 전 리스트 비노출 시나리오 + ↳ `approval_controller_test.dart`, `approval_history_controller_test.dart`, `approval_form_initializer_test.dart` 등에서 초안 복구/403 차단/목록 비노출 플로우 검증 + +## 7. 문서 & 개발자 경험 +- [x] **F7-1** `doc/IMPLEMENTATION_TASKS.md`에 Approval Flow 섹션 추가 및 진행 상태 트래킹 +- [x] **F7-2** `doc/frontend_api_alignment_plan.md`에 엔드포인트/계약 변화 반영 +- [x] **F7-3** `doc/frontend_backend_alignment_report.md`에 프런트 측 후속 작업 연결 +- [x] **F7-4** `lib/widgets/` 컴포넌트 가이드에 결재 위젯 사용법 추가 (필요 시) +- [x] **F7-5** 완료 시 `notify.py` 워크플로 실행 및 알림 (`/Users/maximilian.j.sul/.codex/notify.py`) + +## 8. 배포 & 롤백 +- [ ] **F8-1** 기능 토글 기본 비활성 상태로 머지 → 백엔드 배포/마이그레이션 완료 후 활성화 +- [ ] **F8-2** 스테이징 UAT 체크리스트: 제출/승인/반려/회수/재상신/템플릿 CRUD/대시보드 반영 +- [ ] **F8-3** 운영 배포 전 QA 결과 공유 및 위험 항목 점검, 롤백 시 토글 비활성화 절차 문서화 +- [ ] **F8-4** 배포 후 모니터링: 에러 토스트/네트워크 실패 레포트 수집, 사용자 피드백 채널 열람 + +--- + +### 참고 링크 +- 백엔드 계획: `../superport_api_v2/doc/approval_flow_backend_task_plan.md` +- 스펙 문서: `doc/stock_approval_system_spec_v4.md`, `doc/stock_approval_system_api_v4.md` +- QA 체크리스트: `doc/qa/approval_flow_uat_checklist.md` (작성 대상) diff --git a/doc/frontend_api_alignment_plan.md b/doc/frontend_api_alignment_plan.md index 7aededf..42b2f49 100644 --- a/doc/frontend_api_alignment_plan.md +++ b/doc/frontend_api_alignment_plan.md @@ -3,14 +3,22 @@ ## 진행 현황 스냅샷 (2025-10-19 기준) - 단계 1~2: 공통 네트워크 인프라와 마스터 도메인 원격 저장소/테스트가 모두 반영되어 실 API 계약 기준 코드가 자리잡았다. - 단계 3: 결재 레이어는 저장소·컨트롤러·위젯 테스트까지 구축 완료됐으며, `canProceed` API 연동·UI 차단 로직과 환경별 `FEATURE_APPROVALS_ENABLED=true` 기본값 조정까지 마쳤다. -- 단계 4: 재고 트랜잭션 컨트롤러와 submit/complete 플로우가 API 호출로 전환됐고, 고객 필터/위젯에서 사용하던 정적 카탈로그를 제거하여 전 구간이 실데이터를 사용한다. 보고서 기능은 `ReportingRepositoryRemote` 기반으로 API에 연결돼 다운로드 링크/바이너리 응답을 모두 처리하며, UI는 진행 상태·에러·다운로드 액션(열기/URL 복사)을 제공한다. +- 단계 4: 재고 트랜잭션 컨트롤러와 submit/approve/reject/cancel/complete 플로우가 API 호출로 전환됐고, 고객 필터/위젯에서 사용하던 정적 카탈로그를 제거하여 전 구간이 실데이터를 사용한다. 보고서 기능은 `ReportingRepositoryRemote` 기반으로 API에 연결돼 다운로드 링크/바이너리 응답을 모두 처리하며, UI는 진행 상태·에러·다운로드 액션(열기/URL 복사)을 제공한다. - 단계 5: 테이블 spec 분리는 완료됐고, 권한 경로 통일·Failure 파서 고도화·실패 메시지 통합·실제 API 플로우 검증이 잔여 과제로 남아 있다. +- (2025-10-29) Approval Flow v2 대응을 위해 `ApprovalSubmissionInput` 등 도메인 입력 모델과 `/approval/submit|approve|reject|recall|resubmit|history` 호출을 Data 레이어에 도입했다. 기존 `create/update` 경로는 레거시 화면이 교체될 때까지 병행 유지한다. ## 문서 동기화 규칙 1. `superport_api_v2` 리포지터리의 `stock_approval_system_*.md` 문서를 단일 소스로 간주하고, 수정은 반드시 백엔드 리포지터리에서 먼저 수행한다. 2. 백엔드 문서 변경 후 프론트 리포지터리 루트에서 `tool/sync_stock_docs.sh`를 실행해 `doc/` 경로를 갱신한다. CI 또는 로컬 검증 시에는 `tool/sync_stock_docs.sh --check`로 차이를 확인한다. 3. 문서 차이가 감지되면 동기화 커밋을 생성하고 PR 본문에 백엔드 커밋 링크를 포함해 리뷰어가 출처를 추적할 수 있도록 한다. +## Approval Flow v2 연동 계획 (신규) +1. 백엔드 세부 작업 계획(`../superport_api_v2/doc/approval_flow_backend_task_plan.md`)과 프런트 작업 계획(`doc/approval_flow_frontend_task_plan.md`)을 기준으로 동시 진행한다. +2. 입고/출고/대여 등록 화면은 결재 단계 구성 섹션을 추가하고 제출 요청에 Approval payload를 병합한다. +3. 결재 템플릿/이력 메뉴는 `ShadTable` 기반으로 재구성하고 recall/resubmit, 감사 로그 UI를 확장한다. +4. Approval 관련 DTO/레포지터리/유즈케이스를 전면 재정비하여 신규 엔드포인트(`/approval/submit|approve|reject|recall|resubmit`, `/approval/templates`)와 계약을 맞춘다. +5. 테스트 체계는 위젯/통합 테스트에서 결재 단계 추가/삭제/회수/재상신 플로우를 검증하고, `integration_test`에 시나리오를 추가한다. + ## 0. 사전 준비 및 브랜치 전략 1. 현재 백엔드 서버는 아직 기동되지 않았지만, 모든 기능은 실제 API 계약(`stock_approval_system_api_v4.md`)을 기준으로 구현한다. 2. 프론트엔드 작업용 브랜치를 `feature/api-integration` 형태로 생성하고, 단계별 작업이 끝난 뒤 스쿼시 머지한다. @@ -40,22 +48,27 @@ ## 3. 결재(Approvals) 도메인 실연동 준비 1. [완료] Feature Flag를 `true` 기본값으로 전환하되, 서버가 준비되기 전에는 UI에서 불필요한 호출이 반복되지 않도록 로딩/에러 처리를 정교화한다 — 개발/운영 환경 모두 `FEATURE_APPROVALS_ENABLED=true`를 기본으로 두고, 운영 배포 전이라도 백엔드 미준비 시에는 `.env.*`에서 수동으로 비활성화하도록 가이드를 명시했다. -2. [완료] `ApprovalRepositoryRemote`에 목록/상세 `include=steps,histories,template` 옵션과 생성/수정/삭제/복구/`canProceed` 호출을 구현한다 — `can-proceed` 엔드포인트까지 연동해 컨트롤러에서 액션 실행 전 검증하도록 구성했다. +2. [진행중] `ApprovalRepositoryRemote` 확장 + - (완료) 목록/상세 `include=steps,histories,template` 옵션과 생성/수정/삭제/복구/`canProceed` 호출을 구현했다 — `can-proceed` 엔드포인트까지 연동해 컨트롤러에서 액션 실행 전 검증하도록 구성했다. + - (2025-10-29) `submit`/`approve`/`reject`/`recall`/`resubmit`/`listHistory` 메서드와 대응 DTO(`ApprovalSubmitRequestDto`, `ApprovalResubmitRequestDto`, `ApprovalDecisionRequestDto`, `ApprovalRecallRequestDto`, `ApprovalAuditListDto`)를 추가했다. 컨트롤러·유즈케이스 연결은 F2 단계에서 마이그레이션한다. 3. [완료] `ApprovalStepController.performStepAction`이 `/api/v1/approval-steps/{id}/actions`로 요청을 보낸 뒤 응답으로 상태를 갱신하도록 구성한다. 4. [완료] Approval Templates - 템플릿 CRUD/restore 및 스텝 편집 API 연동을 구현하고, 템플릿 적용 시 `/approvals/{id}/steps` 호출과 연계되도록 리팩터링한다. 5. [완료] 테스트 - `ApprovalController`와 `ApprovalPage` 권한 테스트에 `canProceed` true/false 흐름을 추가했고, 기능 플래그 on/off 시나리오를 커버하는 위젯 테스트를 유지하고 있다. +6. [ ] 결재 열람 제한 연동 + - 상신자/기결재자만 목록·상세 API를 조회할 수 있도록 `ApprovalRepositoryRemote`에 403 (`APPROVAL_ACCESS_DENIED`) 처리 분기를 추가하고, UI에서 권한 토스트/리다이렉트를 구현한다. ## 4. 재고 트랜잭션 (입고/출고/대여) 실데이터 전환 준비 1. [완료] Repository 작성 - `StockTransactionRepositoryRemote`, `TransactionLineRepositoryRemote`, `TransactionCustomerRepositoryRemote`를 `/api/v1/stock-transactions` 계열 엔드포인트에 맞춰 구현한다. - `include=lines,customers,approval` 파라미터를 지원해 상세 응답을 완성한다. - ApiClient 모킹 기반 단위 테스트로 쿼리/경로/페이로드 구성을 검증한다. + - 신규 `status`/`include_pending` 파라미터를 지원해 초안·상신 전표는 기본 목록에서 제외하고, 대기 전용 화면에서만 렌더링한다. 2. [진행중] Controller 연동 - (완료) `InboundPage`, `OutboundPage`, `RentalPage`에서 `_mockRecords`를 제거하고 `StockTransactionRepository` 기반 실데이터를 로드하도록 전환했다. - (완료) 데이터 페칭 로직을 전용 컨트롤러로 분리하고 페이지가 컨트롤러 상태를 구독하도록 리팩터링했다. - - (진행) 상태 전이 액션(Submit/Approve/Reject/Cancel/Complete)을 API 호출 기반으로 대체한다 — submit/complete는 컨트롤러와 위젯에 연결되어 있으나 approve/reject/cancel 버튼/토스트 연결, 생성·수정 다이얼로그에서 `StockTransaction*Input` 매핑, 신규 공통 위젯을 활용한 필드 교체가 남아있다. + - (진행) 상태 전이 액션(Submit/Approve/Reject/Cancel/Complete)을 `doc/stock_approval_system_api_v4.md` 4.7절 규격에 맞춰 API 호출 기반으로 정비한다 — submit/approve/reject/cancel/complete 모두 컨트롤러·위젯에 연결되도록 리팩터링하고, 생성·수정 다이얼로그의 `StockTransaction*Input` 매핑과 공통 위젯 교체를 마무리한다. 3. 상세 모달 UI - 서버 응답 스키마에 맞춘 DTO→Domain 변환기를 작성하고, 편집/삭제 후 상태 동기화를 서버 응답으로 수행한다. 4. 테스트 @@ -102,7 +115,7 @@ 1. [완료] 백엔드 서버 기동 및 점검 - Homebrew로 PostgreSQL 16을 설치해 로컬 DB를 준비한 뒤, `migration/001_initial_schema.sql`과 `script/load_sample_data.sh --with-demo-data`로 스키마·샘플 데이터를 적재했다. - `cargo run -p backend`를 `nohup`으로 기동하고 `curl http://127.0.0.1:8080/health`로 헬스 체크를 확인했다. - - `script/run_api_tests.sh --base-url http://127.0.0.1:8080` 실행 결과 110개 시나리오 중 98건 통과, 12건(재고 전이 흐름 및 그룹-메뉴 권한 복구)이 실패함을 확인했다. 실패 케이스는 미구현 엔드포인트 목록과 함께 log/API_TEST_RESULT_YYYYMMDD_HHMMSS.txt에 기록해 백엔드 공유 예정. + - `script/run_api_tests.sh --base-url http://127.0.0.1:8080`를 `stock_approval_system_api_v4.md` (2025-09-18) 스펙 반영 버전으로 재실행해 상태 전이·권한 복구 시나리오가 모두 통과하는지 확인하고, 결과 로그(log/API_TEST_RESULT_YYYYMMDD_HHMMSS.txt)를 갱신한다. - (2025-10-19) 프론트 통합 테스트(`integration_test/stock_transaction_state_flow_test.dart`)의 환경 변수 안내 및 `.env.staging.example` 템플릿을 추가했다. 백엔드에서 재고 상태 전이/권한 복구 엔드포인트를 배포하면 스테이징 토큰·ID를 확보한 뒤 재실행한다. 2. [완료] 정적 분석 및 테스트 - `flutter analyze` → No issues found. @@ -114,11 +127,10 @@ - `assets/.env.production` 기준 값과 README의 환경 변수 설명을 재확인했으며, `flutter build web --release` 산출물을 통해 배포 아티팩트 생성을 검증했다. - 최종 머지 전 `notify.py` 호출 및 릴리스 노트/환경 파일 확정 프로세스는 배포 승인 시점에 수행하도록 안내를 남긴다. -- 백엔드 미구현 항목 대응 전략 -- [x] 재고 상태 전이 실패(Stage 7 미구현 8건) 관련 API 스펙을 백엔드 팀과 재검토하고, `PATCH /stock-transactions/{id}/status` 확장 일정과 테스트 데이터 세트를 공유한다. (2025-10-19) `doc/backup/backend_change_requests.md` 2.4절에 상태 전이 API 요구사항과 테스트 데이터 항목을 업데이트했다. -- [x] 그룹-메뉴 권한 복구 미구현(4건)은 `/group-menu-permissions/{id}/restore` 엔드포인트 공개 후 프론트 통합 테스트에 포함시킨다. (2025-10-19) 동일 문서 2.2절에 복구 API 요구사항을 명시하고 테스트 시나리오를 정리했다. - - [x] 프론트단에서는 `ApiErrorMapper`와 `Failure` 파서를 보강해 403/409/422 응답 메시지를 토스트·다이얼로그에 그대로 노출하고, 재시도 시 가이드 문구를 제공한다. -- [x] 백엔드 수정 전까지 승인/취소 버튼에는 기능 플래그를 적용해 운영 환경에서 잘못된 전이 요청이 발생하지 않도록 보호한다. (2025-10-19) `FEATURE_STOCK_TRANSITIONS_ENABLED` 플래그를 추가하고 입·출·대여 화면에서 버튼을 비활성화하며 안내 배지를 노출하도록 조정했다. +- 백엔드 v4 스펙 반영 체크리스트 +- [ ] 재고 상태 전이 API 회귀 테스트를 `doc/stock_approval_system_api_v4.md` 4.7절 기준으로 재작성하고 submit/approve/reject/cancel/complete 호출 성공 여부를 통합 테스트에 반영한다. +- [ ] 그룹-메뉴 권한 복구 API(`POST /group-menu-permissions/{id}/restore`) 시나리오를 복구해 삭제/복구 UI가 `include_deleted=true` 응답을 사용하는지 검증한다. +- [ ] 백엔드 배포 확인 후 `FEATURE_STOCK_TRANSITIONS_ENABLED` 플래그 해제 시나리오와 운영 전환 체크리스트를 정리한다. ## 8. 재고 생성 결재 정보 수집 계획 (2024-08-XX 업데이트) 1. **신규 입력 필드 구성** diff --git a/doc/frontend_backend_alignment_report.md b/doc/frontend_backend_alignment_report.md index 677f03c..12da26d 100644 --- a/doc/frontend_backend_alignment_report.md +++ b/doc/frontend_backend_alignment_report.md @@ -3,6 +3,7 @@ ## 개요 - 기준 문서: `doc/backup/backend_change_requests.md`와 최신 계약 문서(`doc/stock_approval_system_api_v4.md`)를 토대로 Flutter 프런트(`superport_v2`)와 Rust 백엔드(`superport_api_v2`) 구현을 재검증했다. - 백엔드 팀이 전달한 최신 패치(로그인/트랜잭션, 결재 단계, 대시보드·보고서, 권한)와 `cargo test` 통과 결과를 반영해 실제 로그인 → 대시보드 → 재고/결재 → 보고서/권한 흐름을 다시 점검했다. +- Approval Flow 전면 개편 합의를 위해 백엔드 작업 계획(`../superport_api_v2/doc/approval_flow_backend_task_plan.md`)과 프런트 작업 계획(`doc/approval_flow_frontend_task_plan.md`)을 신규 작성했다. ## 주요 정합성 결과 | 구분 | 내용 | 결과 | 후속 조치 | @@ -14,6 +15,7 @@ | 5 | 그룹-메뉴 권한 `path`·`is_deleted`·`include_deleted` | ✅ 해결 (`backend/src/domain/group_menu_permissions.rs:149`, `backend/src/adapters/repositories/group_menu_permissions.rs:227`) | DTO/필터·권한 편집 UI가 추가 필드로 회귀 없는지 테스트 | | 6 | 대시보드 KPI `delta` 전일 대비 비율 계산 | ✅ 해결 (`backend/src/adapters/repositories/dashboard.rs:61`) | KPI 카드/차트가 백분율·부호 표시를 지원하는지 확인 | | 7 | 사용자 요약(`created_by`, `requester`) 기본 노출 및 회귀 테스트 | ✅ 해결 (`backend/src/domain/approval_templates.rs:34`, `backend/src/adapters/repositories/approval_templates.rs:100`, `backend/src/adapters/repositories/approvals.rs:878`, `backend/src/adapters/repositories/stock_transactions.rs:1173`, `backend/src/adapters/repositories/reports.rs:256`) | 프런트 DTO가 사번(`employee_id`)·이름을 모두 반영하는지, 리스트/리포트 표시가 정상인지 검증 | +| 8 | Approval Flow v2: 트랜잭션 결재 구성 필수화 + `/approval/*` 엔드포인트 확장 | 🚧 진행중 (`backend/src/domain/stock_transactions.rs:365`, `backend/src/domain/approvals/models.rs:583`, `backend/src/api/v1/approval_flow.rs:13`) | 프런트 DTO/리포지토리 확장(`lib/features/inventory/transactions/data/dtos/`, `lib/features/approvals/data/`) 및 기능 토글 기반 UI 연동 필요 | 아래 섹션에서 영역별 관찰 내용과 프런트엔드 후속 작업을 정리했다. @@ -31,12 +33,22 @@ - `expected_return_date`가 생성/수정/조회 전 흐름에 포함된다(`backend/src/domain/stock_transactions.rs:274`, `backend/src/adapters/repositories/stock_transactions.rs:808`). 프런트 `StockTransactionInput`과 `RentalPage`는 이미 필드를 전송하므로, 저장 후 상세/목록에서 값이 노출되는지 UI 테스트를 추가하면 된다(`lib/features/inventory/rental/presentation/pages/rental_page.dart:1651`). - 마이그레이션 `migration/006_add_expected_return_date_to_stock_transactions.sql`을 반드시 적용해야 하며, 로컬/스테이징 DB에 컬럼이 없으면 500 에러가 발생한다. DevOps와 일정 합의 후 `diesel migration run`을 실행하고 `.env` DB URL을 재확인한다. - 추가 확인: 고객 정보(`customers[].customer`), 거래 라인 메모, 템플릿명 등 선택 필드가 null일 때 키가 빠지지 않는지 샘플 데이터를 확보해 양쪽 DTO 직렬화/역직렬화 테스트를 보강한다. +- 최종 승인 완료 전에는 기본 입고/출고/대여 목록에서 전표가 숨겨져야 하므로, 프런트 목록/완료 카드에 `status=draft|submitted` 필터를 추가하고 대기 전용 섹션을 제공한다. ## 결재 단계 - 목록 API가 `q`·`status_id` 필터를 처리하고 응답에 `transaction_no`를 포함한다(`backend/src/adapters/repositories/approval_steps.rs:176`). 프런트 검색 바(`lib/features/approvals/step/presentation/controllers/approval_step_controller.dart`)가 두 파라미터를 전달하는지, 리스트에서 거래번호를 표시하는지 확인한다. - 도메인이 `status` 구조체(`{ id, name, code }`)를 반환한다(`backend/src/domain/approval_steps.rs:84`). 프런트 DTO는 `status_id` 입력과 `status` 응답을 모두 지원해야 하므로, 레거시 필드 제거와 단위 테스트(`test/features/approvals/step/domain/`) 성공 여부를 점검한다. - 컨트롤러/위젯 테스트: 필터링, 상태 변경, 거래번호 표시 흐름을 추가해 회귀를 방지한다. - 추가 확인: `histories[].action`에 레거시 데이터가 들어오는 경우(`id`, `name` 누락) 프런트가 안전하게 폴백 문자열을 표시하는지, 백엔드는 해당 케이스를 데이터 정제 로직으로 보완할지 정한다. +- 열람 권한은 상신자와 이미 결재한 승인자에게만 부여된다. 단계 미도달 승인자는 목록/상세 접근 시 403 처리되므로, 프런트 `ApprovalDetailPage`·`MyApprovals`에서 숨김/권한 안내 토스트를 구현하고 최종 승인 대기 상태에서도 상신자·중간 승인자만 접근 가능하도록 필터링한다. + +## Approval Flow v2 +- 입·출·대여 생성 요청에 `approval` 블록이 필수(`backend/src/domain/stock_transactions.rs:365`)이며, `approval.config`는 템플릿(`template_id`) 또는 직접 지정 단계(`steps[]`) 중 하나가 존재해야 한다(`backend/src/domain/stock_transactions.rs:384`). 프런트 입력모델(`lib/features/inventory/transactions/domain/entities/stock_transaction_input.dart:1`)은 `approval`을 선택이 아닌 필수 값으로 승격하고, 최소 1단계 + 최종 승인자 검증을 위젯 레벨에서 선반영해야 한다. +- 트랜잭션 목록 기본 필터는 승인 완료 건만 노출하고 `include_pending` 파라미터를 명시해야 대기/초안이 반환된다(`backend/src/domain/stock_transactions.rs:29`, `backend/src/domain/stock_transactions.rs:178`). 프런트 리스트 필터(`lib/features/inventory/transactions/domain/entities/stock_transaction_input.dart:158`)와 대시보드 카드가 새 파라미터를 전달하도록 조정하고, 결재 대기 전표 전용 섹션을 기능 토글(`feature.approval_flow_v2`)로 가드한다. +- 결재 제출·재상신 엔드포인트는 `ApprovalSubmitRequest`/`ApprovalResubmitRequest`를 사용하며 전 단계 배열을 전송해야 한다(`backend/src/domain/approvals/models.rs:583`, `backend/src/api/v1/approval_flow.rs:13`). 프런트는 `ApprovalRequestDto`·`ApprovalStepDto`·`ApprovalAuditDto` 신설 후 `ApprovalRepositoryRemote`를 통해 `/approval/submit|resubmit` 호출 시 단계 순번·승인자 ID를 직렬화한다. +- 승인/반려/회수 액션은 `actor_id`가 세션 사용자와 일치해야 하고 옵티미스틱 잠금(`expected_updated_at`, `transaction_expected_updated_at`)을 요구한다(`backend/src/domain/approvals/models.rs:624`, `backend/src/domain/approvals/models.rs:634`). 프런트 컨트롤러(`lib/features/approvals/presentation/controllers/approval_history_controller.dart`)는 서버 응답의 `approval.updated_at`을 저장해 재전송 시 파라미터로 포함해야 충돌 409를 피할 수 있다. +- 결재 상세 응답은 `current_step`, `steps[].status.is_blocking_next`, `histories[].action_code`를 포함하며 비허용 사용자에게는 `APPROVAL_ACCESS_DENIED`가 반환된다(`doc/stock_approval_system_api_v4.md:1011`, `backend/src/api/v1/approval_flow.rs:42`). DTO 파서(`lib/features/approvals/data/dtos/approval_dto.dart`)에서 새 서브 객체를 맵핑하고, 403 수신 시 접근 제한 안내를 표준 토스트로 노출한다. +- 프런트 데이터 계층에 `ApprovalSubmissionInput`/`ApprovalDecisionInput`/`ApprovalRecallInput`/`ApprovalResubmissionInput`을 추가하고, `ApprovalRepositoryRemote.submit|approve|reject|recall|resubmit|listHistory` 메서드를 신규 엔드포인트(`/approval/submit`, `/approval/approve`, `/approval/reject`, `/approval/recall`, `/approval/resubmit`, `/approval/history`)에 맞춰 구현했다. (`lib/features/approvals/domain/entities/approval.dart`, `lib/features/approvals/data/repositories/approval_repository_remote.dart`, 2025-10-29) — 기존 `create/update/assignSteps` 경로는 레거시 호환을 위해 유지하되, F2 단계에서 컨트롤러/유즈케이스를 새 흐름으로 전환할 예정이다. ## 보고서 (PDF) - 백엔드가 PDF를 스트리밍으로 내려주고 파일명·Content-Length·ETag를 헤더에 포함한다(`backend/src/api/v1/reports.rs:94`). 프런트 `ReportingRepositoryRemote`는 `StreamedResponse` 처리를 유지하되, 새 메타데이터(`report_name`, `generated_at`)로 다운로드 UI를 업데이트한다(`lib/features/reporting/presentation/controllers/reporting_controller.dart`). @@ -54,14 +66,17 @@ | DB | `006_add_expected_return_date_to_stock_transactions.sql` 적용 확인 | 백엔드 | 진행 예정 | 스테이징 DB 스키마 점검 후 공유 | | 결재 | 단계 검색(`q`, `status_id`)·거래번호 노출 통합 테스트 | 프런트/백엔드 | 준비 | 계약 데이터 샘플 확보 필요 | | 결재 | `histories.action` 레거시 데이터 폴백 처리 협의 | 프런트/백엔드 | 준비 | 데이터 정제 vs UI 폴백 선택 | +| 결재 | 열람 권한/대기 전표 노출 제한 구현 (`draft`,`submitted` 접근 제어) | 프런트/백엔드 | 준비 | API 403/필터 명세 동기화 | | 보고서 | Approvals/Transactions PDF 스트리밍 합동 점검 | 프런트/백엔드 | 준비 | 대용량 파일·감사 로그 확인 | | 보고서 | 감사 로그 정책 준수 여부 재확인 | 백엔드 | 준비 | 정책 준수 결과 문서화 | +| 결재 | Approval Flow 작업 계획 상호 공유(`doc/approval_flow_frontend_task_plan.md`) | 프런트/백엔드 | 완료 | 백엔드 문서(`../superport_api_v2/doc/approval_flow_backend_task_plan.md`)와 동기화 | | QA | `flutter analyze`, `flutter test --coverage` 회귀 실행 후 공유 | 프런트 | 준비 | DTO/테스트 수정 후 `notify.py` 발송 | | QA | `cargo test` + 통합 시나리오 스크립트 재실행 | 백엔드 | 준비 | 보고서/결재 단계 회귀 포함 | ## 테스트 & 다음 단계 +- Approval Flow 개선 과제는 `doc/approval_flow_frontend_task_plan.md`를 기준으로 우선순위를 재정정하고 백엔드 진행 상황과 주별 체크인을 맞춘다. - 백엔드 `cargo test` 통과 보고가 공유됐지만, 프런트 QA 관점에서는 다음을 진행한다. - 새 마이그레이션(`006_add_expected_return_date_to_stock_transactions.sql`) 적용 → 스테이징 DB 반영 상태 확인. - - 결재 단계 검색(`q`, `status_id`), 거래번호 노출, 보고서 PDF 다운로드를 프런트/백엔드 합동 점검. + - 결재 단계 검색(`q`, `status_id`), 거래번호 노출, 결재 열람 권한 제한, 보고서 PDF 다운로드를 프런트/백엔드 합동 점검. - `flutter analyze`, `flutter test --coverage`로 DTO·테스트 변경 이후 회귀 여부 확인. - 모든 작업을 마치면 `notify.py` 워크플로를 통해 완료 알림을 발송한다. diff --git a/doc/stock_approval_system_api_v4.md b/doc/stock_approval_system_api_v4.md index 33f6b4e..e692d64 100644 --- a/doc/stock_approval_system_api_v4.md +++ b/doc/stock_approval_system_api_v4.md @@ -34,6 +34,7 @@ - `404 NOT_FOUND` — 리소스 없음 또는 삭제됨. - `409 CONFLICT` — 유니크 제약, 결재 단계 상태 충돌. - `422 UNPROCESSABLE_ENTITY` — 비즈니스 규칙 위반(출고 고객 누락, blocking 상태 전이 등). + - `403 FORBIDDEN` — 권한 부족. 결재 열람 제한 시 `APPROVAL_ACCESS_DENIED` 코드를 사용한다. - 에러 응답 예: `{ "error": { "code": 422, "message": "출고 트랜잭션에는 고객이 최소 1건 필요합니다.", "details": [...] } }`. - **CORS 정책:** 서버는 `config/default.toml`의 `[cors]` 설정을 사용해 허용 오리진을 제어한다. `allowed_origins`가 비어 있으면 모든 오리진을 허용하고, 값에 `http://localhost` 또는 `https://web.example.com:*`처럼 포트 와일드카드(`:*`)를 포함하면 동적 포트 환경에서도 `Access-Control-Allow-Origin`이 요청 오리진과 동일하게 반환된다. 허용 오리진에 일치하지 않으면 `400 BAD_REQUEST`가 응답된다. @@ -549,10 +550,14 @@ `APP-YYYYMMDDNNNN` 패턴으로 생성한 값을 응답에서 확인한다. `approval` 블록은 결재 생성에 필요한 정보를 담으며 생략할 수 없다. +> 기본 목록(`status` 미지정, `include_pending` 미사용)은 최종 승인 완료된 전표만 노출한다. 초안·상신 단계 전표는 `status=draft,submitted` 또는 `include_pending=true`로 별도 조회하거나 Approval Flow 화면에서 확인한다. + ### 4.2 목록 조회 `GET /stock-transactions?customer_id=301&include=lines,customers,approval` - `customer_id` (optional, number): 지정한 고객이 연결된 트랜잭션만 반환한다. 다른 검색 파라미터와 조합 가능하며, `include=customers` 사용 시 선택 고객 정보가 응답에 유지된다. +- `status` (optional, string): `draft`, `submitted`, `approved`, `completed`, `rejected`, `recalled` 값을 콤마로 조합한다. 기본값은 `approved,completed`이며, 초안/상신 건을 조회하려면 `status=draft,submitted`을 명시해야 한다. +- `include_pending=true` (optional, boolean): `true`로 설정 시 기본 필터를 무시하고 모든 상태(초안·상신 포함)를 반환한다. 기본 응답은 최종 승인(`approved`,`completed`) 건만 포함한다. ```json { "items": [ @@ -1004,6 +1009,9 @@ ### 5.2 목록 조회 `GET /approvals?include=steps,histories` + +- `include` (optional, string): `steps`, `histories`, `transaction`, `requested_by`를 콤마로 조합한다. +- **열람 권한:** 상신자 또는 이미 결재를 완료한 승인자만 목록을 조회할 수 있다. 향후 단계 승인자 및 관계없는 사용자가 호출하면 `403`과 `APPROVAL_ACCESS_DENIED` 코드를 반환하며, 응답 본문에는 `{ "error": { "code": 403, "message": "approval access denied" } }` 형식을 사용한다. ```json { "items": [ @@ -1104,6 +1112,7 @@ ### 5.3 단건 조회 `GET /approvals/5001?include=steps,histories` +- 상신자, 이미 결재를 수행한 승인자, 시스템 감사 권한(`approval.view_all`)을 가진 사용자만 접근 가능하다. 향후 단계 승인자는 `403` (`APPROVAL_ACCESS_DENIED`) 응답을 받는다. ```json { "data": { @@ -1360,6 +1369,7 @@ } } ``` +- `approval.transaction.updated_at` 필드는 전표(StockTransaction)의 최신 수정 시각(UTC)을 나타내며 회수·재상신 시 `transaction_expected_updated_at`로 전달해야 한다. ### 5.6 단계 행위 `POST /approval-steps/7001/actions` @@ -1607,10 +1617,11 @@ 주요 필터 및 확장 파라미터: -- `approval_id`, `approval_step_id`, `approver_id`, `approval_action_id`, `status_id` -- `q`(결재번호·승인자 검색), `action_from`, `action_to` (ISO8601) +- `approval_id`, `approval_step_id`, `approver_id`, `approval_action_id`(정수 ID), `status_id` +- `q`(결재번호·승인자 검색), `action_from`, `action_to` (ISO8601 UTC) - `sort=action_at|created_at|updated_at`, `order=asc|desc` - `include` 기본값은 `approver,approval_action,from_status,to_status`; `approval`, `step`, `status` 토큰으로 확장 +- 응답은 `action` 오브젝트에 `name`/`code`를, 루트 레벨에 `action_code`를 포함하여 감사 행위 식별자를 일관되게 노출한다. `GET /approval-histories/91001?include=approval,step` ```json @@ -1621,9 +1632,11 @@ "approval_step_id": 7001, "action": { "id": 3, - "name": "보류" + "name": "보류", + "code": "comment" }, "action_at": "2025-09-18T08:05:00Z", + "action_code": "comment", "note": "보류 코멘트", "approver": { "id": 21, diff --git a/doc/stock_approval_system_spec_v4.md b/doc/stock_approval_system_spec_v4.md index 5638844..8c7215e 100644 --- a/doc/stock_approval_system_spec_v4.md +++ b/doc/stock_approval_system_spec_v4.md @@ -4,6 +4,7 @@ **버전:** 2025-09-18 16:22:30Z (UTC) **요약:** 벤더 ↔ 창고 ↔ 고객사 간 물품 이동(입고/출고)을 관리하는 최소구성 시스템. - 트랜잭션당 1개의 결재(1:1), **승인자 순서 기반의 순차 결재** 지원. +- 상신자는 결재 초안을 임시저장할 수 있으며, 브라우저를 닫아도 `결재 관리` 목록에서 다시 불러와 편집/상신할 수 있다. - **다음 승인자로 넘어가면 안 되는 상태**를 `approval_statuses.is_blocking_next`로 제어. - 모든 테이블(타입/코드 테이블 포함)에 **공통 컬럼** 적용: `is_active`, `is_deleted`, `created_at`, `updated_at`. - **벤더는 트랜잭션 헤더에 연결하지 않음**(벤더는 제품을 통해서만 추적). @@ -21,6 +22,8 @@ - 결재 목록 응답은 각 항목의 `id`(= `approvals.id`)를 항상 노출하여 상세 조회 트리거로 사용한다. - 각 단계 상태가 **blocking**이면 다음 단계로 이동 불가. - 트랜잭션에는 **여러 고객사**를 연결할 수 있음(역할 없음). +- 입고/출고/대여 트랜잭션은 최종 결재자가 승인 완료하기 전까지 기본 목록/완료 카드에 노출되지 않으며, 대기/임시 영역에서만 조회된다. +- 결재 문서는 상신자와 이미 결재를 완료한 승인자만 열람할 수 있고, 향후 단계 승인자는 자신의 순서가 도달하기 전까지 목록/상세 접근이 차단된다. (예: 상신→중간 승인 완료, 최종 승인 대기 시 상신자·중간 승인자만 열람 가능) - 모든 로그인 사용자는 **users** 테이블 행과 매핑되며(`users.group_id`), 그룹-메뉴 권한(`group_menu_permissions`)으로 메뉴별 CRUD 가능 여부가 결정됨. - `users.employee_id`는 영문/숫자 4~32자 정규식(`^[A-Za-z0-9]{4,32}$`)을 따라야 하며, 대소문자 구분 없이 유니크하다. - 신규 사용자는 생성 직후 `force_password_change=true`로 저장하고, 최초 로그인에서 비밀번호를 변경하지 않으면 다른 기능을 사용할 수 없다. @@ -408,6 +411,7 @@ zipcodes ||--o{ customers : addressed > 번호 발급: 서버가 `TRX-YYYYMMDDNNNN` 형식으로 `transaction_no`를 생성하며 클라이언트 입력을 허용하지 않는다. > 목록 조회는 `customer_id` 쿼리 파라미터를 지원해 특정 고객이 연결된 트랜잭션만 필터링할 수 있다. (2024-10 갱신) > 작성자(`created_by_id`)는 로그인 세션의 사용자 ID를 사용하며, API 요청 본문에 전달된 다른 값은 무시하거나 검증 오류로 처리한다. +> 결재 최종 승인 완료 전에는 `transaction_statuses`가 `초안` 또는 `상신` 단계로 유지되며, 기본 입·출·대여 목록과 완료 카드에서는 제외한다. 대기/임시 전용 목록(필터 `status=draft|submitted`)에서만 확인 가능하다. --- @@ -519,6 +523,8 @@ zipcodes ||--o{ customers : addressed | updated_at | 변경일시 | timestamp | - | now() | Y | | | | > 번호 발급: 서버가 `APP-YYYYMMDDNNNN` 형식으로 `approval_no`를 생성하며 클라이언트 입력을 허용하지 않는다. +> 상신자는 결재 초안을 저장하고 추후 재개할 수 있으며, 초안은 `결재 관리` 목록의 "임시저장" 필터로 조회한다. +> 열람 권한은 상신자와 이미 결재를 수행한 승인자에게만 부여되며, 도달하지 않은 단계의 승인자는 목록/상세/이력 API에서 403 또는 비노출로 처리한다. (예: 상신→중간 승인 완료, 최종 승인 대기 시 상신자·중간 승인자만 열람 가능) --- diff --git a/flutter_test_config.dart b/flutter_test_config.dart new file mode 100644 index 0000000..a259bc5 --- /dev/null +++ b/flutter_test_config.dart @@ -0,0 +1,146 @@ +import 'dart:async'; +import 'dart:io'; + +/// 스테이징 결재 더블(approval double) 연결 시 인증서 우회를 위한 HttpOverrides. +class _ApprovalStagingHttpOverrides extends HttpOverrides { + _ApprovalStagingHttpOverrides(this._patterns); + + final List<_HostPattern> _patterns; + + bool _isAllowedHost(String host) { + final normalized = host.trim().toLowerCase(); + if (normalized.isEmpty) { + return false; + } + for (final pattern in _patterns) { + if (pattern.matches(normalized)) { + return true; + } + } + return false; + } + + @override + HttpClient createHttpClient(SecurityContext? context) { + final client = super.createHttpClient(context); + client.badCertificateCallback = (cert, host, port) { + // 허용된 호스트에 대해서만 자기서명 인증서를 허용한다. + return _patterns.isNotEmpty && _isAllowedHost(host); + }; + return client; + } +} + +/// 호스트 패턴(정확 일치 또는 서픽스 매칭)을 표현한다. +class _HostPattern { + _HostPattern._(this.value, this.isSuffix); + + factory _HostPattern.parse(String raw) { + final trimmed = raw.trim().toLowerCase(); + if (trimmed.startsWith('*.')) { + return _HostPattern._(trimmed.substring(2), true); + } + if (trimmed.startsWith('.')) { + return _HostPattern._(trimmed.substring(1), true); + } + return _HostPattern._(trimmed, false); + } + + final String value; + final bool isSuffix; + + bool get isValid => value.isNotEmpty; + + bool matches(String host) { + if (!isValid) { + return false; + } + if (isSuffix) { + return host == value || host.endsWith('.$value'); + } + return host == value; + } +} + +bool _parseBoolDynamic(String? raw) { + if (raw == null) { + return false; + } + switch (raw.trim().toLowerCase()) { + case '1': + case 'y': + case 'yes': + case 'true': + case 'on': + return true; + default: + return false; + } +} + +List<_HostPattern> _collectAllowedPatterns() { + final env = Platform.environment; + final tokens = []; + + void addTokens(String? source) { + if (source == null || source.trim().isEmpty) { + return; + } + tokens.addAll(source.split(',')); + } + + addTokens(env['APPROVAL_DOUBLE_ALLOWED_HOSTS']); + addTokens( + const String.fromEnvironment( + 'APPROVAL_DOUBLE_ALLOWED_HOSTS', + defaultValue: '', + ), + ); + + final baseUrl = + env['API_BASE_URL'] ?? + const String.fromEnvironment('API_BASE_URL', defaultValue: ''); + final uri = Uri.tryParse(baseUrl); + if (uri != null && uri.host.isNotEmpty) { + tokens.add(uri.host); + } + + final patterns = <_HostPattern>[]; + for (final token in tokens) { + final pattern = _HostPattern.parse(token); + if (pattern.isValid) { + patterns.add(pattern); + } + } + return patterns; +} + +/// Flutter 테스트 진입점. +/// +/// - `USE_APPROVAL_STAGING_DOUBLE` 토글이 true일 때 HttpOverrides를 등록한다. +/// - `APPROVAL_DOUBLE_ALLOWED_HOSTS` 또는 `API_BASE_URL` 호스트를 인증 허용 목록에 추가한다. +Future testExecutable(FutureOr Function() testMain) async { + final runOverride = + _parseBoolDynamic(Platform.environment['USE_APPROVAL_STAGING_DOUBLE']) || + const bool.fromEnvironment( + 'USE_APPROVAL_STAGING_DOUBLE', + defaultValue: false, + ); + + HttpOverrides? previous; + if (runOverride) { + final patterns = _collectAllowedPatterns(); + if (patterns.isNotEmpty) { + previous = HttpOverrides.current; + HttpOverrides.global = _ApprovalStagingHttpOverrides(patterns); + } + } + + try { + await testMain(); + } finally { + if (runOverride) { + HttpOverrides.global = previous; + } + } +} diff --git a/integration_test/approvals_flow_test.dart b/integration_test/approvals_flow_test.dart new file mode 100644 index 0000000..bca456d --- /dev/null +++ b/integration_test/approvals_flow_test.dart @@ -0,0 +1,561 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/features/approvals/domain/entities/approval.dart'; +import 'package:superport_v2/features/approvals/domain/entities/approval_proceed_status.dart'; +import 'package:superport_v2/features/approvals/domain/entities/approval_template.dart'; +import 'package:superport_v2/features/approvals/domain/repositories/approval_repository.dart'; +import 'package:superport_v2/features/approvals/domain/repositories/approval_template_repository.dart'; +import 'package:superport_v2/features/approvals/domain/usecases/approve_approval_use_case.dart'; +import 'package:superport_v2/features/approvals/presentation/controllers/approval_controller.dart'; +import 'package:superport_v2/features/inventory/transactions/domain/entities/stock_transaction.dart'; +import 'package:superport_v2/features/inventory/transactions/domain/entities/stock_transaction_input.dart'; +import 'package:superport_v2/features/inventory/transactions/domain/repositories/stock_transaction_repository.dart'; + +void main() { + const runFlow = bool.fromEnvironment('STAGING_RUN_APPROVAL_FLOW'); + const useFakeFlow = bool.fromEnvironment('STAGING_USE_FAKE_APPROVAL_FLOW'); + + if (!runFlow) { + testWidgets( + 'approval flow e2e (환경 변수 설정 필요: STAGING_RUN_APPROVAL_FLOW=true)', + (tester) async { + tester.printToConsole( + '통합 테스트를 실행하려면 STAGING_RUN_APPROVAL_FLOW=true 를 설정하세요.', + ); + }, + skip: true, + ); + return; + } + + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + final missingConfigs = []; + if (!useFakeFlow) { + missingConfigs.add('STAGING_USE_FAKE_APPROVAL_FLOW=true'); + } + + if (missingConfigs.isNotEmpty) { + testWidgets( + 'approval flow e2e (환경 변수 설정 필요: ${missingConfigs.join(', ')})', + (tester) async { + tester.printToConsole( + '결재 통합 테스트를 실행하려면 다음 변수를 설정하세요: ${missingConfigs.join(', ')}', + ); + }, + skip: true, + ); + return; + } + + testWidgets('inventory inbound → approval submit → approver hand-off', ( + tester, + ) async { + const transactionTypeId = 501; + const transactionStatusId = 10; + const warehouseId = 30; + const requesterId = 91; + const firstApproverId = 201; + const secondApproverId = 202; + + final stockRepository = _FakeStockTransactionRepository( + transactionTypeId: transactionTypeId, + initialStatusId: transactionStatusId, + warehouseId: warehouseId, + employeeId: requesterId, + ); + + final approvalRepository = _FakeApprovalRepository(); + final templateRepository = _FakeApprovalTemplateRepository(); + final controller = ApprovalController( + approvalRepository: approvalRepository, + templateRepository: templateRepository, + ); + final approveUseCase = ApproveApprovalUseCase( + repository: approvalRepository, + ); + + final now = DateTime.now(); + final transactionInput = StockTransactionCreateInput( + transactionTypeId: transactionTypeId, + transactionStatusId: transactionStatusId, + warehouseId: warehouseId, + transactionDate: now, + createdById: requesterId, + note: 'integration-test ${now.toIso8601String()}', + lines: [ + TransactionLineCreateInput( + lineNo: 1, + productId: 7001, + quantity: 5, + unitPrice: 1200, + ), + ], + customers: [TransactionCustomerCreateInput(customerId: 4001)], + approval: StockTransactionApprovalInput( + requestedById: requesterId, + steps: [ + ApprovalStepAssignmentItem(stepOrder: 1, approverId: firstApproverId), + ApprovalStepAssignmentItem( + stepOrder: 2, + approverId: secondApproverId, + ), + ], + note: '입고 결재 테스트', + ), + ); + + final createdTransaction = await stockRepository.create(transactionInput); + expect(createdTransaction.id, isNotNull); + tester.printToConsole('created transaction: ${createdTransaction.id}'); + + final approvalSubmission = ApprovalSubmissionInput( + transactionId: createdTransaction.id, + statusId: 1, + requesterId: requesterId, + note: transactionInput.approval.note, + steps: transactionInput.approval.steps, + ); + + final submittedApproval = await approvalRepository.submit( + approvalSubmission, + ); + expect(submittedApproval.id, isNotNull); + tester.printToConsole('submitted approval: ${submittedApproval.id}'); + + await controller.fetch(); + final approvals = controller.result?.items ?? const []; + expect(approvals, isNotEmpty); + expect(approvals.first.id, submittedApproval.id); + + await controller.selectApproval(submittedApproval.id!); + expect(controller.selected?.currentStep?.stepOrder, 1); + + final firstFlow = await approveUseCase( + ApprovalDecisionInput( + approvalId: submittedApproval.id!, + actorId: firstApproverId, + ), + ); + expect(firstFlow.currentStep?.stepOrder, 2); + tester.printToConsole('first approval completed, moved to step 2'); + + await controller.selectApproval(firstFlow.id!); + expect(controller.selected?.currentStep?.stepOrder, 2); + expect(controller.canProceedSelected, isTrue); + + final finalFlow = await approveUseCase( + ApprovalDecisionInput( + approvalId: firstFlow.id!, + actorId: secondApproverId, + ), + ); + expect(finalFlow.currentStep, isNull); + expect(finalFlow.status.isTerminal, isTrue); + tester.printToConsole('approval completed by final approver'); + + await controller.selectApproval(finalFlow.id!); + expect(controller.selected?.status.isTerminal, isTrue); + expect(controller.canProceedSelected, isFalse); + }); +} + +class _FakeStockTransactionRepository implements StockTransactionRepository { + _FakeStockTransactionRepository({ + required this.transactionTypeId, + required this.initialStatusId, + required this.warehouseId, + required this.employeeId, + }); + + final int transactionTypeId; + final int initialStatusId; + final int warehouseId; + final int employeeId; + + int _sequence = 1; + final Map _transactions = {}; + + @override + Future create(StockTransactionCreateInput input) async { + final id = _sequence++; + final transaction = StockTransaction( + id: id, + transactionNo: 'TRX-${id.toString().padLeft(6, '0')}', + transactionDate: input.transactionDate, + type: StockTransactionType(id: transactionTypeId, name: '입고'), + status: StockTransactionStatus(id: initialStatusId, name: '작성중'), + warehouse: StockTransactionWarehouse( + id: warehouseId, + code: 'WH-$warehouseId', + name: '테스트 창고', + ), + createdBy: StockTransactionEmployee( + id: employeeId, + employeeNo: 'EMP-$employeeId', + name: '작성자', + ), + note: input.note, + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + lines: input.lines + .map( + (line) => StockTransactionLine( + id: line.lineNo, + lineNo: line.lineNo, + product: StockTransactionProduct( + id: line.productId, + code: 'PRD-${line.productId}', + name: '테스트 품목', + ), + quantity: line.quantity, + unitPrice: line.unitPrice, + note: line.note, + ), + ) + .toList(growable: false), + customers: input.customers + .map( + (customer) => StockTransactionCustomer( + id: customer.customerId, + customer: StockTransactionCustomerSummary( + id: customer.customerId, + code: 'CUST-${customer.customerId}', + name: '거래처', + ), + note: customer.note, + ), + ) + .toList(growable: false), + ); + _transactions[id] = transaction; + return transaction; + } + + @override + Future submit(int id) async { + final transaction = _require(id); + final updated = transaction.copyWith( + status: StockTransactionStatus(id: transaction.status.id, name: '제출'), + updatedAt: DateTime.now(), + ); + _transactions[id] = updated; + return updated; + } + + StockTransaction _require(int id) { + final transaction = _transactions[id]; + if (transaction == null) { + throw StateError('트랜잭션($id)을 찾을 수 없습니다.'); + } + return transaction; + } + + @override + Future> list({ + StockTransactionListFilter? filter, + }) async { + return PaginatedResult( + items: _transactions.values.toList(growable: false), + page: 1, + pageSize: _transactions.length, + total: _transactions.length, + ); + } + + @override + Future fetchDetail(int id, {List? include}) async { + return _require(id); + } + + @override + Future update(int id, StockTransactionUpdateInput input) => + throw UnimplementedError(); + + @override + Future delete(int id) => throw UnimplementedError(); + + @override + Future restore(int id) => throw UnimplementedError(); + + @override + Future complete(int id) => throw UnimplementedError(); + + @override + Future approve(int id) => throw UnimplementedError(); + + @override + Future reject(int id) => throw UnimplementedError(); + + @override + Future cancel(int id) => throw UnimplementedError(); +} + +class _FakeApprovalRepository implements ApprovalRepository { + int _sequence = 1000; + final Map _approvals = {}; + + @override + Future> list({ + int page = 1, + int pageSize = 20, + int? transactionId, + int? approvalStatusId, + int? requestedById, + List? statusCodes, + bool includePending = false, + bool includeHistories = false, + bool includeSteps = false, + }) async { + final items = _approvals.values.toList(growable: false); + return PaginatedResult( + items: items, + page: 1, + pageSize: items.length, + total: items.length, + ); + } + + @override + Future fetchDetail( + int id, { + bool includeSteps = true, + bool includeHistories = true, + }) async { + final approval = _approvals[id]; + if (approval == null) { + throw StateError('결재($id)를 찾을 수 없습니다.'); + } + return approval; + } + + @override + Future submit(ApprovalSubmissionInput input) async { + final id = _sequence++; + final approvalNo = 'APP-${id.toString().padLeft(6, '0')}'; + final status = ApprovalStatus(id: input.statusId, name: '진행중'); + final requester = ApprovalRequester( + id: input.requesterId, + employeeNo: 'EMP-${input.requesterId}', + name: '상신자', + ); + final steps = input.steps + .map( + (step) => ApprovalStep( + id: step.stepOrder, + stepOrder: step.stepOrder, + approver: ApprovalApprover( + id: step.approverId, + employeeNo: 'EMP-${step.approverId}', + name: '승인자 ${step.approverId}', + ), + status: ApprovalStatus(id: 1, name: '대기'), + assignedAt: DateTime.now(), + note: step.note, + ), + ) + .toList(growable: false); + final approval = Approval( + id: id, + approvalNo: approvalNo, + transactionNo: input.transactionId != null + ? 'TRX-${input.transactionId}' + : 'TRX', + status: status, + requester: requester, + requestedAt: DateTime.now(), + note: input.note, + steps: steps, + histories: const [], + currentStep: steps.isEmpty ? null : steps.first, + ); + _approvals[id] = approval; + return approval; + } + + @override + Future approve(ApprovalDecisionInput input) async { + final approval = await fetchDetail(input.approvalId); + final current = approval.steps.firstWhere( + (step) => step.decidedAt == null, + orElse: () => throw StateError('모든 결재 단계가 이미 완료되었습니다.'), + ); + if (current.approver.id != input.actorId) { + throw StateError('현재 단계 승인자가 아닙니다.'); + } + final steps = approval.steps + .map((step) { + if (step.decidedAt != null) { + return step; + } + if (step.approver.id != input.actorId) { + return step; + } + return step.copyWith( + decidedAt: DateTime.now(), + status: ApprovalStatus(id: 2, name: '승인됨', isTerminal: false), + ); + }) + .toList(growable: false); + + final nextPending = steps.firstWhere( + (step) => step.decidedAt == null, + orElse: () => ApprovalStep( + id: -1, + stepOrder: -1, + approver: ApprovalApprover( + id: approval.requester.id, + employeeNo: approval.requester.employeeNo, + name: approval.requester.name, + ), + status: ApprovalStatus(id: 2, name: '완료', isTerminal: true), + assignedAt: DateTime.now(), + ), + ); + + ApprovalStatus nextStatus; + ApprovalStep? currentStep; + if (nextPending.stepOrder == -1) { + nextStatus = ApprovalStatus(id: 9, name: '승인 완료', isTerminal: true); + currentStep = null; + } else { + nextStatus = approval.status; + currentStep = steps.firstWhere((step) => step.decidedAt == null); + } + + final updated = approval.copyWith( + status: nextStatus, + steps: steps, + currentStep: currentStep, + updatedAt: DateTime.now(), + ); + _approvals[input.approvalId] = updated; + return updated; + } + + @override + Future canProceed(int id) async { + final approval = await fetchDetail(id); + final nextPending = approval.steps.firstWhere( + (step) => step.decidedAt == null, + orElse: () => ApprovalStep( + id: -1, + stepOrder: -1, + approver: ApprovalApprover( + id: approval.requester.id, + employeeNo: approval.requester.employeeNo, + name: approval.requester.name, + ), + status: approval.status, + assignedAt: DateTime.now(), + ), + ); + if (nextPending.stepOrder == -1) { + return ApprovalProceedStatus( + approvalId: id, + canProceed: false, + reason: '모든 단계가 완료되었습니다.', + ); + } + return ApprovalProceedStatus( + approvalId: id, + canProceed: true, + reason: null, + ); + } + + @override + Future> listActions({bool activeOnly = true}) async { + return [ + ApprovalAction(id: 1, name: '승인', code: 'approve'), + ApprovalAction(id: 2, name: '반려', code: 'reject'), + ]; + } + + @override + Future resubmit(ApprovalResubmissionInput input) => + throw UnimplementedError(); + + @override + Future reject(ApprovalDecisionInput input) => + throw UnimplementedError(); + + @override + Future recall(ApprovalRecallInput input) => + throw UnimplementedError(); + + @override + Future> listHistory({ + required int approvalId, + int page = 1, + int pageSize = 20, + DateTime? from, + DateTime? to, + int? actorId, + int? approvalActionId, + }) => throw UnimplementedError(); + + @override + Future performStepAction(ApprovalStepActionInput input) => + throw UnimplementedError(); + + @override + Future assignSteps(ApprovalStepAssignmentInput input) => + throw UnimplementedError(); + + @override + Future create(ApprovalCreateInput input) => + throw UnimplementedError(); + + @override + Future update(ApprovalUpdateInput input) => + throw UnimplementedError(); + + @override + Future delete(int id) => throw UnimplementedError(); + + @override + Future restore(int id) => throw UnimplementedError(); +} + +class _FakeApprovalTemplateRepository implements ApprovalTemplateRepository { + @override + Future> list({ + int page = 1, + int pageSize = 20, + String? query, + bool? isActive, + }) async { + return PaginatedResult( + items: const [], + page: 1, + pageSize: 0, + total: 0, + ); + } + + @override + Future fetchDetail(int id, {bool includeSteps = true}) => + throw UnimplementedError(); + + @override + Future create( + ApprovalTemplateInput input, { + List steps = const [], + }) => throw UnimplementedError(); + + @override + Future update( + int id, + ApprovalTemplateInput input, { + List? steps, + }) => throw UnimplementedError(); + + @override + Future delete(int id) => throw UnimplementedError(); + + @override + Future restore(int id) => throw UnimplementedError(); +} diff --git a/integration_test/stock_transaction_state_flow_test.dart b/integration_test/stock_transaction_state_flow_test.dart index ea18e1e..8a38ff4 100644 --- a/integration_test/stock_transaction_state_flow_test.dart +++ b/integration_test/stock_transaction_state_flow_test.dart @@ -139,6 +139,9 @@ void main() { customers: [ TransactionCustomerCreateInput(customerId: resolvedCustomerId), ], + approval: StockTransactionApprovalInput( + requestedById: resolvedEmployeeId, + ), ); final created = await repository.create(createInput); diff --git a/lib/core/config/environment.dart b/lib/core/config/environment.dart index 970860f..c07bb75 100644 --- a/lib/core/config/environment.dart +++ b/lib/core/config/environment.dart @@ -1,6 +1,8 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:superport_v2/core/config/feature_flags.dart'; + /// 환경 설정 로더 /// /// - .env.development / .env.production 파일을 로드하여 런타임 설정을 주입한다. @@ -57,6 +59,7 @@ class Environment { } baseUrl = dotenv.maybeGet('API_BASE_URL') ?? 'http://localhost:8080'; + FeatureFlags.initialize(); _loadPermissions(); } diff --git a/lib/core/config/feature_flags.dart b/lib/core/config/feature_flags.dart new file mode 100644 index 0000000..1fe12df --- /dev/null +++ b/lib/core/config/feature_flags.dart @@ -0,0 +1,75 @@ +import 'package:flutter_dotenv/flutter_dotenv.dart'; + +/// 기능 토글을 중앙에서 관리한다. +/// +/// - `Environment.initialize` 후 `FeatureFlags.initialize()`가 호출되어야 한다. +/// - 백엔드의 `feature.*` 키와 기존 `FEATURE_*` 키를 모두 지원한다. +class FeatureFlags { + FeatureFlags._(); + + static final Map _values = {}; + + /// 승인 화면 노출 여부. + static bool get approvalsEnabled => _values['approvals_enabled'] ?? false; + + /// 재고 전표 화면 노출 여부. + static bool get stockTransitionsEnabled => + _values['stock_transitions_enabled'] ?? false; + + /// Approval Flow v2 기능 토글. + static bool get approvalFlowV2 => _values['approval_flow_v2'] ?? false; + + /// 기능 토글을 환경 변수에서 읽어 초기화한다. + static void initialize() { + _values + ..clear() + ..addAll({ + 'approvals_enabled': _readFlag( + 'FEATURE_APPROVALS_ENABLED', + defaultValue: false, + ), + 'stock_transitions_enabled': _readFlag( + 'FEATURE_STOCK_TRANSITIONS_ENABLED', + defaultValue: true, + ), + 'approval_flow_v2': _readFlag( + 'FEATURE_APPROVAL_FLOW_V2', + aliases: const ['feature.approval_flow_v2'], + defaultValue: false, + ), + }); + } + + /// 논리 키 기반으로 토글 값을 조회한다. + static bool isEnabled(String logicalKey) => + _values[logicalKey.toLowerCase()] ?? false; + + static bool _readFlag( + String key, { + bool defaultValue = false, + List aliases = const [], + }) { + for (final candidate in {key, ...aliases}) { + final raw = dotenv.maybeGet(candidate); + if (raw == null) { + continue; + } + final normalized = raw.trim().toLowerCase(); + switch (normalized) { + case '1': + case 'y': + case 'yes': + case 'true': + return true; + case '0': + case 'n': + case 'no': + case 'false': + return false; + default: + continue; + } + } + return defaultValue; + } +} diff --git a/lib/core/network/api_client.dart b/lib/core/network/api_client.dart index 1fa7d6d..57ab341 100644 --- a/lib/core/network/api_client.dart +++ b/lib/core/network/api_client.dart @@ -1,3 +1,5 @@ +import 'dart:collection'; + import 'package:dio/dio.dart'; import 'api_error.dart'; @@ -14,6 +16,116 @@ class ApiClient { final ApiErrorMapper _errorMapper; static const _dataKey = 'data'; + /// 경로 세그먼트를 조합해 일관된 요청 경로를 생성한다. + /// + /// - 각 세그먼트의 선행/후행 `/`를 제거한 뒤 단일 슬래시로 결합한다. + /// - 첫 번째 세그먼트가 `/`로 시작하면 결과 역시 `/`를 유지한다. + static String buildPath( + Object base, [ + Iterable segments = const [], + ]) { + final joined = []; + var leadingSlash = false; + + void append(Object? value, {bool isFirst = false}) { + if (value == null) { + return; + } + var text = value.toString(); + if (text.isEmpty) { + return; + } + if (isFirst && text.startsWith('/')) { + leadingSlash = true; + } + text = text.replaceAll(RegExp(r'^/+'), '').replaceAll(RegExp(r'/+$'), ''); + if (text.isEmpty) { + return; + } + joined.add(text); + } + + append(base, isFirst: true); + for (final segment in segments) { + append(segment); + } + + if (joined.isEmpty) { + return leadingSlash ? '/' : ''; + } + final path = joined.join('/'); + return leadingSlash ? '/$path' : path; + } + + /// 페이지네이션/검색/필터 파라미터를 표준 규칙에 맞춰 구성한다. + /// + /// - 문자열은 trim 후 빈 값이면 제외한다. + /// - `include`는 중복 제거 후 콤마로 연결한다. + /// - `DateTime`은 UTC ISO8601 문자열로 직렬화한다. + /// - [filters]가 동일 키를 포함하면 앞선 설정을 덮어쓴다. + static Map buildQuery({ + int? page, + int? pageSize, + String? q, + String? sort, + String? order, + Iterable? include, + DateTime? updatedSince, + Map? filters, + }) { + final query = {}; + + void put(String key, dynamic value) { + if (value == null) { + return; + } + if (value is String) { + final trimmed = value.trim(); + if (trimmed.isEmpty) { + return; + } + query[key] = trimmed; + return; + } + if (value is DateTime) { + query[key] = value.toUtc().toIso8601String(); + return; + } + if (value is Iterable) { + final sanitized = []; + for (final element in value) { + if (element == null) { + continue; + } + final text = element.toString().trim(); + if (text.isEmpty) { + continue; + } + sanitized.add(text); + } + if (sanitized.isEmpty) { + return; + } + final unique = LinkedHashSet.from(sanitized); + query[key] = unique.join(','); + return; + } + query[key] = value; + } + + put('page', page); + put('page_size', pageSize); + put('q', q); + put('sort', sort); + put('order', order?.trim().toLowerCase()); + put('include', include); + put('updated_since', updatedSince); + + filters?.forEach(put); + + return Map.unmodifiable(query); + } + /// 내부에서 사용하는 Dio 인스턴스 /// 외부에서 Dio 직접 사용을 최소화하고, 가능하면 아래 헬퍼 메서드를 사용한다. Dio get dio => _dio; diff --git a/lib/core/network/api_routes.dart b/lib/core/network/api_routes.dart index 9779760..92b1f46 100644 --- a/lib/core/network/api_routes.dart +++ b/lib/core/network/api_routes.dart @@ -5,4 +5,21 @@ class ApiRoutes { /// API v1 prefix static const apiV1 = '/api/v1'; + + /// 결재(Approval) 관련 엔드포인트 + static const approvals = '$apiV1/approvals'; + static const approvalRoot = '$apiV1/approval'; + static const approvalSteps = '$apiV1/approval-steps'; + static const approvalHistory = '$apiV1/approval/history'; + static const approvalActions = '$apiV1/approval-actions'; + static const approvalDrafts = '$apiV1/approval-drafts'; + + /// 결재 행위 전용 경로(`/approval/{action}`)를 반환한다. + static String approvalAction(String action) { + final sanitized = action + .trim() + .replaceAll(RegExp(r'^/+'), '') + .replaceAll(RegExp(r'/+$'), ''); + return '$approvalRoot/$sanitized'; + } } diff --git a/lib/core/routing/app_router.dart b/lib/core/routing/app_router.dart index fdec361..d9d86b6 100644 --- a/lib/core/routing/app_router.dart +++ b/lib/core/routing/app_router.dart @@ -24,6 +24,8 @@ import '../../features/reporting/presentation/pages/reporting_page.dart'; import '../../features/util/postal_search/presentation/pages/postal_search_page.dart'; import '../../widgets/app_shell.dart'; import '../constants/app_sections.dart'; +import '../permissions/permission_manager.dart'; +import 'auth_guard.dart'; /// 전역 네비게이터 키(로그인/셸 라우터 공용). final _rootNavigatorKey = GlobalKey(debugLabel: 'root'); @@ -122,21 +124,41 @@ final appRouter = GoRouter( GoRoute( path: '/approvals/requests', name: 'approvals-requests', + redirect: AuthGuard.require( + resource: '/approvals/requests', + action: PermissionAction.view, + fallback: dashboardRoutePath, + ), builder: (context, state) => ApprovalRequestPage(routeUri: state.uri), ), GoRoute( path: '/approvals/steps', name: 'approvals-steps', + redirect: AuthGuard.require( + resource: '/approvals/steps', + action: PermissionAction.view, + fallback: dashboardRoutePath, + ), builder: (context, state) => const ApprovalStepPage(), ), GoRoute( path: '/approvals/history', name: 'approvals-history', + redirect: AuthGuard.require( + resource: '/approvals/history', + action: PermissionAction.view, + fallback: dashboardRoutePath, + ), builder: (context, state) => const ApprovalHistoryPage(), ), GoRoute( path: '/approvals/templates', name: 'approvals-templates', + redirect: AuthGuard.require( + resource: '/approvals/templates', + action: PermissionAction.view, + fallback: dashboardRoutePath, + ), builder: (context, state) => const ApprovalTemplatePage(), ), GoRoute( diff --git a/lib/core/routing/auth_guard.dart b/lib/core/routing/auth_guard.dart new file mode 100644 index 0000000..e7601f7 --- /dev/null +++ b/lib/core/routing/auth_guard.dart @@ -0,0 +1,39 @@ +import 'package:flutter/widgets.dart'; +import 'package:get_it/get_it.dart'; +import 'package:go_router/go_router.dart'; + +import '../permissions/permission_manager.dart'; + +typedef RouteGuard = String? Function(BuildContext, GoRouterState); + +/// 라우트 접근 시 권한을 검사하는 가드. +class AuthGuard { + const AuthGuard._(); + + /// [resource]와 [action]에 대한 접근 권한을 확인한다. + static bool can( + String resource, { + PermissionAction action = PermissionAction.view, + }) { + if (!GetIt.I.isRegistered()) { + return false; + } + return GetIt.I().can(resource, action); + } + + /// 권한이 없을 경우 [fallback] 경로로 리다이렉트하는 Guard를 생성한다. + static RouteGuard require({ + required String resource, + PermissionAction action = PermissionAction.view, + required String fallback, + }) { + return (context, state) { + if (!GetIt.I.isRegistered()) { + return null; + } + final manager = GetIt.I(); + final allowed = manager.can(resource, action); + return allowed ? null : fallback; + }; + } +} diff --git a/lib/core/services/token_storage_native.dart b/lib/core/services/token_storage_native.dart index 2899ffe..dbc3b22 100644 --- a/lib/core/services/token_storage_native.dart +++ b/lib/core/services/token_storage_native.dart @@ -4,6 +4,7 @@ import 'token_storage.dart'; /// 안전한 스토리지에 저장할 액세스 토큰 키. const _kAccessTokenKey = 'access_token'; + /// 안전한 스토리지에 저장할 리프레시 토큰 키. const _kRefreshTokenKey = 'refresh_token'; diff --git a/lib/core/services/token_storage_web.dart b/lib/core/services/token_storage_web.dart index fd599dc..d79e6fb 100644 --- a/lib/core/services/token_storage_web.dart +++ b/lib/core/services/token_storage_web.dart @@ -5,6 +5,7 @@ import 'token_storage.dart'; /// 웹 로컬스토리지에 저장할 액세스 토큰 키. const _kAccessTokenKey = 'access_token'; + /// 웹 로컬스토리지에 저장할 리프레시 토큰 키. const _kRefreshTokenKey = 'refresh_token'; diff --git a/lib/features/approvals/data/dtos/approval_audit_dto.dart b/lib/features/approvals/data/dtos/approval_audit_dto.dart new file mode 100644 index 0000000..ce3205a --- /dev/null +++ b/lib/features/approvals/data/dtos/approval_audit_dto.dart @@ -0,0 +1,156 @@ +import 'package:superport_v2/core/common/utils/json_utils.dart'; + +import '../../domain/entities/approval.dart'; +import 'approval_step_dto.dart'; + +/// 결재 감사 로그(Audit) DTO. +class ApprovalAuditDto { + ApprovalAuditDto({ + this.id, + required this.action, + this.fromStatus, + required this.toStatus, + required this.actor, + required this.actionAt, + this.note, + this.actionCode, + this.payload, + }); + + final int? id; + final ApprovalActionDto action; + final ApprovalStatusDto? fromStatus; + final ApprovalStatusDto toStatus; + final ApprovalApproverDto actor; + final DateTime actionAt; + final String? note; + final String? actionCode; + final Map? payload; + + factory ApprovalAuditDto.fromJson(Map json) { + final actionMap = { + ...?_asMap(json['action']), + ...?_asMap(json['approval_action']), + }; + final fallbackActionId = json['approval_action_id'] ?? json['action_id']; + if (fallbackActionId != null) { + actionMap.putIfAbsent('id', () => fallbackActionId); + } + final fallbackActionName = _firstNonEmpty([ + _readString(json, 'action_name'), + _readString(json, 'approval_action_name'), + ]); + if (fallbackActionName != null) { + actionMap.putIfAbsent('name', () => fallbackActionName); + } + final rootActionCode = _firstNonEmpty([ + _readString(json, 'action_code'), + _readString(json, 'approval_action_code'), + ]); + if (rootActionCode != null) { + actionMap.putIfAbsent('code', () => rootActionCode); + actionMap.putIfAbsent('action_code', () => rootActionCode); + } + final actionDto = ApprovalActionDto.fromJson(actionMap); + final fromStatusMap = _asMap(json['from_status']); + final toStatusMap = _asMap(json['to_status']) ?? const {}; + final actorMap = + _asMap(json['actor']) ?? + _asMap(json['approver']) ?? + const {}; + final resolvedActionCode = rootActionCode ?? actionDto.code; + + return ApprovalAuditDto( + id: JsonUtils.readInt(json, 'id'), + action: actionDto, + fromStatus: fromStatusMap == null + ? null + : ApprovalStatusDto.fromJson(fromStatusMap), + toStatus: ApprovalStatusDto.fromJson(toStatusMap), + actor: ApprovalApproverDto.fromJson(actorMap), + actionAt: _parseDate(json['action_at']) ?? DateTime.now(), + note: _readString(json, 'note'), + actionCode: resolvedActionCode, + payload: _asMap( + json['payload'], + )?.map((key, value) => MapEntry(key, value)), + ); + } + + ApprovalHistory toEntity() => ApprovalHistory( + id: id, + action: action.toEntity(), + fromStatus: fromStatus?.toEntity(), + toStatus: toStatus.toEntity(), + approver: actor.toEntity(), + actionAt: actionAt, + note: note, + actionCode: actionCode, + payload: payload, + ); +} + +/// 결재 감사 로그 액션 DTO. +class ApprovalActionDto { + ApprovalActionDto({required this.id, required this.name, this.code}); + + final int id; + final String name; + final String? code; + + factory ApprovalActionDto.fromJson(Map json) { + if (json['action'] is Map) { + return ApprovalActionDto.fromJson(json['action'] as Map); + } + final id = JsonUtils.readInt(json, 'id', fallback: 0); + final name = _firstNonEmpty([ + _readString(json, 'name'), + _readString(json, 'action_name'), + ]); + if (name == null) { + throw const FormatException('결재 감사 로그 액션 이름이 누락되었습니다.'); + } + final code = _firstNonEmpty([ + _readString(json, 'code'), + _readString(json, 'action_code'), + ]); + return ApprovalActionDto(id: id, name: name, code: code); + } + + ApprovalAction toEntity() => ApprovalAction(id: id, name: name, code: code); +} + +Map? _asMap(dynamic value) => + value is Map ? value : null; + +String? _readString( + Map? source, + String key, { + String? fallback, +}) { + if (source == null) return fallback; + final value = source[key]; + if (value is String) return value; + if (value == null) return fallback; + return value.toString(); +} + +DateTime? _parseDate(Object? value) { + if (value == null) return null; + if (value is DateTime) return value; + if (value is String) return DateTime.tryParse(value); + return null; +} + +String? _firstNonEmpty(Iterable values) { + for (final value in values) { + if (value == null) { + continue; + } + final trimmed = value.trim(); + if (trimmed.isNotEmpty) { + return trimmed; + } + } + return null; +} diff --git a/lib/features/approvals/data/dtos/approval_draft_dto.dart b/lib/features/approvals/data/dtos/approval_draft_dto.dart new file mode 100644 index 0000000..3bd63c0 --- /dev/null +++ b/lib/features/approvals/data/dtos/approval_draft_dto.dart @@ -0,0 +1,268 @@ +import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/core/common/utils/json_utils.dart'; + +import '../../domain/entities/approval_draft.dart'; + +/// 결재 초안 단계 DTO. +class ApprovalDraftStepDto { + ApprovalDraftStepDto({ + required this.stepOrder, + required this.approverId, + this.approverRole, + this.note, + this.isOptional = false, + }); + + final int stepOrder; + final int approverId; + final String? approverRole; + final String? note; + final bool isOptional; + + factory ApprovalDraftStepDto.fromJson(Map json) { + return ApprovalDraftStepDto( + stepOrder: JsonUtils.readInt(json, 'step_order', fallback: 0), + approverId: JsonUtils.readInt(json, 'approver_id', fallback: 0), + approverRole: _readString(json['approver_role']), + note: _readString(json['note']), + isOptional: json['is_optional'] is bool + ? json['is_optional'] as bool + : false, + ); + } + + ApprovalDraftStep toEntity() => ApprovalDraftStep( + stepOrder: stepOrder, + approverId: approverId, + approverRole: approverRole, + note: note, + isOptional: isOptional, + ); +} + +/// 결재 초안 페이로드 DTO. +class ApprovalDraftPayloadDto { + ApprovalDraftPayloadDto({ + this.title, + this.summary, + this.note, + this.templateId, + this.metadata, + this.steps = const [], + }); + + final String? title; + final String? summary; + final String? note; + final int? templateId; + final Map? metadata; + final List steps; + + factory ApprovalDraftPayloadDto.fromJson(Map json) { + final steps = (json['steps'] as List? ?? const []) + .whereType>() + .map(ApprovalDraftStepDto.fromJson) + .toList(growable: false); + final metadata = json['metadata'] is Map + ? Map.from(json['metadata'] as Map) + : null; + return ApprovalDraftPayloadDto( + title: _readString(json['title']), + summary: _readString(json['summary']), + note: _readString(json['note']), + templateId: json['template_id'] as int?, + metadata: metadata, + steps: steps, + ); + } + + ApprovalDraftPayload toEntity() => ApprovalDraftPayload( + title: title, + summary: summary, + note: note, + templateId: templateId, + metadata: metadata, + steps: steps.map((step) => step.toEntity()).toList(growable: false), + ); +} + +/// 결재 초안 요약 DTO. +class ApprovalDraftSummaryDto { + ApprovalDraftSummaryDto({ + required this.id, + required this.requesterId, + required this.status, + required this.savedAt, + this.requestId, + this.transactionId, + this.templateId, + this.title, + this.summary, + this.expiresAt, + this.sessionKey, + this.stepCount = 0, + }); + + final int id; + final int requesterId; + final ApprovalDraftStatus status; + final DateTime savedAt; + final int? requestId; + final int? transactionId; + final int? templateId; + final String? title; + final String? summary; + final DateTime? expiresAt; + final String? sessionKey; + final int stepCount; + + factory ApprovalDraftSummaryDto.fromJson(Map json) { + final savedAtRaw = json['saved_at']; + final expiresAtRaw = json['expires_at']; + return ApprovalDraftSummaryDto( + id: JsonUtils.readInt(json, 'id', fallback: 0), + requesterId: JsonUtils.readInt(json, 'requester_id', fallback: 0), + status: _parseStatus(_readString(json['status'])), + savedAt: _parseDate(savedAtRaw) ?? DateTime.now().toUtc(), + requestId: json['request_id'] as int?, + transactionId: json['transaction_id'] as int?, + templateId: json['template_id'] as int?, + title: _readString(json['title']), + summary: _readString(json['summary']), + expiresAt: _parseDate(expiresAtRaw), + sessionKey: _readString(json['session_key']), + stepCount: JsonUtils.readInt(json, 'step_count', fallback: 0), + ); + } + + ApprovalDraftSummary toEntity() => ApprovalDraftSummary( + id: id, + requesterId: requesterId, + status: status, + savedAt: savedAt, + requestId: requestId, + transactionId: transactionId, + templateId: templateId, + title: title, + summary: summary, + expiresAt: expiresAt, + sessionKey: sessionKey, + stepCount: stepCount, + ); +} + +/// 결재 초안 상세 DTO. +class ApprovalDraftDetailDto { + ApprovalDraftDetailDto({ + required this.id, + required this.requesterId, + required this.payload, + required this.savedAt, + this.transactionId, + this.templateId, + this.expiresAt, + this.sessionKey, + }); + + final int id; + final int requesterId; + final ApprovalDraftPayloadDto payload; + final DateTime savedAt; + final int? transactionId; + final int? templateId; + final DateTime? expiresAt; + final String? sessionKey; + + factory ApprovalDraftDetailDto.fromJson(Map json) { + final payloadMap = json['payload'] is Map + ? json['payload'] as Map + : const {}; + return ApprovalDraftDetailDto( + id: JsonUtils.readInt(json, 'id', fallback: 0), + requesterId: JsonUtils.readInt(json, 'requester_id', fallback: 0), + payload: ApprovalDraftPayloadDto.fromJson(payloadMap), + savedAt: _parseDate(json['saved_at']) ?? DateTime.now().toUtc(), + transactionId: json['transaction_id'] as int?, + templateId: json['template_id'] as int?, + expiresAt: _parseDate(json['expires_at']), + sessionKey: _readString(json['session_key']), + ); + } + + ApprovalDraftDetail toEntity() => ApprovalDraftDetail( + id: id, + requesterId: requesterId, + payload: payload.toEntity(), + savedAt: savedAt, + transactionId: transactionId, + templateId: templateId, + expiresAt: expiresAt, + sessionKey: sessionKey, + ); +} + +class ApprovalDraftDto { + ApprovalDraftDto._(); + + static PaginatedResult parsePaginated( + Map? json, + ) { + final items = JsonUtils.extractList(json) + .map(ApprovalDraftSummaryDto.fromJson) + .map((dto) => dto.toEntity()) + .toList(growable: false); + return PaginatedResult( + items: items, + page: JsonUtils.readInt(json, 'page', fallback: 1), + pageSize: JsonUtils.readInt(json, 'page_size', fallback: items.length), + total: JsonUtils.readInt(json, 'total', fallback: items.length), + ); + } + + static ApprovalDraftDetail? parseDetail(Map? json) { + if (json == null || json.isEmpty) { + return null; + } + final map = JsonUtils.extractMap(json); + if (map.isEmpty) { + return null; + } + return ApprovalDraftDetailDto.fromJson(map).toEntity(); + } +} + +String? _readString(dynamic value) { + if (value == null) { + return null; + } + if (value is String) { + final trimmed = value.trim(); + return trimmed.isEmpty ? null : trimmed; + } + return value.toString(); +} + +DateTime? _parseDate(dynamic value) { + if (value == null) { + return null; + } + if (value is DateTime) { + return value.toUtc(); + } + if (value is String && value.isNotEmpty) { + return DateTime.tryParse(value)?.toUtc(); + } + return null; +} + +ApprovalDraftStatus _parseStatus(String? value) { + switch (value) { + case 'expired': + return ApprovalDraftStatus.expired; + case 'archived': + return ApprovalDraftStatus.archived; + case 'active': + default: + return ApprovalDraftStatus.active; + } +} diff --git a/lib/features/approvals/data/dtos/approval_dto.dart b/lib/features/approvals/data/dtos/approval_dto.dart index c906ecd..d7ac346 100644 --- a/lib/features/approvals/data/dtos/approval_dto.dart +++ b/lib/features/approvals/data/dtos/approval_dto.dart @@ -2,6 +2,8 @@ import 'package:superport_v2/core/common/models/paginated_result.dart'; import 'package:superport_v2/core/common/utils/json_utils.dart'; import '../../domain/entities/approval.dart'; +import 'approval_audit_dto.dart'; +import 'approval_step_dto.dart'; /// 결재 API 응답을 표현하는 DTO. /// @@ -11,7 +13,9 @@ class ApprovalDto { ApprovalDto({ this.id, required this.approvalNo, + this.transactionId, this.transactionNo, + this.transactionUpdatedAt, required this.status, this.currentStep, required this.requester, @@ -28,7 +32,9 @@ class ApprovalDto { final int? id; final String approvalNo; + final int? transactionId; final String? transactionNo; + final DateTime? transactionUpdatedAt; final ApprovalStatusDto status; final ApprovalStepDto? currentStep; final ApprovalRequesterDto requester; @@ -38,7 +44,7 @@ class ApprovalDto { final bool isActive; final bool isDeleted; final List steps; - final List histories; + final List histories; final DateTime? createdAt; final DateTime? updatedAt; @@ -51,7 +57,7 @@ class ApprovalDto { approvalEnvelope['status'], approvalEnvelope['approval_status'], ]); - final requesterMap = _firstNonEmptyMap([ + final rawRequesterMap = _firstNonEmptyMap([ json['requester'], json['requested_by'], approvalEnvelope['requester'], @@ -86,14 +92,29 @@ class ApprovalDto { [json, transactionMap, approvalEnvelope, envelopeTransactionMap], const ['transaction_no', 'transactionNo'], ); + final transactionId = + json['transaction_id'] as int? ?? + approvalEnvelope['transaction_id'] as int? ?? + transactionMap['id'] as int? ?? + envelopeTransactionMap['id'] as int?; + final transactionUpdatedAt = _parseDate( + transactionMap['updated_at'] ?? + envelopeTransactionMap['updated_at'] ?? + json['transaction_updated_at'] ?? + approvalEnvelope['transaction_updated_at'], + ); return ApprovalDto( id: json['id'] as int? ?? approvalEnvelope['id'] as int?, approvalNo: approvalNo, + transactionId: transactionId, transactionNo: transactionNo, + transactionUpdatedAt: transactionUpdatedAt, status: ApprovalStatusDto.fromJson(statusMap), currentStep: currentStepDto, - requester: ApprovalRequesterDto.fromJson(requesterMap), + requester: ApprovalRequesterDto.fromJson( + _resolveRequesterMap(json, approvalEnvelope, rawRequesterMap), + ), requestedAt: _parseDate( json['requested_at'] ?? approvalEnvelope['requested_at'], @@ -113,7 +134,7 @@ class ApprovalDto { false, steps: stepsSource.map(ApprovalStepDto.fromJson).toList(growable: false), histories: historiesSource - .map(ApprovalHistoryDto.fromJson) + .map(ApprovalAuditDto.fromJson) .toList(growable: false), createdAt: _parseDate( json['created_at'] ?? approvalEnvelope['created_at'], @@ -128,7 +149,9 @@ class ApprovalDto { Approval toEntity() => Approval( id: id, approvalNo: approvalNo, + transactionId: transactionId, transactionNo: transactionNo ?? '-', + transactionUpdatedAt: transactionUpdatedAt, status: status.toEntity(), currentStep: currentStep?.toEntity(), requester: requester.toEntity(), @@ -159,38 +182,6 @@ class ApprovalDto { } } -/// 결재 상태(Status) DTO. -class ApprovalStatusDto { - ApprovalStatusDto({required this.id, required this.name, this.color}); - - final int id; - final String name; - final String? color; - - factory ApprovalStatusDto.fromJson(Map json) { - if (json['status'] is Map) { - return ApprovalStatusDto.fromJson(json['status'] as Map); - } - return ApprovalStatusDto( - id: - json['id'] as int? ?? - json['status_id'] as int? ?? - json['approval_status_id'] as int? ?? - 0, - name: - _readString(json['name']) ?? - _readString(json['status_name']) ?? - _readString(json['approval_status_name']) ?? - _readString(json['status']) ?? - '-', - color: _readString(json['color']), - ); - } - - /// DTO를 [ApprovalStatus]로 변환한다. - ApprovalStatus toEntity() => ApprovalStatus(id: id, name: name, color: color); -} - /// 결재 요청자 DTO. class ApprovalRequesterDto { ApprovalRequesterDto({ @@ -205,8 +196,11 @@ class ApprovalRequesterDto { factory ApprovalRequesterDto.fromJson(Map json) { return ApprovalRequesterDto( - id: json['id'] as int? ?? json['employee_id'] as int? ?? 0, - employeeNo: _readString(json['employee_no']) ?? '-', + id: JsonUtils.readInt(json, 'id', fallback: 0), + employeeNo: + _readString(json['employee_no']) ?? + _readString(json['employee_id']) ?? + '-', name: _readString(json['name']) ?? _readString(json['employee_name']) ?? @@ -219,194 +213,6 @@ class ApprovalRequesterDto { ApprovalRequester(id: id, employeeNo: employeeNo, name: name); } -/// 결재 승인자 DTO. -class ApprovalApproverDto { - ApprovalApproverDto({ - required this.id, - required this.employeeNo, - required this.name, - }); - - final int id; - final String employeeNo; - final String name; - - factory ApprovalApproverDto.fromJson(Map json) { - return ApprovalApproverDto( - id: json['id'] as int? ?? json['approver_id'] as int? ?? 0, - employeeNo: _readString(json['employee_no']) ?? '-', - name: - _readString(json['name']) ?? - _readString(json['employee_name']) ?? - '-', - ); - } - - /// DTO를 [ApprovalApprover]로 변환한다. - ApprovalApprover toEntity() => - ApprovalApprover(id: id, employeeNo: employeeNo, name: name); -} - -/// 결재 단계 DTO. -class ApprovalStepDto { - ApprovalStepDto({ - this.id, - required this.stepOrder, - required this.approver, - required this.status, - required this.assignedAt, - this.decidedAt, - this.note, - this.isDeleted = false, - }); - - final int? id; - final int stepOrder; - final ApprovalApproverDto approver; - final ApprovalStatusDto status; - final DateTime assignedAt; - final DateTime? decidedAt; - final String? note; - final bool isDeleted; - - factory ApprovalStepDto.fromJson(Map json) { - return ApprovalStepDto( - id: json['id'] as int?, - stepOrder: json['step_order'] as int? ?? 0, - approver: ApprovalApproverDto.fromJson( - (json['approver'] as Map? ?? const {}), - ), - status: ApprovalStatusDto.fromJson( - (json['status'] as Map? ?? - json['step_status'] as Map? ?? - json['approval_status'] as Map? ?? - const {}), - ), - assignedAt: _parseDate(json['assigned_at']) ?? DateTime.now(), - decidedAt: _parseDate(json['decided_at']), - note: _readString(json['note']), - isDeleted: - json['is_deleted'] as bool? ?? - (json['deleted_at'] != null || - (json['is_active'] is bool && !(json['is_active'] as bool))), - ); - } - - /// DTO를 [ApprovalStep]으로 변환한다. - ApprovalStep toEntity() => ApprovalStep( - id: id, - stepOrder: stepOrder, - approver: approver.toEntity(), - status: status.toEntity(), - assignedAt: assignedAt, - decidedAt: decidedAt, - note: note, - isDeleted: isDeleted, - ); -} - -/// 결재 이력 DTO. -class ApprovalHistoryDto { - ApprovalHistoryDto({ - this.id, - required this.action, - this.fromStatus, - required this.toStatus, - required this.approver, - required this.actionAt, - this.note, - }); - - final int? id; - final ApprovalActionDto action; - final ApprovalStatusDto? fromStatus; - final ApprovalStatusDto toStatus; - final ApprovalApproverDto approver; - final DateTime actionAt; - final String? note; - - factory ApprovalHistoryDto.fromJson(Map json) { - final actionMap = _firstNonEmptyMap([ - json['action'], - json['approval_action'], - json['step_action'], - ]); - final fromStatusMap = _firstNonEmptyMap([ - json['from_status'], - json['fromStatus'], - ]); - final toStatusMap = _firstNonEmptyMap([ - json['to_status'], - json['toStatus'], - ]); - final approverMap = _firstNonEmptyMap([json['approver'], json['employee']]); - final fallbackAction = { - 'id': json['approval_action_id'] ?? json['action_id'], - 'name': - _readString(json['approval_action_name']) ?? - _readString(json['action_name']) ?? - _readString(json['action']) ?? - '-', - }; - - return ApprovalHistoryDto( - id: json['id'] as int?, - action: ApprovalActionDto.fromJson( - actionMap.isEmpty ? fallbackAction : actionMap, - ), - fromStatus: fromStatusMap.isEmpty - ? null - : ApprovalStatusDto.fromJson(fromStatusMap), - toStatus: ApprovalStatusDto.fromJson(toStatusMap), - approver: ApprovalApproverDto.fromJson(approverMap), - actionAt: - _parseDate(json['action_at'] ?? json['actionAt']) ?? DateTime.now(), - note: _readString(json['note']), - ); - } - - /// DTO를 [ApprovalHistory]로 변환한다. - ApprovalHistory toEntity() => ApprovalHistory( - id: id, - action: action.toEntity(), - fromStatus: fromStatus?.toEntity(), - toStatus: toStatus.toEntity(), - approver: approver.toEntity(), - actionAt: actionAt, - note: note, - ); -} - -/// 결재 행위(Action) DTO. -class ApprovalActionDto { - ApprovalActionDto({required this.id, required this.name}); - - final int id; - final String name; - - factory ApprovalActionDto.fromJson(Map json) { - if (json['action'] is Map) { - return ApprovalActionDto.fromJson(json['action'] as Map); - } - return ApprovalActionDto( - id: - json['id'] as int? ?? - json['action_id'] as int? ?? - json['approval_action_id'] as int? ?? - 0, - name: - _readString(json['name']) ?? - _readString(json['action_name']) ?? - _readString(json['approval_action_name']) ?? - _readString(json['action']) ?? - '-', - ); - } - - /// DTO를 [ApprovalAction]으로 변환한다. - ApprovalAction toEntity() => ApprovalAction(id: id, name: name); -} - List> _asListOfMap(dynamic value) { if (value is List) { return value.whereType>().toList(growable: false); @@ -426,6 +232,60 @@ Map _firstNonEmptyMap(List candidates) { return const {}; } +Map _resolveRequesterMap( + Map root, + Map envelope, + Map candidate, +) { + if (candidate.isNotEmpty) { + return candidate; + } + final resolved = {}; + final rootRequestedBy = _mapOrEmpty(root['requested_by']); + if (rootRequestedBy.isNotEmpty) { + resolved.addAll(rootRequestedBy); + } + final envelopeRequestedBy = _mapOrEmpty(envelope['requested_by']); + if (resolved.isEmpty && envelopeRequestedBy.isNotEmpty) { + resolved.addAll(envelopeRequestedBy); + } else if (envelopeRequestedBy.isNotEmpty) { + for (final entry in envelopeRequestedBy.entries) { + resolved.putIfAbsent(entry.key, () => entry.value); + } + } + + final fallbackId = _pickInt( + [resolved, rootRequestedBy, envelopeRequestedBy, root, envelope], + const ['requester_id', 'requested_by_id', 'id'], + ); + if (fallbackId != null) { + resolved['id'] = fallbackId; + } + + final fallbackEmployeeNo = _pickString( + [resolved, rootRequestedBy, envelopeRequestedBy, root, envelope], + const [ + 'employee_no', + 'employee_id', + 'requester_employee_no', + 'requested_by_employee_no', + ], + ); + if (fallbackEmployeeNo != null) { + resolved['employee_no'] = fallbackEmployeeNo; + } + + final fallbackName = _pickString( + [resolved, rootRequestedBy, envelopeRequestedBy, root, envelope], + const ['name', 'employee_name', 'requester_name', 'requested_by_name'], + ); + if (fallbackName != null) { + resolved['name'] = fallbackName; + } + + return resolved; +} + String? _pickString(List sources, List keys) { for (final source in sources) { if (source is Map) { @@ -440,6 +300,29 @@ String? _pickString(List sources, List keys) { return null; } +int? _pickInt(List sources, List keys) { + for (final source in sources) { + if (source is Map) { + for (final key in keys) { + final value = source[key]; + if (value is int) { + return value; + } + if (value is num) { + return value.toInt(); + } + if (value is String) { + final parsed = int.tryParse(value); + if (parsed != null) { + return parsed; + } + } + } + } + } + return null; +} + /// 문자열/DateTime 입력을 DateTime으로 변환한다. DateTime? _parseDate(Object? value) { if (value == null) return null; diff --git a/lib/features/approvals/data/dtos/approval_proceed_status_dto.dart b/lib/features/approvals/data/dtos/approval_proceed_status_dto.dart index 273fa2a..4e0410d 100644 --- a/lib/features/approvals/data/dtos/approval_proceed_status_dto.dart +++ b/lib/features/approvals/data/dtos/approval_proceed_status_dto.dart @@ -21,8 +21,8 @@ class ApprovalProceedStatusDto { } ApprovalProceedStatus toEntity() => ApprovalProceedStatus( - approvalId: approvalId, - canProceed: canProceed, - reason: reason, - ); + approvalId: approvalId, + canProceed: canProceed, + reason: reason, + ); } diff --git a/lib/features/approvals/data/dtos/approval_request_dto.dart b/lib/features/approvals/data/dtos/approval_request_dto.dart new file mode 100644 index 0000000..7db25df --- /dev/null +++ b/lib/features/approvals/data/dtos/approval_request_dto.dart @@ -0,0 +1,254 @@ +import 'package:superport_v2/core/common/utils/json_utils.dart'; + +import '../../domain/entities/approval.dart'; +import 'approval_audit_dto.dart'; + +/// 결재 상신(Submit) 요청 DTO. +class ApprovalSubmitRequestDto { + ApprovalSubmitRequestDto({required this.approval, required this.steps}); + + final ApprovalCreatePayloadDto approval; + final List steps; + + Map toJson() { + return { + 'approval': approval.toJson(), + 'steps': steps.map((e) => e.toJson()).toList(growable: false), + }; + } +} + +/// 결재 재상신 요청 DTO. +class ApprovalResubmitRequestDto { + ApprovalResubmitRequestDto({ + required this.approvalId, + required this.actorId, + required this.steps, + this.note, + this.expectedUpdatedAt, + this.transactionExpectedUpdatedAt, + }); + + final int approvalId; + final int actorId; + final List steps; + final String? note; + final DateTime? expectedUpdatedAt; + final DateTime? transactionExpectedUpdatedAt; + + Map toJson() { + final sanitizedNote = note?.trim(); + return { + 'approval_id': approvalId, + 'actor_id': actorId, + 'steps': steps.map((e) => e.toJson()).toList(growable: false), + if (sanitizedNote != null && sanitizedNote.isNotEmpty) + 'note': sanitizedNote, + if (expectedUpdatedAt != null) + 'expected_updated_at': expectedUpdatedAt!.toUtc().toIso8601String(), + if (transactionExpectedUpdatedAt != null) + 'transaction_expected_updated_at': transactionExpectedUpdatedAt! + .toUtc() + .toIso8601String(), + }; + } +} + +/// 결재 승인/반려 요청 DTO. +class ApprovalDecisionRequestDto { + ApprovalDecisionRequestDto({ + required this.approvalId, + required this.actorId, + this.note, + this.expectedUpdatedAt, + }); + + final int approvalId; + final int actorId; + final String? note; + final DateTime? expectedUpdatedAt; + + Map toJson() { + final sanitizedNote = note?.trim(); + return { + 'approval_id': approvalId, + 'actor_id': actorId, + if (sanitizedNote != null && sanitizedNote.isNotEmpty) + 'note': sanitizedNote, + if (expectedUpdatedAt != null) + 'expected_updated_at': expectedUpdatedAt!.toUtc().toIso8601String(), + }; + } +} + +/// 결재 회수 요청 DTO. +class ApprovalRecallRequestDto { + ApprovalRecallRequestDto({ + required this.approvalId, + required this.actorId, + this.note, + this.expectedUpdatedAt, + this.transactionExpectedUpdatedAt, + }); + + final int approvalId; + final int actorId; + final String? note; + final DateTime? expectedUpdatedAt; + final DateTime? transactionExpectedUpdatedAt; + + Map toJson() { + final sanitizedNote = note?.trim(); + return { + 'approval_id': approvalId, + 'actor_id': actorId, + if (sanitizedNote != null && sanitizedNote.isNotEmpty) + 'note': sanitizedNote, + if (expectedUpdatedAt != null) + 'expected_updated_at': expectedUpdatedAt!.toUtc().toIso8601String(), + if (transactionExpectedUpdatedAt != null) + 'transaction_expected_updated_at': transactionExpectedUpdatedAt! + .toUtc() + .toIso8601String(), + }; + } +} + +/// 결재 본문 생성 DTO. +class ApprovalCreatePayloadDto { + ApprovalCreatePayloadDto({ + this.transactionId, + this.templateId, + required this.statusId, + required this.requesterId, + this.finalApproverId, + this.requestedAt, + this.decidedAt, + this.cancelledAt, + this.lastActionAt, + this.title, + this.summary, + this.note, + this.metadata, + }); + + final int? transactionId; + final int? templateId; + final int statusId; + final int requesterId; + final int? finalApproverId; + final DateTime? requestedAt; + final DateTime? decidedAt; + final DateTime? cancelledAt; + final DateTime? lastActionAt; + final String? title; + final String? summary; + final String? note; + final Map? metadata; + + Map toJson() { + final sanitizedNote = note?.trim(); + final sanitizedTitle = title?.trim(); + final sanitizedSummary = summary?.trim(); + return { + 'transaction_id': transactionId, + 'template_id': templateId, + 'approval_status_id': statusId, + 'requested_by_id': requesterId, + 'final_approver_id': finalApproverId, + if (requestedAt != null) + 'requested_at': requestedAt!.toUtc().toIso8601String(), + if (decidedAt != null) 'decided_at': decidedAt!.toUtc().toIso8601String(), + if (cancelledAt != null) + 'cancelled_at': cancelledAt!.toUtc().toIso8601String(), + if (lastActionAt != null) + 'last_action_at': lastActionAt!.toUtc().toIso8601String(), + if (sanitizedTitle != null && sanitizedTitle.isNotEmpty) + 'title': sanitizedTitle, + if (sanitizedSummary != null && sanitizedSummary.isNotEmpty) + 'summary': sanitizedSummary, + if (sanitizedNote != null && sanitizedNote.isNotEmpty) + 'note': sanitizedNote, + if (metadata != null && metadata!.isNotEmpty) 'metadata': metadata, + }; + } + + factory ApprovalCreatePayloadDto.fromSubmission( + ApprovalSubmissionInput input, + ) { + return ApprovalCreatePayloadDto( + transactionId: input.transactionId, + templateId: input.templateId, + statusId: input.statusId, + requesterId: input.requesterId, + finalApproverId: input.finalApproverId, + requestedAt: input.requestedAt, + decidedAt: input.decidedAt, + cancelledAt: input.cancelledAt, + lastActionAt: input.lastActionAt, + title: input.title, + summary: input.summary, + note: input.note, + metadata: input.metadata, + ); + } +} + +/// 결재 단계 생성 입력 DTO. +class ApprovalStepInputDto { + ApprovalStepInputDto({ + required this.stepOrder, + required this.approverId, + this.note, + }); + + final int stepOrder; + final int approverId; + final String? note; + + factory ApprovalStepInputDto.fromDomain(ApprovalStepAssignmentItem item) { + return ApprovalStepInputDto( + stepOrder: item.stepOrder, + approverId: item.approverId, + note: item.note, + ); + } + + Map toJson() { + final sanitizedNote = note?.trim(); + return { + 'step_order': stepOrder, + 'approver_id': approverId, + if (sanitizedNote != null && sanitizedNote.isNotEmpty) + 'note': sanitizedNote, + }; + } +} + +/// 감사 로그 리스트 응답 DTO. +class ApprovalAuditListDto { + ApprovalAuditListDto({ + required this.items, + required this.page, + required this.pageSize, + required this.total, + }); + + final List items; + final int page; + final int pageSize; + final int total; + + factory ApprovalAuditListDto.fromJson(Map? json) { + final rawItems = JsonUtils.extractList(json, keys: const ['items']); + final items = rawItems + .map((item) => ApprovalAuditDto.fromJson(item)) + .toList(growable: false); + return ApprovalAuditListDto( + items: items, + page: JsonUtils.readInt(json, 'page', fallback: 1), + pageSize: JsonUtils.readInt(json, 'page_size', fallback: items.length), + total: JsonUtils.readInt(json, 'total', fallback: items.length), + ); + } +} diff --git a/lib/features/approvals/data/dtos/approval_step_dto.dart b/lib/features/approvals/data/dtos/approval_step_dto.dart new file mode 100644 index 0000000..de85b36 --- /dev/null +++ b/lib/features/approvals/data/dtos/approval_step_dto.dart @@ -0,0 +1,207 @@ +import 'package:superport_v2/core/common/utils/json_utils.dart'; + +import '../../domain/entities/approval.dart'; + +/// 결재 상태(summary) DTO. +class ApprovalStatusDto { + ApprovalStatusDto({ + required this.id, + required this.name, + this.color, + bool? isBlockingNext, + bool? isTerminal, + }) : isBlockingNext = isBlockingNext ?? true, + isTerminal = isTerminal ?? false; + + final int id; + final String name; + final String? color; + final bool isBlockingNext; + final bool isTerminal; + + factory ApprovalStatusDto.fromJson(Map json) { + if (json['status'] is Map) { + return ApprovalStatusDto.fromJson(json['status'] as Map); + } + final resolvedName = + _readString(json, 'name') ?? + _readString(json, 'status_name') ?? + _readString(json, 'statusName') ?? + '-'; + final rawColor = + _readString(json, 'color') ?? + _readString(json, 'status_color') ?? + _readString(json, 'statusColor'); + return ApprovalStatusDto( + id: JsonUtils.readInt(json, 'id', fallback: 0), + name: resolvedName, + color: rawColor, + isBlockingNext: + _readBool(json, 'is_blocking_next', fallback: true) ?? + _readBool(json, 'isBlockingNext', fallback: true) ?? + true, + isTerminal: + _readBool(json, 'is_terminal', fallback: false) ?? + _readBool(json, 'isTerminal', fallback: false) ?? + false, + ); + } + + ApprovalStatus toEntity() => ApprovalStatus( + id: id, + name: name, + color: color, + isBlockingNext: isBlockingNext, + isTerminal: isTerminal, + ); +} + +/// 결재 사용자 요약 DTO. +class ApprovalApproverDto { + ApprovalApproverDto({ + required this.id, + required this.employeeNo, + required this.name, + }); + + final int id; + final String employeeNo; + final String name; + + factory ApprovalApproverDto.fromJson(Map json) { + final employeeNo = + _readString(json, 'employee_no') ?? + _readString(json, 'employee_id', fallback: '-'); + return ApprovalApproverDto( + id: JsonUtils.readInt(json, 'id', fallback: 0), + employeeNo: employeeNo ?? '-', + name: _readString(json, 'name', fallback: '-') ?? '-', + ); + } + + ApprovalApprover toEntity() => + ApprovalApprover(id: id, employeeNo: employeeNo, name: name); +} + +/// 결재 단계(summary) DTO. +class ApprovalStepDto { + ApprovalStepDto({ + this.id, + this.requestId, + required this.stepOrder, + this.templateStepId, + this.approverRole, + required this.approver, + required this.status, + this.assignedAt, + this.decidedAt, + this.actionAt, + this.note, + this.isDeleted = false, + this.isOptional = false, + this.escalationMinutes, + this.metadata, + }); + + final int? id; + final int? requestId; + final int stepOrder; + final int? templateStepId; + final String? approverRole; + final ApprovalApproverDto approver; + final ApprovalStatusDto status; + final DateTime? assignedAt; + final DateTime? decidedAt; + final DateTime? actionAt; + final String? note; + final bool isDeleted; + final bool isOptional; + final int? escalationMinutes; + final Map? metadata; + + factory ApprovalStepDto.fromJson(Map json) { + final statusMap = + _asMap(json['status']) ?? _asMap(json['step_status']) ?? const {}; + final approverMap = _asMap(json['approver']) ?? const {}; + + final assignedAt = _parseDate(json['assigned_at']); + final decidedAt = _parseDate(json['decided_at']); + final actionAt = _parseDate(json['action_at']); + + return ApprovalStepDto( + id: JsonUtils.readInt(json, 'id'), + requestId: JsonUtils.readInt(json, 'request_id'), + stepOrder: JsonUtils.readInt(json, 'step_order', fallback: 0), + templateStepId: JsonUtils.readInt(json, 'template_step_id'), + approverRole: _readString(json, 'approver_role'), + approver: ApprovalApproverDto.fromJson(approverMap), + status: ApprovalStatusDto.fromJson(statusMap), + assignedAt: assignedAt, + decidedAt: decidedAt, + actionAt: actionAt, + note: _readString(json, 'note'), + isDeleted: + _readBool(json, 'is_deleted') ?? + (json['deleted_at'] != null || + (json['is_active'] is bool && !(json['is_active'] as bool))), + isOptional: _readBool(json, 'is_optional', fallback: false) ?? false, + escalationMinutes: JsonUtils.readInt(json, 'escalation_minutes'), + metadata: _asMap( + json['metadata'], + )?.map((key, value) => MapEntry(key, value)), + ); + } + + ApprovalStep toEntity() => ApprovalStep( + id: id, + requestId: requestId, + stepOrder: stepOrder, + templateStepId: templateStepId, + approverRole: approverRole, + approver: approver.toEntity(), + status: status.toEntity(), + assignedAt: assignedAt ?? DateTime.now(), + decidedAt: decidedAt, + actionAt: actionAt, + note: note, + isDeleted: isDeleted, + isOptional: isOptional, + escalationMinutes: escalationMinutes, + metadata: metadata, + ); +} + +String? _readString( + Map? source, + String key, { + String? fallback, +}) { + if (source == null) return fallback; + final value = source[key]; + if (value is String) return value; + if (value == null) return fallback; + return value.toString(); +} + +bool? _readBool(Map? source, String key, {bool? fallback}) { + if (source == null) return fallback; + final value = source[key]; + if (value is bool) return value; + if (value is num) return value != 0; + if (value is String) { + final normalized = value.trim().toLowerCase(); + if (normalized.isEmpty) return fallback; + return ['1', 'y', 'yes', 'true'].contains(normalized); + } + return fallback; +} + +Map? _asMap(dynamic value) => + value is Map ? value : null; + +DateTime? _parseDate(Object? value) { + if (value == null) return null; + if (value is DateTime) return value; + if (value is String) return DateTime.tryParse(value); + return null; +} diff --git a/lib/features/approvals/data/repositories/approval_draft_repository_remote.dart b/lib/features/approvals/data/repositories/approval_draft_repository_remote.dart new file mode 100644 index 0000000..4ecd071 --- /dev/null +++ b/lib/features/approvals/data/repositories/approval_draft_repository_remote.dart @@ -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> 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>( + ApiRoutes.approvalDrafts, + query: query, + options: Options(responseType: ResponseType.json), + ); + return ApprovalDraftDto.parsePaginated(response.data); + } + + @override + Future fetch({ + required int id, + required int requesterId, + }) async { + final query = ApiClient.buildQuery(filters: {'requester_id': requesterId}); + final response = await _api.get>( + ApiClient.buildPath(ApiRoutes.approvalDrafts, [id]), + query: query, + options: Options(responseType: ResponseType.json), + ); + return ApprovalDraftDto.parseDetail(response.data); + } + + @override + Future save(ApprovalDraftSaveInput input) async { + final payload = input.toJson(); + final response = await _api.post>( + 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 delete({required int id, required int requesterId}) async { + final query = ApiClient.buildQuery(filters: {'requester_id': requesterId}); + await _api.delete( + ApiClient.buildPath(ApiRoutes.approvalDrafts, [id]), + query: query, + options: Options(responseType: ResponseType.json), + ); + } +} diff --git a/lib/features/approvals/data/repositories/approval_repository_remote.dart b/lib/features/approvals/data/repositories/approval_repository_remote.dart index 49fd0c6..17025cf 100644 --- a/lib/features/approvals/data/repositories/approval_repository_remote.dart +++ b/lib/features/approvals/data/repositories/approval_repository_remote.dart @@ -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? statusCodes, + bool includePending = false, bool includeHistories = false, bool includeSteps = false, }) async { - final includeParts = []; + final includeParts = ['requested_by', 'transaction']; if (includeSteps) { includeParts.add('steps'); } if (includeHistories) { includeParts.add('histories'); } - final response = await _api.get>( - _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>( + _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 = []; + final includeParts = ['transaction', 'requested_by']; if (includeSteps) { includeParts.add('steps'); } if (includeHistories) { includeParts.add('histories'); } + final query = ApiClient.buildQuery(include: includeParts); final response = await _api.get>( - '$_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 submit(ApprovalSubmissionInput input) async { + final payload = ApprovalSubmitRequestDto( + approval: ApprovalCreatePayloadDto.fromSubmission(input), + steps: _mapSteps(input.steps), + ); + final response = await _api.post>( + ApiRoutes.approvalAction('submit'), + data: payload.toJson(), + options: Options(responseType: ResponseType.json), + ); + return _mapApprovalFromResponse(response.data); + } + + @override + Future 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>( + ApiRoutes.approvalAction('resubmit'), + data: payload.toJson(), + options: Options(responseType: ResponseType.json), + ); + return _mapApprovalFromResponse(response.data); + } + + @override + Future approve(ApprovalDecisionInput input) async { + final payload = ApprovalDecisionRequestDto( + approvalId: input.approvalId, + actorId: input.actorId, + note: input.note, + expectedUpdatedAt: input.expectedUpdatedAt, + ); + final response = await _api.post>( + ApiRoutes.approvalAction('approve'), + data: payload.toJson(), + options: Options(responseType: ResponseType.json), + ); + return _mapApprovalFromResponse(response.data); + } + + @override + Future reject(ApprovalDecisionInput input) async { + final payload = ApprovalDecisionRequestDto( + approvalId: input.approvalId, + actorId: input.actorId, + note: input.note, + expectedUpdatedAt: input.expectedUpdatedAt, + ); + final response = await _api.post>( + ApiRoutes.approvalAction('reject'), + data: payload.toJson(), + options: Options(responseType: ResponseType.json), + ); + return _mapApprovalFromResponse(response.data); + } + + @override + Future 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>( + ApiRoutes.approvalAction('recall'), + data: payload.toJson(), + options: Options(responseType: ResponseType.json), + ); + return _mapApprovalFromResponse(response.data); } /// 활성화된 결재 행위 목록을 조회한다. @override Future> listActions({bool activeOnly = true}) async { + final query = ApiClient.buildQuery( + page: 1, + pageSize: 100, + filters: {if (activeOnly) 'active': true}, + ); final response = await _api.get>( - '${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? ?? []) @@ -91,11 +188,50 @@ class ApprovalRepositoryRemote implements ApprovalRepository { return items; } + @override + Future> 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>( + ApiRoutes.approvalHistory, + query: query, + options: Options(responseType: ResponseType.json), + ); + final dto = ApprovalAuditListDto.fromJson(response.data ?? const {}); + return PaginatedResult( + items: dto.items.map((e) => e.toEntity()).toList(growable: false), + page: dto.page, + pageSize: dto.pageSize, + total: dto.total, + ); + } + /// 결재 단계 행위를 수행하고 업데이트된 결재 정보를 반환한다. @override Future performStepAction(ApprovalStepActionInput input) async { + final path = ApiClient.buildPath(ApiRoutes.approvalSteps, [ + input.stepId, + 'actions', + ]); final response = await _api.post>( - '${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 assignSteps(ApprovalStepAssignmentInput input) async { + final path = ApiClient.buildPath(_basePath, [input.approvalId, 'steps']); final response = await _api.post>( - '${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 canProceed(int id) async { final response = await _api.get>( - '$_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 update(ApprovalUpdateInput input) async { final response = await _api.patch>( - '$_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 delete(int id) async { - await _api.delete('$_basePath/$id'); + await _api.delete(ApiClient.buildPath(_basePath, [id])); } /// 삭제된 결재를 복구한다. @override Future restore(int id) async { final response = await _api.post>( - '$_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? body) { + final payload = _extractApprovalPayload(body); + if (payload.isEmpty) { + throw StateError('결재 응답에 결재 데이터가 없습니다.'); + } + return ApprovalDto.fromJson(payload).toEntity(); + } + + Map _extractApprovalPayload(Map? body) { + if (body == null || body.isEmpty) { + return const {}; + } + final data = body['data']; + if (data is Map) { + final approval = _selectApprovalPayload(data); + if (approval != null) { + return approval; + } + return Map.from(data); + } + final approval = _selectApprovalPayload(body); + if (approval != null) { + return approval; + } + return Map.from(body); + } + + List _mapSteps(List items) { + return items.map(ApprovalStepInputDto.fromDomain).toList(growable: false); } /// 결재 단계/행위 응답에서 결재 객체 JSON을 추출한다. diff --git a/lib/features/approvals/domain/entities/approval.dart b/lib/features/approvals/domain/entities/approval.dart index 953857c..ee1f395 100644 --- a/lib/features/approvals/domain/entities/approval.dart +++ b/lib/features/approvals/domain/entities/approval.dart @@ -6,7 +6,9 @@ class Approval { Approval({ this.id, required this.approvalNo, + this.transactionId, required this.transactionNo, + this.transactionUpdatedAt, required this.status, this.currentStep, required this.requester, @@ -23,7 +25,9 @@ class Approval { final int? id; final String approvalNo; + final int? transactionId; final String transactionNo; + final DateTime? transactionUpdatedAt; final ApprovalStatus status; final ApprovalStep? currentStep; final ApprovalRequester requester; @@ -40,7 +44,9 @@ class Approval { Approval copyWith({ int? id, String? approvalNo, + int? transactionId, String? transactionNo, + DateTime? transactionUpdatedAt, ApprovalStatus? status, ApprovalStep? currentStep, ApprovalRequester? requester, @@ -57,7 +63,9 @@ class Approval { return Approval( id: id ?? this.id, approvalNo: approvalNo ?? this.approvalNo, + transactionId: transactionId ?? this.transactionId, transactionNo: transactionNo ?? this.transactionNo, + transactionUpdatedAt: transactionUpdatedAt ?? this.transactionUpdatedAt, status: status ?? this.status, currentStep: currentStep ?? this.currentStep, requester: requester ?? this.requester, @@ -75,11 +83,35 @@ class Approval { } class ApprovalStatus { - ApprovalStatus({required this.id, required this.name, this.color}); + ApprovalStatus({ + required this.id, + required this.name, + this.color, + this.isBlockingNext = true, + this.isTerminal = false, + }); final int id; final String name; final String? color; + final bool isBlockingNext; + final bool isTerminal; + + ApprovalStatus copyWith({ + int? id, + String? name, + String? color, + bool? isBlockingNext, + bool? isTerminal, + }) { + return ApprovalStatus( + id: id ?? this.id, + name: name ?? this.name, + color: color ?? this.color, + isBlockingNext: isBlockingNext ?? this.isBlockingNext, + isTerminal: isTerminal ?? this.isTerminal, + ); + } } class ApprovalRequester { @@ -97,43 +129,71 @@ class ApprovalRequester { class ApprovalStep { ApprovalStep({ this.id, + this.requestId, required this.stepOrder, + this.templateStepId, + this.approverRole, required this.approver, required this.status, required this.assignedAt, this.decidedAt, this.note, this.isDeleted = false, + this.actionAt, + this.isOptional = false, + this.escalationMinutes, + this.metadata, }); final int? id; + final int? requestId; final int stepOrder; + final int? templateStepId; + final String? approverRole; final ApprovalApprover approver; final ApprovalStatus status; final DateTime assignedAt; final DateTime? decidedAt; final String? note; final bool isDeleted; + final DateTime? actionAt; + final bool isOptional; + final int? escalationMinutes; + final Map? metadata; ApprovalStep copyWith({ int? id, + int? requestId, int? stepOrder, + int? templateStepId, + String? approverRole, ApprovalApprover? approver, ApprovalStatus? status, DateTime? assignedAt, DateTime? decidedAt, String? note, bool? isDeleted, + DateTime? actionAt, + bool? isOptional, + int? escalationMinutes, + Map? metadata, }) { return ApprovalStep( id: id ?? this.id, + requestId: requestId ?? this.requestId, stepOrder: stepOrder ?? this.stepOrder, + templateStepId: templateStepId ?? this.templateStepId, + approverRole: approverRole ?? this.approverRole, approver: approver ?? this.approver, status: status ?? this.status, assignedAt: assignedAt ?? this.assignedAt, decidedAt: decidedAt ?? this.decidedAt, note: note ?? this.note, isDeleted: isDeleted ?? this.isDeleted, + actionAt: actionAt ?? this.actionAt, + isOptional: isOptional ?? this.isOptional, + escalationMinutes: escalationMinutes ?? this.escalationMinutes, + metadata: metadata ?? this.metadata, ); } } @@ -159,6 +219,8 @@ class ApprovalHistory { required this.approver, required this.actionAt, this.note, + this.actionCode, + this.payload, }); final int? id; @@ -168,13 +230,16 @@ class ApprovalHistory { final ApprovalApprover approver; final DateTime actionAt; final String? note; + final String? actionCode; + final Map? payload; } class ApprovalAction { - ApprovalAction({required this.id, required this.name}); + ApprovalAction({required this.id, required this.name, this.code}); final int id; final String name; + final String? code; } /// 결재 단계에서 수행 가능한 행위 타입 @@ -300,3 +365,85 @@ class ApprovalStepAssignmentItem { }; } } + +/// 결재 상신 입력 모델. +class ApprovalSubmissionInput { + ApprovalSubmissionInput({ + this.transactionId, + this.templateId, + required this.statusId, + required this.requesterId, + this.finalApproverId, + this.requestedAt, + this.decidedAt, + this.cancelledAt, + this.lastActionAt, + this.title, + this.summary, + this.note, + this.metadata, + this.steps = const [], + }); + + final int? transactionId; + final int? templateId; + final int statusId; + final int requesterId; + final int? finalApproverId; + final DateTime? requestedAt; + final DateTime? decidedAt; + final DateTime? cancelledAt; + final DateTime? lastActionAt; + final String? title; + final String? summary; + final String? note; + final Map? metadata; + final List steps; +} + +/// 결재 승인/반려 입력 모델. +class ApprovalDecisionInput { + ApprovalDecisionInput({ + required this.approvalId, + required this.actorId, + this.note, + this.expectedUpdatedAt, + }); + + final int approvalId; + final int actorId; + final String? note; + final DateTime? expectedUpdatedAt; +} + +/// 결재 회수 입력 모델. +class ApprovalRecallInput extends ApprovalDecisionInput { + ApprovalRecallInput({ + required super.approvalId, + required super.actorId, + super.note, + super.expectedUpdatedAt, + this.transactionExpectedUpdatedAt, + }); + + final DateTime? transactionExpectedUpdatedAt; +} + +/// 결재 재상신 입력 모델. +class ApprovalResubmissionInput { + ApprovalResubmissionInput({ + required this.approvalId, + required this.actorId, + required this.submission, + this.note, + this.expectedUpdatedAt, + this.transactionExpectedUpdatedAt, + }); + + final int approvalId; + final int actorId; + final ApprovalSubmissionInput submission; + final String? note; + final DateTime? expectedUpdatedAt; + final DateTime? transactionExpectedUpdatedAt; +} diff --git a/lib/features/approvals/domain/entities/approval_draft.dart b/lib/features/approvals/domain/entities/approval_draft.dart new file mode 100644 index 0000000..dfc3d73 --- /dev/null +++ b/lib/features/approvals/domain/entities/approval_draft.dart @@ -0,0 +1,286 @@ +import 'dart:collection'; + +import 'approval.dart'; + +/// 결재 초안 상태를 표현하는 열거형. +enum ApprovalDraftStatus { active, expired, archived } + +/// 결재 초안 단계 정보를 나타낸다. +class ApprovalDraftStep { + ApprovalDraftStep({ + required this.stepOrder, + required this.approverId, + this.approverRole, + this.note, + this.isOptional = false, + }); + + final int stepOrder; + final int approverId; + final String? approverRole; + final String? note; + final bool isOptional; + + ApprovalStepAssignmentItem toAssignment() { + return ApprovalStepAssignmentItem( + stepOrder: stepOrder, + approverId: approverId, + note: note, + ); + } + + Map toJson() { + final trimmedNote = note?.trim(); + return { + 'step_order': stepOrder, + 'approver_id': approverId, + if (approverRole != null && approverRole!.trim().isNotEmpty) + 'approver_role': approverRole, + if (trimmedNote != null && trimmedNote.isNotEmpty) 'note': trimmedNote, + 'is_optional': isOptional, + }; + } +} + +/// 결재 초안 본문을 나타낸다. +class ApprovalDraftPayload { + ApprovalDraftPayload({ + this.title, + this.summary, + this.note, + this.templateId, + Map? metadata, + List? steps, + }) : metadata = metadata == null + ? null + : Map.unmodifiable(Map.from(metadata)), + _steps = steps == null + ? const [] + : List.unmodifiable(steps); + + final String? title; + final String? summary; + final String? note; + final int? templateId; + final Map? metadata; + final List _steps; + + UnmodifiableListView get steps => + UnmodifiableListView(_steps); + + List toAssignments() { + return _steps.map((step) => step.toAssignment()).toList(growable: false); + } +} + +/// 결재 초안 요약 정보를 담는다. +class ApprovalDraftSummary { + ApprovalDraftSummary({ + required this.id, + required this.requesterId, + required this.status, + required this.savedAt, + this.requestId, + this.transactionId, + this.templateId, + this.title, + this.summary, + this.expiresAt, + this.sessionKey, + this.stepCount = 0, + }); + + final int id; + final int requesterId; + final ApprovalDraftStatus status; + final DateTime savedAt; + final int? requestId; + final int? transactionId; + final int? templateId; + final String? title; + final String? summary; + final DateTime? expiresAt; + final String? sessionKey; + final int stepCount; +} + +/// 결재 초안 상세 정보를 나타낸다. +class ApprovalDraftDetail { + ApprovalDraftDetail({ + required this.id, + required this.requesterId, + required this.payload, + required this.savedAt, + this.transactionId, + this.templateId, + this.expiresAt, + this.sessionKey, + }); + + final int id; + final int requesterId; + final ApprovalDraftPayload payload; + final DateTime savedAt; + final int? transactionId; + final int? templateId; + final DateTime? expiresAt; + final String? sessionKey; + + Map? get sanitizedMetadata => + _stripClientState(payload.metadata); + + ApprovalSubmissionInput toSubmissionInput({ + int? defaultStatusId, + int? transactionIdOverride, + }) { + final statusId = _extractStatusId(payload.metadata) ?? defaultStatusId ?? 0; + final assignments = payload.toAssignments(); + final cleanedMetadata = _stripClientState(payload.metadata); + return ApprovalSubmissionInput( + transactionId: transactionIdOverride ?? transactionId, + templateId: payload.templateId ?? templateId, + statusId: statusId, + requesterId: requesterId, + finalApproverId: assignments.isEmpty ? null : assignments.last.approverId, + title: payload.title, + summary: payload.summary, + note: payload.note, + metadata: cleanedMetadata, + steps: assignments, + ); + } +} + +/// 결재 초안 목록 필터. +class ApprovalDraftListFilter { + const ApprovalDraftListFilter({ + required this.requesterId, + this.page = 1, + this.pageSize = 20, + this.transactionId, + this.includeExpired = false, + }) : assert(page > 0, 'page는 1 이상이어야 합니다.'); + + final int requesterId; + final int page; + final int pageSize; + final int? transactionId; + final bool includeExpired; + + Map toQuery() { + return { + 'page': page, + 'page_size': pageSize, + 'requester_id': requesterId, + if (transactionId != null) 'transaction_id': transactionId, + if (includeExpired) 'include_expired': includeExpired, + }; + } +} + +/// 결재 초안 저장 입력 모델. +class ApprovalDraftSaveInput { + ApprovalDraftSaveInput({ + required this.requesterId, + required List steps, + this.requestId, + this.transactionId, + this.templateId, + this.title, + this.summary, + this.note, + Map? metadata, + this.sessionKey, + this.statusId, + }) : metadata = metadata == null + ? null + : Map.unmodifiable(Map.from(metadata)), + steps = List.unmodifiable(steps); + + final int requesterId; + final List steps; + final int? requestId; + final int? transactionId; + final int? templateId; + final String? title; + final String? summary; + final String? note; + final Map? metadata; + final String? sessionKey; + final int? statusId; + + bool get hasSteps => steps.isNotEmpty; + + Map toJson() { + final payload = { + 'requester_id': requesterId, + if (requestId != null) 'request_id': requestId, + if (transactionId != null) 'transaction_id': transactionId, + if (templateId != null) 'template_id': templateId, + if (title != null && title!.trim().isNotEmpty) 'title': title, + if (summary != null && summary!.trim().isNotEmpty) 'summary': summary, + if (note != null && note!.trim().isNotEmpty) 'note': note, + if (sessionKey != null && sessionKey!.trim().isNotEmpty) + 'session_key': sessionKey, + }; + final mergedMetadata = _mergeStatus(source: metadata, statusId: statusId); + if (mergedMetadata != null && mergedMetadata.isNotEmpty) { + payload['metadata'] = mergedMetadata; + } + payload['steps'] = steps + .map((step) => step.toJson()) + .toList(growable: false); + return payload; + } +} + +const _clientStateKey = '_client_state'; +const _statusKey = 'status_id'; + +Map? _mergeStatus({ + Map? source, + int? statusId, +}) { + if (statusId == null) { + return source; + } + final merged = source == null + ? {} + : Map.from(source); + final client = merged[_clientStateKey]; + final state = client is Map + ? Map.from(client) + : {}; + state[_statusKey] = statusId; + merged[_clientStateKey] = state; + return merged; +} + +int? _extractStatusId(Map? metadata) { + if (metadata == null || metadata.isEmpty) { + return null; + } + final client = metadata[_clientStateKey]; + if (client is Map) { + final value = client[_statusKey]; + if (value is int) { + return value; + } + if (value is String) { + return int.tryParse(value); + } + } + return null; +} + +Map? _stripClientState(Map? metadata) { + if (metadata == null || metadata.isEmpty) { + return metadata; + } + if (!metadata.containsKey(_clientStateKey)) { + return metadata; + } + final cloned = Map.from(metadata); + cloned.remove(_clientStateKey); + return cloned.isEmpty ? null : cloned; +} diff --git a/lib/features/approvals/domain/entities/approval_flow.dart b/lib/features/approvals/domain/entities/approval_flow.dart new file mode 100644 index 0000000..8ed3325 --- /dev/null +++ b/lib/features/approvals/domain/entities/approval_flow.dart @@ -0,0 +1,167 @@ +import '../entities/approval.dart'; + +/// 결재 흐름(Approval Flow)을 표현하는 도메인 엔티티. +/// +/// - 상신자, 최종 승인자, 단계 목록, 이력, 상태 요약을 한 번에 제공한다. +/// - presentation 레이어에서는 이 엔티티만 의존해 UI를 구성한다. +class ApprovalFlow { + ApprovalFlow({ + required Approval approval, + ApprovalApprover? finalApprover, + ApprovalFlowStatusSummary? statusSummary, + }) : _approval = approval, + finalApprover = finalApprover ?? _inferFinalApprover(approval.steps), + statusSummary = + statusSummary ?? + ApprovalFlowStatusSummary.from( + status: approval.status, + steps: approval.steps, + currentStep: approval.currentStep, + ), + _steps = List.unmodifiable(approval.steps), + _histories = List.unmodifiable(approval.histories); + + /// 결재 원본 데이터 + final Approval _approval; + + /// 결재 단계 목록 + final List _steps; + + /// 결재 이력 목록 + final List _histories; + + /// 최종 승인자 정보 (단계 목록 기반 추론 결과) + final ApprovalApprover? finalApprover; + + /// 결재 상태 요약 정보 + final ApprovalFlowStatusSummary statusSummary; + + /// 원본 결재 엔티티에 접근한다. + Approval get approval => _approval; + + /// 결재 식별자(ID) + int? get id => _approval.id; + + /// 결재 번호(APP-YYYYMMDDNNNN 형식) + String get approvalNo => _approval.approvalNo; + + /// 연동된 전표 번호 + String get transactionNo => _approval.transactionNo; + + /// 연동된 전표 ID + int? get transactionId => _approval.transactionId; + + /// 연동된 전표 최신 수정 시각 + DateTime? get transactionUpdatedAt => _approval.transactionUpdatedAt; + + /// 현재 결재 상태 + ApprovalStatus get status => _approval.status; + + /// 현재 진행 중인 단계 정보 + ApprovalStep? get currentStep => _approval.currentStep; + + /// 상신자 정보 + ApprovalRequester get requester => _approval.requester; + + /// 상신 일시 + DateTime get requestedAt => _approval.requestedAt; + + /// 최종 결정 일시 + DateTime? get decidedAt => _approval.decidedAt; + + /// 결재 메모 + String? get note => _approval.note; + + /// 생성 일시 + DateTime? get createdAt => _approval.createdAt; + + /// 변경 일시 + DateTime? get updatedAt => _approval.updatedAt; + + /// 단계 목록을 반환한다. + List get steps => _steps; + + /// 이력 목록을 반환한다. + List get histories => _histories; + + /// [Approval] 엔티티에서 [ApprovalFlow]를 생성하는 팩토리. + factory ApprovalFlow.fromApproval(Approval approval) => + ApprovalFlow(approval: approval); + + static ApprovalApprover? _inferFinalApprover(List steps) { + if (steps.isEmpty) { + return null; + } + final sorted = List.from(steps) + ..sort((a, b) => a.stepOrder.compareTo(b.stepOrder)); + return sorted.last.approver; + } +} + +/// 결재 상태 요약 정보. +/// +/// - 전체 단계 수, 완료 단계 수, 대기 단계 수, 현재 단계 순번을 제공한다. +class ApprovalFlowStatusSummary { + ApprovalFlowStatusSummary({ + required this.status, + required this.totalSteps, + required this.completedSteps, + required this.pendingSteps, + this.currentStepOrder, + }); + + /// 전체 결재 상태 + final ApprovalStatus status; + + /// 총 단계 수 + final int totalSteps; + + /// 완료된 단계 수 + final int completedSteps; + + /// 대기 중인 단계 수 + final int pendingSteps; + + /// 현재 진행 중인 단계 순번 (없으면 null) + final int? currentStepOrder; + + /// 완료율(%)을 정수로 반환한다. + int get completionRate { + if (totalSteps <= 0) { + return 0; + } + final ratio = (completedSteps / totalSteps) * 100; + return ratio.isFinite ? ratio.round() : 0; + } + + /// 결재 상태와 단계 목록을 기반으로 요약 정보를 생성한다. + factory ApprovalFlowStatusSummary.from({ + required ApprovalStatus status, + required List steps, + ApprovalStep? currentStep, + }) { + final total = steps.length; + final completed = steps.where((step) => step.decidedAt != null).length; + final pending = total - completed; + final currentOrder = currentStep?.stepOrder ?? _findCurrentStepOrder(steps); + return ApprovalFlowStatusSummary( + status: status, + totalSteps: total, + completedSteps: completed, + pendingSteps: pending < 0 ? 0 : pending, + currentStepOrder: currentOrder, + ); + } + + static int? _findCurrentStepOrder(List steps) { + for (final step in steps) { + if (step.decidedAt == null) { + return step.stepOrder; + } + } + if (steps.isEmpty) { + return null; + } + return steps.last.stepOrder; + } +} diff --git a/lib/features/approvals/domain/repositories/approval_draft_repository.dart b/lib/features/approvals/domain/repositories/approval_draft_repository.dart new file mode 100644 index 0000000..374004f --- /dev/null +++ b/lib/features/approvals/domain/repositories/approval_draft_repository.dart @@ -0,0 +1,19 @@ +import 'package:superport_v2/core/common/models/paginated_result.dart'; + +import '../entities/approval_draft.dart'; + +/// 결재 초안 저장소 인터페이스. +abstract class ApprovalDraftRepository { + Future> list( + ApprovalDraftListFilter filter, + ); + + Future fetch({ + required int id, + required int requesterId, + }); + + Future save(ApprovalDraftSaveInput input); + + Future delete({required int id, required int requesterId}); +} diff --git a/lib/features/approvals/domain/repositories/approval_repository.dart b/lib/features/approvals/domain/repositories/approval_repository.dart index aaf5081..df4d2e9 100644 --- a/lib/features/approvals/domain/repositories/approval_repository.dart +++ b/lib/features/approvals/domain/repositories/approval_repository.dart @@ -14,6 +14,8 @@ abstract class ApprovalRepository { int? transactionId, int? approvalStatusId, int? requestedById, + List? statusCodes, + bool includePending = false, bool includeHistories = false, bool includeSteps = false, }); @@ -25,6 +27,32 @@ abstract class ApprovalRepository { bool includeHistories = true, }); + /// 결재를 상신한다. + Future submit(ApprovalSubmissionInput input); + + /// 결재를 재상신한다. + Future resubmit(ApprovalResubmissionInput input); + + /// 결재를 승인한다. + Future approve(ApprovalDecisionInput input); + + /// 결재를 반려한다. + Future reject(ApprovalDecisionInput input); + + /// 결재를 회수한다. + Future recall(ApprovalRecallInput input); + + /// 결재 감사 로그를 조회한다. + Future> listHistory({ + required int approvalId, + int page, + int pageSize, + DateTime? from, + DateTime? to, + int? actorId, + int? approvalActionId, + }); + /// 활성화된 결재 행위(approve/reject/comment 등) 목록 조회 Future> listActions({bool activeOnly = true}); diff --git a/lib/features/approvals/domain/usecases/apply_approval_template_use_case.dart b/lib/features/approvals/domain/usecases/apply_approval_template_use_case.dart new file mode 100644 index 0000000..3a96218 --- /dev/null +++ b/lib/features/approvals/domain/usecases/apply_approval_template_use_case.dart @@ -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 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 _mapTemplateSteps( + ApprovalTemplate template, + ) { + final sorted = List.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); + } +} diff --git a/lib/features/approvals/domain/usecases/approve_approval_use_case.dart b/lib/features/approvals/domain/usecases/approve_approval_use_case.dart new file mode 100644 index 0000000..3938325 --- /dev/null +++ b/lib/features/approvals/domain/usecases/approve_approval_use_case.dart @@ -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 call(ApprovalDecisionInput input) async { + await _ensureCanProceed(input.approvalId); + final approval = await _repository.approve(input); + return ApprovalFlow.fromApproval(approval); + } + + /// 결재 단계 진행 권한을 사전 확인한다. + Future _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('결재를 진행할 권한이 없습니다.'); + } +} diff --git a/lib/features/approvals/domain/usecases/delete_approval_draft_use_case.dart b/lib/features/approvals/domain/usecases/delete_approval_draft_use_case.dart new file mode 100644 index 0000000..5c0d7c1 --- /dev/null +++ b/lib/features/approvals/domain/usecases/delete_approval_draft_use_case.dart @@ -0,0 +1,13 @@ +import '../repositories/approval_draft_repository.dart'; + +/// 결재 초안을 삭제하는 유즈케이스. +class DeleteApprovalDraftUseCase { + DeleteApprovalDraftUseCase({required ApprovalDraftRepository repository}) + : _repository = repository; + + final ApprovalDraftRepository _repository; + + Future call({required int id, required int requesterId}) { + return _repository.delete(id: id, requesterId: requesterId); + } +} diff --git a/lib/features/approvals/domain/usecases/get_approval_draft_use_case.dart b/lib/features/approvals/domain/usecases/get_approval_draft_use_case.dart new file mode 100644 index 0000000..c115450 --- /dev/null +++ b/lib/features/approvals/domain/usecases/get_approval_draft_use_case.dart @@ -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 call({ + required int id, + required int requesterId, + }) { + return _repository.fetch(id: id, requesterId: requesterId); + } +} diff --git a/lib/features/approvals/domain/usecases/list_approval_drafts_use_case.dart b/lib/features/approvals/domain/usecases/list_approval_drafts_use_case.dart new file mode 100644 index 0000000..575f775 --- /dev/null +++ b/lib/features/approvals/domain/usecases/list_approval_drafts_use_case.dart @@ -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> call( + ApprovalDraftListFilter filter, + ) { + return _repository.list(filter); + } +} diff --git a/lib/features/approvals/domain/usecases/recall_approval_use_case.dart b/lib/features/approvals/domain/usecases/recall_approval_use_case.dart new file mode 100644 index 0000000..ab68756 --- /dev/null +++ b/lib/features/approvals/domain/usecases/recall_approval_use_case.dart @@ -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 call(ApprovalRecallInput input) async { + final approval = await _repository.recall(input); + return ApprovalFlow.fromApproval(approval); + } +} diff --git a/lib/features/approvals/domain/usecases/reject_approval_use_case.dart b/lib/features/approvals/domain/usecases/reject_approval_use_case.dart new file mode 100644 index 0000000..6065461 --- /dev/null +++ b/lib/features/approvals/domain/usecases/reject_approval_use_case.dart @@ -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 call(ApprovalDecisionInput input) async { + await _ensureCanProceed(input.approvalId); + final approval = await _repository.reject(input); + return ApprovalFlow.fromApproval(approval); + } + + /// 결재 단계 진행 권한을 사전 확인한다. + Future _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('결재를 진행할 권한이 없습니다.'); + } +} diff --git a/lib/features/approvals/domain/usecases/resubmit_approval_use_case.dart b/lib/features/approvals/domain/usecases/resubmit_approval_use_case.dart new file mode 100644 index 0000000..42c4b80 --- /dev/null +++ b/lib/features/approvals/domain/usecases/resubmit_approval_use_case.dart @@ -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 call(ApprovalResubmissionInput input) async { + final approval = await _repository.resubmit(input); + return ApprovalFlow.fromApproval(approval); + } +} diff --git a/lib/features/approvals/domain/usecases/save_approval_draft_use_case.dart b/lib/features/approvals/domain/usecases/save_approval_draft_use_case.dart new file mode 100644 index 0000000..a184451 --- /dev/null +++ b/lib/features/approvals/domain/usecases/save_approval_draft_use_case.dart @@ -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 call(ApprovalDraftSaveInput input) { + return _repository.save(input); + } +} diff --git a/lib/features/approvals/domain/usecases/save_approval_template_use_case.dart b/lib/features/approvals/domain/usecases/save_approval_template_use_case.dart new file mode 100644 index 0000000..4581aa3 --- /dev/null +++ b/lib/features/approvals/domain/usecases/save_approval_template_use_case.dart @@ -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 call({ + int? templateId, + required ApprovalTemplateInput input, + List? steps, + }) { + if (templateId == null) { + return _repository.create(input, steps: steps ?? const []); + } + return _repository.update(templateId, input, steps: steps); + } +} diff --git a/lib/features/approvals/domain/usecases/submit_approval_use_case.dart b/lib/features/approvals/domain/usecases/submit_approval_use_case.dart new file mode 100644 index 0000000..a757f99 --- /dev/null +++ b/lib/features/approvals/domain/usecases/submit_approval_use_case.dart @@ -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 call(ApprovalSubmissionInput input) async { + final approval = await _repository.submit(input); + return ApprovalFlow.fromApproval(approval); + } +} diff --git a/lib/features/approvals/history/data/dtos/approval_history_record_dto.dart b/lib/features/approvals/history/data/dtos/approval_history_record_dto.dart index b3dd221..324e55c 100644 --- a/lib/features/approvals/history/data/dtos/approval_history_record_dto.dart +++ b/lib/features/approvals/history/data/dtos/approval_history_record_dto.dart @@ -1,6 +1,7 @@ import 'package:superport_v2/core/common/models/paginated_result.dart'; import 'package:superport_v2/core/common/utils/json_utils.dart'; -import 'package:superport_v2/features/approvals/data/dtos/approval_dto.dart'; +import 'package:superport_v2/features/approvals/data/dtos/approval_audit_dto.dart'; +import 'package:superport_v2/features/approvals/data/dtos/approval_step_dto.dart'; import 'package:superport_v2/features/approvals/domain/entities/approval.dart'; import '../../domain/entities/approval_history_record.dart'; diff --git a/lib/features/approvals/history/presentation/controllers/approval_history_controller.dart b/lib/features/approvals/history/presentation/controllers/approval_history_controller.dart index c3368db..cf3697b 100644 --- a/lib/features/approvals/history/presentation/controllers/approval_history_controller.dart +++ b/lib/features/approvals/history/presentation/controllers/approval_history_controller.dart @@ -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 _flowCache = {}; PaginatedResult? _result; + PaginatedResult? _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 _auditActions = {}; + bool _isSelectionForbidden = false; PaginatedResult? 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? 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 get auditActions { + if (_auditActions.isEmpty) { + return const []; + } + 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 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 = {}; + 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 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 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 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 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 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? 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 _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, + ); + } + } + } } diff --git a/lib/features/approvals/history/presentation/pages/approval_history_page.dart b/lib/features/approvals/history/presentation/pages/approval_history_page.dart index df58613..a2ec39c 100644 --- a/lib/features/approvals/history/presentation/pages/approval_history_page.dart +++ b/lib/features/approvals/history/presentation/pages/approval_history_page.dart @@ -7,13 +7,27 @@ import 'package:shadcn_ui/shadcn_ui.dart'; import '../../../../../core/config/environment.dart'; import '../../../../../core/constants/app_sections.dart'; import '../../../../../widgets/app_layout.dart'; +import '../../../../../widgets/components/feedback.dart'; import '../../../../../widgets/components/filter_bar.dart'; import '../../../../../widgets/components/superport_date_picker.dart'; import '../../../../../widgets/components/superport_table.dart'; import '../../../../../widgets/components/feature_disabled_placeholder.dart'; +import '../../../../../widgets/components/superport_dialog.dart'; +import '../../../../auth/application/auth_service.dart'; +import '../../../../auth/domain/entities/authenticated_user.dart'; import '../../domain/entities/approval_history_record.dart'; import '../../domain/repositories/approval_history_repository.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 '../../../shared/widgets/widgets.dart'; +import '../../../shared/widgets/approver_autocomplete_field.dart'; +import '../../../shared/approver_catalog.dart'; import '../controllers/approval_history_controller.dart'; +import '../widgets/approval_audit_log_table.dart'; +import '../widgets/approval_flow_timeline.dart'; class ApprovalHistoryPage extends StatelessWidget { const ApprovalHistoryPage({super.key}); @@ -64,20 +78,29 @@ class _ApprovalHistoryEnabledPageState late final ApprovalHistoryController _controller; final TextEditingController _searchController = TextEditingController(); final FocusNode _searchFocus = FocusNode(); - final DateFormat _dateTimeFormat = DateFormat('yyyy-MM-dd HH:mm'); + final intl.DateFormat _dateTimeFormat = intl.DateFormat('yyyy-MM-dd HH:mm'); DateTimeRange? _dateRange; String? _lastError; static const _pageSizeOptions = [10, 20, 50]; + static const _auditActionAll = '__all__'; + final TextEditingController _auditActorIdController = TextEditingController(); int? _sortColumnIndex; bool _sortAscending = true; - static const _sortableColumns = {0, 1, 2, 3, 4, 5, 6, 7}; + static const _sortableColumns = {0, 1, 2, 3, 4, 5}; + AuthenticatedUser? _currentUser; + ApprovalHistoryRecord? _selectedRecord; @override void initState() { super.initState(); + final sl = GetIt.I; _controller = ApprovalHistoryController( - repository: GetIt.I(), + repository: sl(), + approvalRepository: sl(), + recallUseCase: sl(), + resubmitUseCase: sl(), )..addListener(_handleUpdate); + _currentUser = sl().session?.user; WidgetsBinding.instance.addPostFrameCallback((_) async { await _controller.fetch(); }); @@ -101,6 +124,7 @@ class _ApprovalHistoryEnabledPageState _controller.dispose(); _searchController.dispose(); _searchFocus.dispose(); + _auditActorIdController.dispose(); super.dispose(); } @@ -213,53 +237,50 @@ class _ApprovalHistoryEnabledPageState ), ], ), - child: ShadCard( - title: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text('이력 목록', style: theme.textTheme.h3), - Text('$totalCount건', style: theme.textTheme.muted), - ], - ), - child: _controller.isLoading - ? const Padding( - padding: EdgeInsets.all(48), - child: Center(child: CircularProgressIndicator()), - ) - : histories.isEmpty - ? Padding( - padding: const EdgeInsets.all(32), - child: Text( - '조건에 맞는 결재 이력이 없습니다.', - style: theme.textTheme.muted, - ), - ) - : _ApprovalHistoryTable( - histories: sortedHistories, - dateFormat: _dateTimeFormat, - query: _controller.query, - pagination: SuperportTablePagination( - currentPage: currentPage, - totalPages: totalPages, - totalItems: totalCount, - pageSize: _controller.pageSize, - pageSizeOptions: _pageSizeOptions, - ), - onPageChange: (page) => _controller.fetch(page: page), - onPageSizeChange: (size) { - _controller.updatePageSize(size); - _controller.fetch(page: 1); - }, - isLoading: _controller.isLoading, - sortableColumns: _sortableColumns, - sortState: _sortColumnIndex == null - ? null - : SuperportTableSortState( - columnIndex: _sortColumnIndex!, - ascending: _sortAscending, - ), - onSortChanged: _handleSortChange, - ), + child: LayoutBuilder( + builder: (context, constraints) { + final isCompact = constraints.maxWidth < 1080; + if (_selectedRecord != null && + !sortedHistories.any( + (record) => record.id == _selectedRecord!.id, + )) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + setState(() { + _selectedRecord = null; + }); + _controller.clearSelection(); + }); + } + final tableCard = _buildHistoryTableCard( + context, + theme, + sortedHistories, + totalCount, + currentPage, + totalPages, + result?.pageSize ?? _controller.pageSize, + ); + final detailCard = _buildDetailCard(theme); + + if (isCompact) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [tableCard, const SizedBox(height: 16), detailCard], + ); + } + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: tableCard), + const SizedBox(width: 16), + SizedBox(width: 360, child: detailCard), + ], + ); + }, ), ); }, @@ -288,6 +309,18 @@ class _ApprovalHistoryEnabledPageState _controller.fetch(page: 1); } + void _clearAuditFilters() { + _auditActorIdController.clear(); + _controller.clearAuditFilters(); + _refreshAuditForSelectedRecord(resetPage: true); + } + + void _handleAuditActorSelected(ApprovalApproverCatalogItem? item) { + final selectedId = item?.id ?? int.tryParse(_auditActorIdController.text); + _controller.updateAuditActor(selectedId); + _refreshAuditForSelectedRecord(resetPage: true); + } + void _handleSortChange(int columnIndex, bool ascending) { setState(() { _sortColumnIndex = columnIndex; @@ -305,31 +338,31 @@ class _ApprovalHistoryEnabledPageState int compare; switch (columnIndex) { case 0: - compare = a.id.compareTo(b.id); + compare = a.approvalNo.toLowerCase().compareTo( + b.approvalNo.toLowerCase(), + ); break; case 1: - compare = a.approvalNo.compareTo(b.approvalNo); - break; - case 2: final left = a.stepOrder ?? 0; final right = b.stepOrder ?? 0; compare = left.compareTo(right); break; - case 3: - compare = a.approver.name.compareTo(b.approver.name); - break; - case 4: - compare = a.action.name.compareTo(b.action.name); - break; - case 5: - compare = (a.fromStatus?.name ?? '').compareTo( - b.fromStatus?.name ?? '', + case 2: + compare = a.approver.name.toLowerCase().compareTo( + b.approver.name.toLowerCase(), ); break; - case 6: - compare = a.toStatus.name.compareTo(b.toStatus.name); + case 3: + compare = a.action.name.toLowerCase().compareTo( + b.action.name.toLowerCase(), + ); break; - case 7: + case 4: + compare = a.toStatus.name.toLowerCase().compareTo( + b.toStatus.name.toLowerCase(), + ); + break; + case 5: compare = a.actionAt.compareTo(b.actionAt); break; default: @@ -352,113 +385,984 @@ class _ApprovalHistoryEnabledPageState return '코멘트'; } } + + Widget _buildHistoryTableCard( + BuildContext context, + ShadThemeData theme, + List histories, + int totalCount, + int currentPage, + int totalPages, + int pageSize, + ) { + final normalizedQuery = _controller.query.trim().toLowerCase(); + final rows = >[]; + for (final record in histories) { + final isSelected = _selectedRecord?.id == record.id; + final approvalNo = record.approvalNo; + final highlight = + normalizedQuery.isNotEmpty && + approvalNo.toLowerCase().contains(normalizedQuery); + final approvalStyle = highlight + ? theme.textTheme.small.copyWith( + fontWeight: FontWeight.w700, + color: theme.colorScheme.foreground, + ) + : theme.textTheme.small; + rows.add([ + _buildTableCell( + theme, + Text(approvalNo, style: approvalStyle), + selected: isSelected, + ), + _buildTableCell( + theme, + _buildStepBadge(theme, record), + selected: isSelected, + alignment: Alignment.centerLeft, + ), + _buildTableCell( + theme, + ApprovalApproverCell( + name: record.approver.name, + employeeNo: record.approver.employeeNo, + ), + selected: isSelected, + ), + _buildTableCell( + theme, + ShadBadge.outline(child: Text(record.action.name)), + selected: isSelected, + ), + _buildTableCell( + theme, + Text(_statusLabel(record), style: theme.textTheme.small), + selected: isSelected, + ), + _buildTableCell( + theme, + Text(_dateTimeFormat.format(record.actionAt.toLocal())), + selected: isSelected, + ), + _buildTableCell( + theme, + ApprovalNoteTooltip(note: record.note), + selected: isSelected, + ), + ]); + } + + final header = const [ + ShadTableCell.header(child: Text('결재번호')), + ShadTableCell.header(child: Text('단계')), + ShadTableCell.header(child: Text('승인자')), + ShadTableCell.header(child: Text('행위')), + ShadTableCell.header(child: Text('변경 상태')), + ShadTableCell.header(child: Text('일시')), + ShadTableCell.header(child: Text('메모')), + ]; + + final pagination = SuperportTablePagination( + currentPage: currentPage, + totalPages: totalPages, + totalItems: totalCount, + pageSize: pageSize, + pageSizeOptions: _pageSizeOptions, + ); + + return ShadCard( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('결재 이력', style: theme.textTheme.h3), + const SizedBox(height: 4), + Text('$totalCount건', style: theme.textTheme.muted), + ], + ), + if (_selectedRecord != null) + ShadButton.ghost( + onPressed: _controller.isLoading ? null : _clearSelection, + child: const Text('선택 해제'), + ), + ], + ), + const SizedBox(height: 16), + if (_controller.isLoading) + const Padding( + padding: EdgeInsets.symmetric(vertical: 40), + child: Center(child: CircularProgressIndicator()), + ) + else if (histories.isEmpty) + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 32, + ), + decoration: BoxDecoration( + color: theme.colorScheme.secondary.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: theme.colorScheme.border), + ), + child: Center( + child: Text( + '조건에 맞는 결재 이력이 없습니다.', + style: theme.textTheme.muted, + ), + ), + ) + else + SuperportTable.fromCells( + header: header, + rows: rows, + rowHeight: 74, + maxHeight: 520, + columnSpanExtent: (index) { + switch (index) { + case 0: + return const FixedTableSpanExtent(150); + case 1: + return const FixedTableSpanExtent(80); + case 2: + return const FixedTableSpanExtent(220); + case 3: + case 4: + return const FixedTableSpanExtent(150); + case 5: + return const FixedTableSpanExtent(160); + default: + return const FixedTableSpanExtent(200); + } + }, + onRowTap: (index) => _handleSelectRecord(histories[index]), + sortableColumns: _sortableColumns, + sortState: _sortColumnIndex == null + ? null + : SuperportTableSortState( + columnIndex: _sortColumnIndex!, + ascending: _sortAscending, + ), + onSortChanged: _handleSortChange, + pagination: pagination, + onPageChange: (page) => _controller.fetch(page: page), + onPageSizeChange: (size) { + _controller.updatePageSize(size); + _controller.fetch(page: 1); + }, + isLoading: _controller.isLoading, + ), + ], + ), + ), + ); + } + + Widget _buildDetailCard(ShadThemeData theme) { + final selectedRecord = _selectedRecord; + final selectedFlow = _controller.selectedFlow; + final auditResult = _controller.auditResult; + final auditLogs = auditResult?.items ?? const []; + final auditPagination = auditResult == null + ? null + : SuperportTablePagination( + currentPage: auditResult.page, + totalPages: auditResult.pageSize == 0 + ? 1 + : (auditResult.total / auditResult.pageSize).ceil().clamp( + 1, + 9999, + ), + totalItems: auditResult.total, + pageSize: _controller.auditPageSize, + pageSizeOptions: _pageSizeOptions, + ); + + return ShadCard( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('결재 상세', style: theme.textTheme.h3), + const SizedBox(height: 4), + Text('회수 · 재상신과 감사 로그 요약', style: theme.textTheme.muted), + ], + ), + if (selectedRecord != null) + ShadButton.ghost( + onPressed: _controller.isPerformingAction + ? null + : _clearSelection, + child: const Text('선택 해제'), + ), + ], + ), + const SizedBox(height: 16), + if (selectedRecord == null) + _buildDetailPlaceholder(theme) + else ...[ + _buildDetailSummary(theme, selectedRecord, selectedFlow), + const SizedBox(height: 16), + if (_controller.isSelectionForbidden) ...[ + _buildForbiddenDetailNotice(theme), + ] else ...[ + _buildActionButtons(theme, selectedFlow), + const SizedBox(height: 16), + _buildTabSelector(theme), + const SizedBox(height: 16), + if (_controller.isLoadingFlow) + const Padding( + padding: EdgeInsets.symmetric(vertical: 40), + child: Center(child: CircularProgressIndicator()), + ) + else if (_controller.activeTab == ApprovalHistoryTab.flow) + _buildFlowTabContent(theme, selectedFlow) + else + _buildAuditTabContent(theme, auditLogs, auditPagination), + ], + ], + ], + ), + ), + ); + } + + Widget _buildForbiddenDetailNotice(ShadThemeData theme) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20), + decoration: BoxDecoration( + color: theme.colorScheme.secondary.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: theme.colorScheme.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '열람 권한이 없습니다', + style: theme.textTheme.p.copyWith( + fontWeight: FontWeight.w600, + color: theme.colorScheme.destructive, + ), + ), + const SizedBox(height: 8), + Text( + '상신자 또는 기결재자만 감사 로그와 상세 내역을 확인할 수 있습니다.', + style: theme.textTheme.small, + ), + const SizedBox(height: 8), + Text( + '필요 시 담당자에게 접근 권한을 요청하거나 다른 결재를 선택하세요.', + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.mutedForeground, + ), + ), + ], + ), + ); + } + + Widget _buildDetailPlaceholder(ShadThemeData theme) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), + decoration: BoxDecoration( + color: theme.colorScheme.secondary.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: theme.colorScheme.border), + ), + child: Text( + '좌측 목록에서 결재 이력을 선택하면 상태 타임라인과 감사 로그가 표시됩니다.', + style: theme.textTheme.muted, + ), + ); + } + + Widget _buildDetailSummary( + ShadThemeData theme, + ApprovalHistoryRecord record, + ApprovalFlow? flow, + ) { + final statusLabel = _statusLabel(record); + final stepLabel = record.stepOrder == null + ? '단계 정보 없음' + : '${record.stepOrder}단계'; + final requester = flow?.requester; + final currentStep = flow?.statusSummary.currentStepOrder; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ApprovalStatusBadge( + label: record.toStatus.name, + colorHex: record.toStatus.color, + ), + const SizedBox(height: 12), + Text('결재번호 ${record.approvalNo}', style: theme.textTheme.small), + const SizedBox(height: 4), + Text( + '$stepLabel · ${record.approver.name}', + style: theme.textTheme.small, + ), + const SizedBox(height: 4), + Text(statusLabel, style: theme.textTheme.muted), + if (requester != null) ...[ + const SizedBox(height: 8), + Text( + '상신자 ${requester.name} (${requester.employeeNo})', + style: theme.textTheme.small, + ), + ], + if (flow != null && currentStep != null) ...[ + const SizedBox(height: 4), + Text( + '현재 진행 단계: $currentStep / ${flow.statusSummary.totalSteps}', + style: theme.textTheme.small, + ), + ], + ], + ); + } + + Widget _buildActionButtons(ShadThemeData theme, ApprovalFlow? flow) { + final canRecall = flow != null && _canRecall(flow); + final canResubmit = flow != null && _canResubmit(flow); + final recallReason = flow == null + ? '결재 정보를 불러오는 중입니다.' + : _recallDisabledReason(flow); + final resubmitReason = flow == null + ? '결재 정보를 불러오는 중입니다.' + : _resubmitDisabledReason(flow); + + final recallNotice = _buildRecallConditionNotice( + theme: theme, + flow: flow, + canRecall: canRecall, + reason: recallReason, + ); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + ShadButton( + onPressed: + flow == null || !canRecall || _controller.isPerformingAction + ? null + : () => _handleRecall(flow), + child: const Text('회수'), + ), + ShadButton.outline( + onPressed: + flow == null || !canResubmit || _controller.isPerformingAction + ? null + : () => _handleResubmit(flow), + child: const Text('재상신'), + ), + ], + ), + if (_controller.isPerformingAction) ...[ + const SizedBox(height: 8), + Text( + '결재 작업을 처리하는 중입니다...', + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.mutedForeground, + ), + ), + ] else ...[ + if (recallNotice != null) ...[ + const SizedBox(height: 8), + recallNotice, + ], + if (!canResubmit && resubmitReason != null) ...[ + SizedBox(height: recallNotice == null ? 8 : 4), + Text( + '재상신 불가: $resubmitReason', + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.mutedForeground, + ), + ), + ], + ], + ], + ); + } + + Widget? _buildRecallConditionNotice({ + required ShadThemeData theme, + required ApprovalFlow? flow, + required bool canRecall, + required String? reason, + }) { + if (flow == null) { + return Row( + children: [ + Icon( + lucide.LucideIcons.info, + size: 16, + color: theme.colorScheme.mutedForeground, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + '회수 조건을 확인하는 중입니다.', + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.mutedForeground, + ), + ), + ), + ], + ); + } + final icon = canRecall + ? lucide.LucideIcons.badgeCheck + : lucide.LucideIcons.shieldAlert; + final color = canRecall + ? theme.colorScheme.primary + : theme.colorScheme.destructive; + final message = canRecall + ? '첫 승인자가 아직 결정을 내리지 않아 회수할 수 있습니다.' + : (reason ?? '회수 조건을 확인할 수 없습니다.'); + return Row( + children: [ + Icon(icon, size: 16, color: color), + const SizedBox(width: 8), + Expanded( + child: Text( + message, + style: theme.textTheme.small.copyWith(color: color), + ), + ), + ], + ); + } + + Widget _buildTabSelector(ShadThemeData theme) { + final activeTab = _controller.activeTab; + return Row( + children: [ + _buildTabButton( + label: '상태 타임라인', + value: ApprovalHistoryTab.flow, + isActive: activeTab == ApprovalHistoryTab.flow, + enabled: true, + ), + const SizedBox(width: 8), + _buildTabButton( + label: '감사 로그', + value: ApprovalHistoryTab.audit, + isActive: activeTab == ApprovalHistoryTab.audit, + enabled: !_controller.isSelectionForbidden, + ), + ], + ); + } + + Widget _buildTabButton({ + required String label, + required ApprovalHistoryTab value, + required bool isActive, + required bool enabled, + }) { + if (isActive) { + return ShadButton(onPressed: null, child: Text(label)); + } + return ShadButton.outline( + onPressed: enabled ? () => _handleTabChange(value) : null, + child: Text(label), + ); + } + + Widget _buildFlowTabContent(ShadThemeData theme, ApprovalFlow? flow) { + if (flow == null) { + return _buildDetailPlaceholder(theme); + } + return ApprovalFlowTimeline(flow: flow, dateFormat: _dateTimeFormat); + } + + Widget _buildAuditTabContent( + ShadThemeData theme, + List logs, + SuperportTablePagination? pagination, + ) { + final record = _selectedRecord; + if (record == null) { + return _buildDetailPlaceholder(theme); + } + final auditRange = _currentAuditRange(); + final actorId = _controller.auditActorId; + final actorText = actorId?.toString() ?? ''; + final currentActionCode = _controller.auditActionCode ?? _auditActionAll; + final actionOptions = _controller.auditActions; + final isLoadingAudit = _controller.isLoadingAudit; + final hasAuditFilters = _controller.hasActiveAuditFilters; + if (_auditActorIdController.text.trim() != actorText) { + _auditActorIdController.value = TextEditingValue(text: actorText); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + AbsorbPointer( + absorbing: isLoadingAudit, + child: SizedBox( + width: 240, + child: ApprovalApproverAutocompleteField( + key: ValueKey(actorId ?? 'all'), + idController: _auditActorIdController, + hintText: '행위자 검색', + onSelected: _handleAuditActorSelected, + ), + ), + ), + SizedBox( + width: 200, + child: ShadSelect( + key: ValueKey(currentActionCode), + initialValue: currentActionCode, + selectedOptionBuilder: (context, value) => + Text(_auditActionLabel(value, actionOptions)), + onChanged: isLoadingAudit + ? null + : (value) { + if (value == null || value == _auditActionAll) { + _controller.updateAuditAction(null); + } else { + _controller.updateAuditAction(value); + } + _refreshAuditForSelectedRecord(resetPage: true); + }, + options: [ + const ShadOption( + value: _auditActionAll, + child: Text('전체 행위'), + ), + ...actionOptions + .where( + (action) => + action.code != null && + action.code!.trim().isNotEmpty, + ) + .map( + (action) => ShadOption( + value: action.code!, + child: Text(action.name), + ), + ), + ], + ), + ), + SizedBox( + width: 240, + child: SuperportDateRangePickerButton( + value: auditRange, + dateFormat: intl.DateFormat('yyyy-MM-dd'), + enabled: !isLoadingAudit, + firstDate: DateTime(DateTime.now().year - 5), + lastDate: DateTime(DateTime.now().year + 1), + initialDateRange: + auditRange ?? + DateTimeRange( + start: DateTime.now().subtract(const Duration(days: 7)), + end: DateTime.now(), + ), + onChanged: (range) { + if (range == null) { + _controller.updateAuditDateRange(null, null); + _refreshAuditForSelectedRecord(resetPage: true); + return; + } + _controller.updateAuditDateRange(range.start, range.end); + _refreshAuditForSelectedRecord(resetPage: true); + }, + ), + ), + if (auditRange != null) + ShadButton.ghost( + onPressed: isLoadingAudit + ? null + : () { + _controller.updateAuditDateRange(null, null); + _refreshAuditForSelectedRecord(resetPage: true); + }, + child: const Text('기간 초기화'), + ), + if (hasAuditFilters) + ShadButton.ghost( + onPressed: isLoadingAudit ? null : _clearAuditFilters, + child: const Text('감사 필터 초기화'), + ), + ], + ), + const SizedBox(height: 16), + ApprovalAuditLogTable( + logs: logs, + dateFormat: _dateTimeFormat, + pagination: pagination, + onPageChange: (page) => _controller.fetchAuditLogs( + approvalId: record.approvalId, + page: page, + ), + onPageSizeChange: (size) { + _controller.updateAuditPageSize(size); + _controller.fetchAuditLogs(approvalId: record.approvalId, page: 1); + }, + isLoading: isLoadingAudit, + ), + ], + ); + } + + ShadTableCell _buildTableCell( + ShadThemeData theme, + Widget child, { + bool selected = false, + Alignment alignment = Alignment.centerLeft, + }) { + return ShadTableCell( + alignment: alignment, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + decoration: BoxDecoration( + color: selected + ? theme.colorScheme.primary.withValues(alpha: 0.08) + : Colors.transparent, + borderRadius: BorderRadius.circular(selected ? 8 : 6), + ), + child: child, + ), + ); + } + + Widget _buildStepBadge(ShadThemeData theme, ApprovalHistoryRecord record) { + if (record.stepOrder == null) { + return Text('-', style: theme.textTheme.muted); + } + return ShadBadge(child: Text('${record.stepOrder}단계')); + } + + String _statusLabel(ApprovalHistoryRecord record) { + final from = record.fromStatus?.name; + if (from == null || from.isEmpty) { + return record.toStatus.name; + } + return '$from → ${record.toStatus.name}'; + } + + Future _handleSelectRecord(ApprovalHistoryRecord record) async { + if (_selectedRecord?.id == record.id && _controller.selectedFlow != null) { + return; + } + setState(() { + _selectedRecord = record; + }); + try { + await _controller.loadApprovalFlow(record.approvalId); + if (_controller.isSelectionForbidden) { + return; + } + if (_controller.activeTab == ApprovalHistoryTab.audit) { + await _controller.fetchAuditLogs(approvalId: record.approvalId); + } + } catch (_) { + // 오류 메시지는 컨트롤러 리스너에서 처리한다. + } + } + + void _handleTabChange(ApprovalHistoryTab tab) { + if (_controller.activeTab == tab) { + return; + } + _controller.updateActiveTab(tab); + final record = _selectedRecord; + if (tab == ApprovalHistoryTab.audit && record != null) { + if (_controller.isSelectionForbidden) { + return; + } + _controller.fetchAuditLogs(approvalId: record.approvalId); + } + } + + void _refreshAuditForSelectedRecord({bool resetPage = false}) { + final record = _selectedRecord; + if (record == null) { + return; + } + if (_controller.isSelectionForbidden) { + return; + } + final page = resetPage ? 1 : _controller.auditResult?.page ?? 1; + _controller.fetchAuditLogs(approvalId: record.approvalId, page: page); + } + + DateTimeRange? _currentAuditRange() { + final from = _controller.auditFrom; + final to = _controller.auditTo; + if (from == null || to == null) { + return null; + } + return DateTimeRange(start: from, end: to); + } + + String _auditActionLabel(String value, List actions) { + if (value == _auditActionAll) { + return '전체 행위'; + } + for (final action in actions) { + if (action.code == value) { + return action.name; + } + } + return '전체 행위'; + } + + Future _handleRecall(ApprovalFlow flow) async { + final user = _currentUser; + if (user == null) { + SuperportToast.error(context, '현재 사용자 정보를 확인할 수 없습니다.'); + return; + } + final approvalId = flow.id; + if (approvalId == null) { + SuperportToast.error(context, '결재 식별자를 확인할 수 없습니다.'); + return; + } + final note = await _promptActionNote( + title: '결재 회수', + confirmLabel: '회수', + description: '회수 사유를 입력하세요. 입력하지 않아도 회수를 진행할 수 있습니다.', + ); + final refreshed = await _controller.refreshFlow(approvalId); + if (!mounted) { + return; + } + if (refreshed == null) { + SuperportToast.error(context, '결재 상세를 새로고침하지 못했습니다. 다시 시도해 주세요.'); + return; + } + final latestFlow = refreshed; + final sanitizedNote = note?.isEmpty == true ? null : note; + final transactionUpdatedAt = latestFlow.transactionUpdatedAt; + if (transactionUpdatedAt == null) { + SuperportToast.error( + context, + '연동 전표 변경 시각을 확인할 수 없습니다. 화면을 새로고침한 뒤 다시 시도하세요.', + ); + return; + } + final input = ApprovalRecallInput( + approvalId: approvalId, + actorId: user.id, + note: sanitizedNote, + expectedUpdatedAt: latestFlow.approval.updatedAt, + transactionExpectedUpdatedAt: transactionUpdatedAt, + ); + final result = await _controller.recallApproval(input); + if (!mounted) { + return; + } + if (result != null) { + SuperportToast.success( + context, + '결재(${latestFlow.approvalNo}) 회수를 완료했습니다.', + ); + } + } + + Future _handleResubmit(ApprovalFlow flow) async { + final user = _currentUser; + if (user == null) { + SuperportToast.error(context, '현재 사용자 정보를 확인할 수 없습니다.'); + return; + } + final approvalId = flow.id; + if (approvalId == null) { + SuperportToast.error(context, '결재 식별자를 확인할 수 없습니다.'); + return; + } + final note = await _promptActionNote( + title: '결재 재상신', + confirmLabel: '재상신', + description: '재상신 시 전달할 메시지를 입력하세요. 입력하지 않아도 재상신됩니다.', + ); + final refreshed = await _controller.refreshFlow(approvalId); + if (!mounted) { + return; + } + if (refreshed == null) { + SuperportToast.error(context, '결재 상세를 새로고침하지 못했습니다. 다시 시도해 주세요.'); + return; + } + final latestFlow = refreshed; + final sanitizedNote = note?.isEmpty == true ? null : note; + final transactionUpdatedAt = latestFlow.transactionUpdatedAt; + if (transactionUpdatedAt == null) { + SuperportToast.error( + context, + '연동 전표 변경 시각을 확인할 수 없습니다. 화면을 새로고침한 뒤 다시 시도하세요.', + ); + return; + } + final steps = latestFlow.steps + .map( + (step) => ApprovalStepAssignmentItem( + stepOrder: step.stepOrder, + approverId: step.approver.id, + note: step.note, + ), + ) + .toList(growable: false); + final submission = ApprovalSubmissionInput( + transactionId: latestFlow.transactionId, + statusId: latestFlow.status.id, + requesterId: latestFlow.requester.id, + finalApproverId: latestFlow.finalApprover?.id, + note: latestFlow.note, + steps: steps, + ); + final input = ApprovalResubmissionInput( + approvalId: approvalId, + actorId: user.id, + submission: submission, + note: sanitizedNote, + expectedUpdatedAt: latestFlow.approval.updatedAt, + transactionExpectedUpdatedAt: transactionUpdatedAt, + ); + final result = await _controller.resubmitApproval(input); + if (!mounted) { + return; + } + if (result != null) { + SuperportToast.success( + context, + '결재(${latestFlow.approvalNo}) 재상신을 완료했습니다.', + ); + } + } + + bool _canRecall(ApprovalFlow flow) { + if (flow.status.isTerminal) { + return false; + } + if (flow.steps.isEmpty) { + return false; + } + final first = flow.steps.first; + return first.decidedAt == null; + } + + bool _canResubmit(ApprovalFlow flow) { + if (!flow.status.isTerminal) { + return false; + } + final statusName = flow.status.name.toLowerCase(); + return statusName.contains('반려') || statusName.contains('reject'); + } + + String? _recallDisabledReason(ApprovalFlow flow) { + if (flow.status.isTerminal) { + return '결재가 종료되었습니다.'; + } + if (flow.steps.isEmpty) { + return '결재 단계 정보가 없습니다.'; + } + if (flow.steps.first.decidedAt != null) { + return '첫 승인자가 이미 결정을 내려 회수할 수 없습니다.'; + } + return null; + } + + String? _resubmitDisabledReason(ApprovalFlow flow) { + if (!flow.status.isTerminal) { + return '결재가 아직 진행 중입니다.'; + } + final statusName = flow.status.name.toLowerCase(); + if (!(statusName.contains('반려') || statusName.contains('reject'))) { + return '반려 상태에서만 재상신할 수 있습니다.'; + } + return null; + } + + Future _promptActionNote({ + required String title, + required String confirmLabel, + required String description, + }) async { + final theme = ShadTheme.of(context); + final controller = TextEditingController(); + String? result; + await showSuperportDialog( + context: context, + title: title, + description: description, + headerActions: [ + if (_controller.selectedFlow != null) + ApprovalStatusBadge( + label: _controller.selectedFlow!.status.name, + colorHex: _controller.selectedFlow!.status.color, + ), + ], + actions: [ + ShadButton.ghost( + onPressed: () => + Navigator.of(context, rootNavigator: true).maybePop(), + child: const Text('취소'), + ), + ShadButton( + onPressed: () { + result = controller.text.trim(); + Navigator.of(context, rootNavigator: true).maybePop(); + }, + child: Text(confirmLabel), + ), + ], + body: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('사유는 선택 입력입니다. 비워두면 전달되지 않습니다.', style: theme.textTheme.muted), + const SizedBox(height: 12), + ShadInput( + controller: controller, + maxLines: 4, + placeholder: const Text('사유 입력'), + ), + ], + ), + ); + controller.dispose(); + if (result != null && result!.isEmpty) { + return null; + } + return result; + } + + void _clearSelection() { + _controller.clearSelection(); + if (_selectedRecord == null) { + return; + } + setState(() { + _selectedRecord = null; + }); + } } /// 결재 이력 데이터를 표 형태로 렌더링하는 위젯. -class _ApprovalHistoryTable extends StatelessWidget { - const _ApprovalHistoryTable({ - required this.histories, - required this.dateFormat, - required this.query, - required this.pagination, - required this.onPageChange, - required this.onPageSizeChange, - required this.isLoading, - required this.sortableColumns, - required this.sortState, - required this.onSortChanged, - }); - - final List histories; - final DateFormat dateFormat; - final String query; - final SuperportTablePagination pagination; - final ValueChanged onPageChange; - final ValueChanged onPageSizeChange; - final bool isLoading; - final Set sortableColumns; - final SuperportTableSortState? sortState; - final void Function(int columnIndex, bool ascending) onSortChanged; - - @override - Widget build(BuildContext context) { - final theme = ShadTheme.of(context); - final normalizedQuery = query.trim().toLowerCase(); - - final columns = const [ - Text('ID'), - Text('결재번호'), - Text('단계순서'), - Text('승인자'), - Text('행위'), - Text('변경전 상태'), - Text('변경후 상태'), - Text('작업일시'), - Text('비고'), - ]; - - final rows = histories.map((history) { - final isHighlighted = - normalizedQuery.isNotEmpty && - history.approvalNo.toLowerCase().contains(normalizedQuery); - final highlightStyle = theme.textTheme.small.copyWith( - fontWeight: FontWeight.w600, - color: theme.colorScheme.foreground, - ); - final noteText = history.note?.trim(); - final noteContent = noteText?.isNotEmpty == true ? noteText : null; - final subLabelStyle = theme.textTheme.muted.copyWith( - fontSize: (theme.textTheme.muted.fontSize ?? 14) - 1, - ); - - return [ - Text(history.id.toString()), - Text(history.approvalNo, style: isHighlighted ? highlightStyle : null), - Text(history.stepOrder == null ? '-' : history.stepOrder.toString()), - Text(history.approver.name), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text(history.action.name), - if (noteContent != null) Text(noteContent, style: subLabelStyle), - ], - ), - Text(history.fromStatus?.name ?? '-'), - Text(history.toStatus.name), - Text(dateFormat.format(history.actionAt.toLocal())), - Text(noteContent ?? '-'), - ]; - }).toList(); - - return SuperportTable( - columns: columns, - rows: rows, - rowHeight: 64, - maxHeight: 520, - columnSpanExtent: (index) { - switch (index) { - case 1: - return const FixedTableSpanExtent(180); - case 2: - case 4: - return const FixedTableSpanExtent(120); - case 5: - case 6: - return const FixedTableSpanExtent(150); - case 7: - return const FixedTableSpanExtent(180); - default: - return const FixedTableSpanExtent(110); - } - }, - pagination: pagination, - onPageChange: onPageChange, - onPageSizeChange: onPageSizeChange, - isLoading: isLoading, - sortableColumns: sortableColumns, - sortState: sortState, - onSortChanged: onSortChanged, - ); - } -} diff --git a/lib/features/approvals/history/presentation/widgets/approval_audit_log_table.dart b/lib/features/approvals/history/presentation/widgets/approval_audit_log_table.dart new file mode 100644 index 0000000..24cca01 --- /dev/null +++ b/lib/features/approvals/history/presentation/widgets/approval_audit_log_table.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import '../../../../approvals/domain/entities/approval.dart'; +import '../../../shared/widgets/approval_ui_helpers.dart'; +import '../../../../../widgets/components/superport_table.dart'; + +/// 결재 감사 로그를 표 형태로 렌더링하는 위젯. +class ApprovalAuditLogTable extends StatelessWidget { + const ApprovalAuditLogTable({ + super.key, + required this.logs, + required this.dateFormat, + this.pagination, + this.onPageChange, + this.onPageSizeChange, + this.isLoading = false, + }); + + /// 감사 로그 목록. + final List logs; + + /// 날짜 포맷터. + final DateFormat dateFormat; + + /// 페이지네이션 상태. + final SuperportTablePagination? pagination; + + /// 페이지 변경 콜백. + final ValueChanged? onPageChange; + + /// 페이지 크기 변경 콜백. + final ValueChanged? onPageSizeChange; + + /// 로딩 여부. + final bool isLoading; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + if (logs.isEmpty) { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 32), + decoration: BoxDecoration( + color: theme.colorScheme.secondary.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: theme.colorScheme.border), + ), + child: Center( + child: Text('선택한 결재의 감사 로그가 없습니다.', style: theme.textTheme.muted), + ), + ); + } + + return SuperportTable( + columns: const [ + Text('행위'), + Text('변경 상태'), + Text('승인자'), + Text('메모'), + Text('일시'), + ], + rows: logs.map((log) { + final statusLabel = _buildStatusLabel(log); + final timestamp = dateFormat.format(log.actionAt.toLocal()); + return [ + ShadBadge.outline(child: Text(log.action.name)), + ApprovalStatusBadge(label: statusLabel, colorHex: log.toStatus.color), + ApprovalApproverCell( + name: log.approver.name, + employeeNo: log.approver.employeeNo, + ), + ApprovalNoteTooltip(note: log.note), + Text(timestamp), + ]; + }).toList(), + rowHeight: 68, + maxHeight: 420, + columnSpanExtent: (index) { + switch (index) { + case 0: + return const FixedTableSpanExtent(120); + case 2: + return const FixedTableSpanExtent(220); + case 3: + return const FixedTableSpanExtent(220); + case 4: + return const FixedTableSpanExtent(160); + default: + return const FixedTableSpanExtent(140); + } + }, + pagination: pagination, + onPageChange: onPageChange, + onPageSizeChange: onPageSizeChange, + isLoading: isLoading, + ); + } + + String _buildStatusLabel(ApprovalHistory log) { + final from = log.fromStatus?.name ?? '시작'; + final to = log.toStatus.name; + return '$from → $to'; + } +} diff --git a/lib/features/approvals/history/presentation/widgets/approval_flow_timeline.dart b/lib/features/approvals/history/presentation/widgets/approval_flow_timeline.dart new file mode 100644 index 0000000..99c50f0 --- /dev/null +++ b/lib/features/approvals/history/presentation/widgets/approval_flow_timeline.dart @@ -0,0 +1,216 @@ +import 'package:flutter/material.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import '../../../../approvals/domain/entities/approval.dart'; +import '../../../../approvals/domain/entities/approval_flow.dart'; +import '../../../shared/widgets/approval_ui_helpers.dart'; + +/// 결재 흐름의 상태 변화를 타임라인으로 표현하는 위젯. +class ApprovalFlowTimeline extends StatelessWidget { + const ApprovalFlowTimeline({ + super.key, + required this.flow, + required this.dateFormat, + }); + + /// 표시할 결재 흐름. + final ApprovalFlow flow; + + /// 일시 포맷터. + final DateFormat dateFormat; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + final histories = List.from(flow.histories) + ..sort((a, b) => a.actionAt.compareTo(b.actionAt)); + final summary = flow.statusSummary; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSummary(theme, summary), + const SizedBox(height: 16), + if (histories.isEmpty) + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 20), + decoration: BoxDecoration( + color: theme.colorScheme.secondary.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(12), + ), + child: Text('결재 상태 변경 이력이 없습니다.', style: theme.textTheme.muted), + ) + else + Column( + children: [ + for (var index = 0; index < histories.length; index++) + _TimelineEntry( + history: histories[index], + isFirst: index == 0, + isLast: index == histories.length - 1, + dateFormat: dateFormat, + ), + ], + ), + ], + ); + } + + Widget _buildSummary(ShadThemeData theme, ApprovalFlowStatusSummary summary) { + final requester = flow.requester; + final finalApprover = flow.finalApprover; + final status = flow.status; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + ApprovalStatusBadge(label: status.name, colorHex: status.color), + const SizedBox(width: 12), + Text( + '총 ${summary.totalSteps}단계 · 완료 ${summary.completedSteps} · 대기 ${summary.pendingSteps}', + style: theme.textTheme.small, + ), + ], + ), + const SizedBox(height: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '상신자', + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.mutedForeground, + ), + ), + const SizedBox(height: 4), + Text( + '${requester.name} (${requester.employeeNo})', + style: theme.textTheme.p, + ), + if (finalApprover != null) ...[ + const SizedBox(height: 12), + Text( + '최종 승인자', + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.mutedForeground, + ), + ), + const SizedBox(height: 4), + Text( + '${finalApprover.name} (${finalApprover.employeeNo})', + style: theme.textTheme.p, + ), + ], + const SizedBox(height: 12), + Text( + '결재번호 ${flow.approvalNo}', + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.mutedForeground, + ), + ), + ], + ), + ], + ); + } +} + +class _TimelineEntry extends StatelessWidget { + const _TimelineEntry({ + required this.history, + required this.isFirst, + required this.isLast, + required this.dateFormat, + }); + + final ApprovalHistory history; + final bool isFirst; + final bool isLast; + final DateFormat dateFormat; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + final fromStatus = history.fromStatus?.name ?? '시작'; + final toStatus = history.toStatus.name; + final timestamp = dateFormat.format(history.actionAt.toLocal()); + + return Padding( + padding: EdgeInsets.only(top: isFirst ? 0 : 16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildTimelineIndicator(theme), + const SizedBox(width: 12), + Expanded( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + decoration: BoxDecoration( + color: theme.colorScheme.secondary.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: theme.colorScheme.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + history.action.name, + style: theme.textTheme.small.copyWith( + fontWeight: FontWeight.w700, + ), + ), + Text(timestamp, style: theme.textTheme.muted), + ], + ), + const SizedBox(height: 8), + Text('$fromStatus → $toStatus', style: theme.textTheme.small), + const SizedBox(height: 12), + ApprovalApproverCell( + name: history.approver.name, + employeeNo: history.approver.employeeNo, + ), + if (history.note?.trim().isNotEmpty == true) ...[ + const SizedBox(height: 12), + ApprovalNoteTooltip(note: history.note), + ], + ], + ), + ), + ), + ], + ), + ); + } + + Widget _buildTimelineIndicator(ShadThemeData theme) { + final primary = theme.colorScheme.primary; + return SizedBox( + width: 20, + child: Column( + children: [ + Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: primary, + borderRadius: BorderRadius.circular(12), + ), + ), + if (!isLast) + Container( + width: 2, + height: 40, + margin: const EdgeInsets.only(top: 4), + decoration: BoxDecoration(color: primary.withValues(alpha: 0.4)), + ), + ], + ), + ); + } +} diff --git a/lib/features/approvals/presentation/controllers/approval_controller.dart b/lib/features/approvals/presentation/controllers/approval_controller.dart index 21e023b..bc2495e 100644 --- a/lib/features/approvals/presentation/controllers/approval_controller.dart +++ b/lib/features/approvals/presentation/controllers/approval_controller.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:superport_v2/core/network/failure.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; @@ -6,13 +8,18 @@ import 'package:superport_v2/core/common/utils/pagination_utils.dart'; import '../../../inventory/lookups/domain/entities/lookup_item.dart'; import '../../../inventory/lookups/domain/repositories/inventory_lookup_repository.dart'; import '../../domain/entities/approval.dart'; +import '../../domain/entities/approval_draft.dart'; import '../../domain/entities/approval_proceed_status.dart'; import '../../domain/entities/approval_template.dart'; import '../../domain/repositories/approval_repository.dart'; import '../../domain/repositories/approval_template_repository.dart'; +import '../../domain/usecases/get_approval_draft_use_case.dart'; +import '../../domain/usecases/list_approval_drafts_use_case.dart'; +import '../../domain/usecases/save_approval_draft_use_case.dart'; enum ApprovalStatusFilter { all, + draft, pending, inProgress, onHold, @@ -29,6 +36,7 @@ const Map> _actionAliases = { }; const Map _defaultStatusCodes = { + ApprovalStatusFilter.draft: 'draft', ApprovalStatusFilter.pending: 'pending', ApprovalStatusFilter.inProgress: 'in_progress', ApprovalStatusFilter.onHold: 'on_hold', @@ -36,6 +44,12 @@ const Map _defaultStatusCodes = { ApprovalStatusFilter.rejected: 'rejected', }; +const List _pendingFallbackStatusCodes = [ + 'draft', + 'submitted', + 'in_progress', +]; + /// 결재 목록 및 상세 화면 상태 컨트롤러 /// /// - 목록 조회/필터 상태와 선택된 결재 상세 데이터를 관리한다. @@ -45,13 +59,22 @@ class ApprovalController extends ChangeNotifier { required ApprovalRepository approvalRepository, required ApprovalTemplateRepository templateRepository, InventoryLookupRepository? lookupRepository, + SaveApprovalDraftUseCase? saveDraftUseCase, + GetApprovalDraftUseCase? getDraftUseCase, + ListApprovalDraftsUseCase? listDraftsUseCase, }) : _repository = approvalRepository, _templateRepository = templateRepository, - _lookupRepository = lookupRepository; + _lookupRepository = lookupRepository, + _saveDraftUseCase = saveDraftUseCase, + _getDraftUseCase = getDraftUseCase, + _listDraftsUseCase = listDraftsUseCase; final ApprovalRepository _repository; final ApprovalTemplateRepository _templateRepository; final InventoryLookupRepository? _lookupRepository; + final SaveApprovalDraftUseCase? _saveDraftUseCase; + final GetApprovalDraftUseCase? _getDraftUseCase; + final ListApprovalDraftsUseCase? _listDraftsUseCase; PaginatedResult? _result; Approval? _selected; @@ -65,6 +88,7 @@ class ApprovalController extends ChangeNotifier { bool _isApplyingTemplate = false; int? _applyingTemplateId; ApprovalProceedStatus? _proceedStatus; + ApprovalSubmissionInput? _submissionDraft; String? _errorMessage; ApprovalStatusFilter _statusFilter = ApprovalStatusFilter.all; int? _transactionIdFilter; @@ -111,6 +135,9 @@ class ApprovalController extends ChangeNotifier { return reason; } + ApprovalSubmissionInput? get submissionDraft => _submissionDraft; + bool get hasSubmissionDraft => _submissionDraft != null; + List get approvalStatusOptions => _statusOptions; int? get defaultApprovalStatusId { @@ -163,12 +190,15 @@ class ApprovalController extends ChangeNotifier { resolvedPage = page; } final statusId = _statusIdFor(_statusFilter); + final statusCodes = _statusCodesFor(_statusFilter); final response = await _repository.list( page: resolvedPage, pageSize: _result?.pageSize ?? 20, transactionId: _transactionIdFilter, approvalStatusId: statusId, requestedById: _requestedById, + statusCodes: statusCodes.isEmpty ? null : statusCodes, + includePending: _statusFilter == ApprovalStatusFilter.all, includeSteps: false, includeHistories: false, ); @@ -255,7 +285,7 @@ class ApprovalController extends ChangeNotifier { String statusLabel(ApprovalStatusFilter filter) { if (filter == ApprovalStatusFilter.all) { - return '전체 상태'; + return '전체 상태 (임시저장·진행 포함)'; } final code = _statusCodeFor(filter); if (code != null) { @@ -271,6 +301,7 @@ class ApprovalController extends ChangeNotifier { ApprovalStatusFilter.onHold => '보류', ApprovalStatusFilter.approved => '승인완료', ApprovalStatusFilter.rejected => '반려', + ApprovalStatusFilter.draft => '임시저장', ApprovalStatusFilter.all => '전체 상태', }; } @@ -295,6 +326,22 @@ class ApprovalController extends ChangeNotifier { return lookup?.id; } + List _statusCodesFor(ApprovalStatusFilter filter) { + if (filter == ApprovalStatusFilter.all) { + return const []; + } + final code = _statusCodeFor(filter); + if (filter == ApprovalStatusFilter.pending) { + if (code == null || code.toLowerCase() == 'pending') { + return List.unmodifiable(_pendingFallbackStatusCodes); + } + } + if (code == null || code.isEmpty) { + return const []; + } + return List.unmodifiable([code]); + } + /// 활성화된 결재 템플릿 목록을 조회해 캐싱한다. /// /// 템플릿이 비어 있거나 [force]가 `true`이면 API를 다시 호출한다. @@ -362,6 +409,141 @@ class ApprovalController extends ChangeNotifier { notifyListeners(); } + /// 결재 상신 초안을 보관한다. + void cacheSubmissionDraft(ApprovalSubmissionInput draft) { + _submissionDraft = draft; + notifyListeners(); + _persistSubmissionDraft(draft); + } + + /// 저장된 결재 상신 초안을 반환하고 초기화한다. + ApprovalSubmissionInput? consumeSubmissionDraft() { + final draft = _submissionDraft; + if (draft == null) { + return null; + } + _submissionDraft = null; + notifyListeners(); + return draft; + } + + /// 결재 상신 초안을 초기화한다. + void clearSubmissionDraft() { + if (_submissionDraft == null) { + return; + } + _submissionDraft = null; + notifyListeners(); + } + + Future restoreSubmissionDraft({ + required int requesterId, + int? transactionId, + }) async { + final listUseCase = _listDraftsUseCase; + final getUseCase = _getDraftUseCase; + if (listUseCase == null || getUseCase == null) { + return null; + } + try { + final filter = ApprovalDraftListFilter( + requesterId: requesterId, + transactionId: transactionId, + pageSize: 10, + ); + final result = await listUseCase.call(filter); + if (result.items.isEmpty) { + return null; + } + final sessionKey = _submissionSessionKey(requesterId); + final summary = result.items.firstWhere( + (item) => item.sessionKey == sessionKey, + orElse: () => result.items.first, + ); + final detail = await getUseCase.call( + id: summary.id, + requesterId: requesterId, + ); + if (detail == null) { + return null; + } + final submission = detail.toSubmissionInput( + defaultStatusId: _defaultSubmissionStatusId(), + transactionIdOverride: transactionId ?? detail.transactionId, + ); + _submissionDraft = submission; + notifyListeners(); + return submission; + } catch (error, stackTrace) { + debugPrint('[ApprovalController] 초안 복구 실패: $error\n$stackTrace'); + return null; + } + } + + void _persistSubmissionDraft(ApprovalSubmissionInput draft) { + final useCase = _saveDraftUseCase; + if (useCase == null) { + return; + } + if (draft.steps.isEmpty) { + return; + } + final input = _buildSubmissionDraftInput(draft); + if (!input.hasSteps) { + return; + } + unawaited( + Future(() async { + try { + await useCase.call(input); + } catch (error, stackTrace) { + debugPrint('[ApprovalController] 초안 저장 실패: $error\n$stackTrace'); + } + }), + ); + } + + ApprovalDraftSaveInput _buildSubmissionDraftInput( + ApprovalSubmissionInput draft, + ) { + final steps = draft.steps + .map( + (step) => ApprovalDraftStep( + stepOrder: step.stepOrder, + approverId: step.approverId, + note: step.note, + ), + ) + .toList(growable: false); + return ApprovalDraftSaveInput( + requesterId: draft.requesterId, + transactionId: draft.transactionId, + templateId: draft.templateId, + title: draft.title, + summary: draft.summary, + note: draft.note, + metadata: draft.metadata, + sessionKey: _submissionSessionKey(draft.requesterId), + statusId: draft.statusId, + steps: steps, + ); + } + + int? _defaultSubmissionStatusId() { + final pendingId = _statusIdFor(ApprovalStatusFilter.pending); + if (pendingId != null && pendingId > 0) { + return pendingId; + } + final draftId = _statusIdFor(ApprovalStatusFilter.draft); + if (draftId != null && draftId > 0) { + return draftId; + } + return null; + } + + String _submissionSessionKey(int requesterId) => + 'approval_submission_$requesterId'; + /// 결재를 생성하고 목록/상세 상태를 최신화한다. Future createApproval(ApprovalCreateInput input) async { _setSubmitting(true); diff --git a/lib/features/approvals/presentation/pages/approval_page.dart b/lib/features/approvals/presentation/pages/approval_page.dart index a6ab292..92e0231 100644 --- a/lib/features/approvals/presentation/pages/approval_page.dart +++ b/lib/features/approvals/presentation/pages/approval_page.dart @@ -19,6 +19,9 @@ import '../../domain/entities/approval.dart'; import '../../domain/entities/approval_template.dart'; import '../../domain/repositories/approval_repository.dart'; import '../../domain/repositories/approval_template_repository.dart'; +import '../../domain/usecases/get_approval_draft_use_case.dart'; +import '../../domain/usecases/list_approval_drafts_use_case.dart'; +import '../../domain/usecases/save_approval_draft_use_case.dart'; import '../../../inventory/lookups/domain/repositories/inventory_lookup_repository.dart'; import '../../../inventory/shared/widgets/employee_autocomplete_field.dart'; import '../controllers/approval_controller.dart'; @@ -98,6 +101,15 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> { lookupRepository: GetIt.I.isRegistered() ? GetIt.I() : null, + saveDraftUseCase: GetIt.I.isRegistered() + ? GetIt.I() + : null, + getDraftUseCase: GetIt.I.isRegistered() + ? GetIt.I() + : null, + listDraftsUseCase: GetIt.I.isRegistered() + ? GetIt.I() + : null, )..addListener(_handleControllerUpdate); WidgetsBinding.instance.addPostFrameCallback((_) async { await Future.wait([ @@ -307,24 +319,28 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> { ), SizedBox( width: 200, - child: ShadSelect( - key: ValueKey(_controller.statusFilter), - initialValue: _controller.statusFilter, - selectedOptionBuilder: (context, value) => - Text(_statusLabel(value)), - onChanged: (value) { - if (value == null) return; - _controller.updateStatusFilter(value); - _controller.fetch(page: 1); - }, - options: ApprovalStatusFilter.values - .map( - (filter) => ShadOption( - value: filter, - child: Text(_statusLabel(filter)), - ), - ) - .toList(), + child: Tooltip( + message: '전체 상태 선택 시 임시저장·상신·진행중 결재까지 함께 조회합니다.', + waitDuration: const Duration(milliseconds: 200), + child: ShadSelect( + key: ValueKey(_controller.statusFilter), + initialValue: _controller.statusFilter, + selectedOptionBuilder: (context, value) => + Text(_statusLabel(value)), + onChanged: (value) { + if (value == null) return; + _controller.updateStatusFilter(value); + _controller.fetch(page: 1); + }, + options: ApprovalStatusFilter.values + .map( + (filter) => ShadOption( + value: filter, + child: Text(_statusLabel(filter)), + ), + ) + .toList(), + ), ), ), ], @@ -875,6 +891,11 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> { style: shadTheme.textTheme.small, ), const SizedBox(height: 16), + Text( + _dialogDescription(type), + style: shadTheme.textTheme.muted, + ), + const SizedBox(height: 12), Text('비고', style: shadTheme.textTheme.small), const SizedBox(height: 8), ShadTextarea( @@ -950,11 +971,11 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> { String _dialogTitle(ApprovalStepActionType type) { switch (type) { case ApprovalStepActionType.approve: - return '단계 승인'; + return '결재 단계 승인'; case ApprovalStepActionType.reject: - return '단계 반려'; + return '결재 단계 반려'; case ApprovalStepActionType.comment: - return '코멘트 등록'; + return '결재 단계 코멘트'; } } @@ -979,6 +1000,17 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> { return '코멘트를 등록했습니다.'; } } + + String _dialogDescription(ApprovalStepActionType type) { + switch (type) { + case ApprovalStepActionType.approve: + return '승인하면 다음 단계로 진행합니다. 필요 시 비고를 남길 수 있습니다.'; + case ApprovalStepActionType.reject: + return '반려 사유를 입력해 단계를 반려합니다. 비고는 선택 사항입니다.'; + case ApprovalStepActionType.comment: + return '코멘트를 등록하면 결재 참여자에게 공유됩니다. 비고 입력이 필요합니다.'; + } + } } class _ApprovalTable extends StatelessWidget { diff --git a/lib/features/approvals/request/presentation/controllers/approval_request_controller.dart b/lib/features/approvals/request/presentation/controllers/approval_request_controller.dart new file mode 100644 index 0000000..7ca9bac --- /dev/null +++ b/lib/features/approvals/request/presentation/controllers/approval_request_controller.dart @@ -0,0 +1,538 @@ +import 'package:flutter/foundation.dart'; + +import '../../../../approvals/domain/entities/approval.dart'; +import '../../../../approvals/domain/entities/approval_flow.dart'; +import '../../../../approvals/domain/entities/approval_template.dart'; +import '../../../../approvals/domain/repositories/approval_template_repository.dart'; +import '../../../../approvals/domain/usecases/apply_approval_template_use_case.dart'; +import '../../../../approvals/domain/usecases/save_approval_template_use_case.dart'; +import '../../../../inventory/transactions/domain/entities/stock_transaction_input.dart'; + +/// 결재 요청 화면에서 사용하는 참가자 요약 정보. +/// +/// - 상신자(requester)와 승인자(approver)에 공통으로 적용한다. +class ApprovalRequestParticipant { + const ApprovalRequestParticipant({ + required this.id, + required this.name, + required this.employeeNo, + }); + + final int id; + final String name; + final String employeeNo; + + /// [ApprovalRequester]로 변환한다. + ApprovalRequester toRequester() { + return ApprovalRequester(id: id, employeeNo: employeeNo, name: name); + } + + /// [ApprovalApprover]로 변환한다. + ApprovalApprover toApprover() { + return ApprovalApprover(id: id, employeeNo: employeeNo, name: name); + } +} + +/// 결재 요청 단계 상태를 표현한다. +class ApprovalRequestStep { + const ApprovalRequestStep({ + required this.stepOrder, + required this.approver, + this.note, + }); + + final int stepOrder; + final ApprovalRequestParticipant approver; + final String? note; + + int get approverId => approver.id; + + ApprovalRequestStep copyWith({ + int? stepOrder, + ApprovalRequestParticipant? approver, + String? note, + }) { + return ApprovalRequestStep( + stepOrder: stepOrder ?? this.stepOrder, + approver: approver ?? this.approver, + note: note ?? this.note, + ); + } + + /// 도메인 계층에서 사용하는 [ApprovalStepAssignmentItem]으로 변환한다. + ApprovalStepAssignmentItem toAssignmentItem() { + return ApprovalStepAssignmentItem( + stepOrder: stepOrder, + approverId: approver.id, + note: note, + ); + } +} + +/// 결재 템플릿 버전 정보를 보관한다. +class ApprovalTemplateSnapshot { + const ApprovalTemplateSnapshot({ + required this.templateId, + required this.updatedAt, + }); + + final int templateId; + final DateTime? updatedAt; + + ApprovalTemplateSnapshot copyWith({int? templateId, DateTime? updatedAt}) { + return ApprovalTemplateSnapshot( + templateId: templateId ?? this.templateId, + updatedAt: updatedAt ?? this.updatedAt, + ); + } +} + +/// 결재 요청 상태를 관리하고 검증/전송 모델로 변환하는 컨트롤러. +/// +/// - 최대 98단계까지 결재 단계를 추가할 수 있으며, 승인자 중복을 방지한다. +/// - 마지막 단계 승인자가 최종 승인자로 자동 바인딩된다. +/// - 템플릿을 적용/변경할 때 버전 정보를 기록해 추후 비교에 활용한다. +class ApprovalRequestController extends ChangeNotifier { + ApprovalRequestController({ + int maxSteps = 98, + ApprovalTemplateRepository? templateRepository, + SaveApprovalTemplateUseCase? saveTemplateUseCase, + ApplyApprovalTemplateUseCase? applyTemplateUseCase, + }) : assert(maxSteps > 0, 'maxSteps는 1 이상이어야 합니다.'), + _maxSteps = maxSteps, + _templateRepository = templateRepository, + _saveTemplateUseCase = saveTemplateUseCase, + _applyTemplateUseCase = applyTemplateUseCase; + + static const int defaultMaxSteps = 98; + + final int _maxSteps; + final ApprovalTemplateRepository? _templateRepository; + final SaveApprovalTemplateUseCase? _saveTemplateUseCase; + final ApplyApprovalTemplateUseCase? _applyTemplateUseCase; + + ApprovalRequestParticipant? _requester; + final List _steps = []; + ApprovalTemplateSnapshot? _templateSnapshot; + bool _isDirty = false; + String? _errorMessage; + bool _isApplyingTemplate = false; + + ApprovalRequestParticipant? get requester => _requester; + List get steps => List.unmodifiable(_steps); + int get maxSteps => _maxSteps; + bool get hasReachedStepLimit => _steps.length >= _maxSteps; + bool get hasDuplicateApprover => + _steps.map((step) => step.approverId).toSet().length != _steps.length; + bool get hasRequesterConflict { + final requester = _requester; + if (requester == null) { + return false; + } + return _steps.any((step) => step.approverId == requester.id); + } + + int get totalSteps => _steps.length; + String? get errorMessage => _errorMessage; + bool get isDirty => _isDirty; + bool get isApplyingTemplate => _isApplyingTemplate; + ApprovalTemplateSnapshot? get templateSnapshot => _templateSnapshot; + + ApprovalRequestParticipant? get finalApprover { + if (_steps.isEmpty) { + return null; + } + return _steps.last.approver; + } + + int? get finalApproverId => finalApprover?.id; + + /// 상신자를 설정한다. + void setRequester(ApprovalRequestParticipant? participant) { + if (_requester == participant) { + return; + } + _requester = participant; + if (participant != null && + _steps.any((step) => step.approverId == participant.id)) { + _markDirty(); + _setError('상신자는 승인자로 지정할 수 없습니다.'); + return; + } + _isDirty = true; + _clearError(); + notifyListeners(); + } + + /// 결재 단계를 추가한다. + /// + /// - 최대 단계 수를 초과하거나 중복 승인자를 추가하면 false를 반환한다. + bool addStep({required ApprovalRequestParticipant approver, String? note}) { + if (hasReachedStepLimit) { + _setError('결재 단계는 최대 $_maxSteps개까지 추가할 수 있습니다.'); + return false; + } + final duplicated = _steps.any((step) => step.approverId == approver.id); + if (_conflictsWithRequester(approver)) { + _setError('상신자는 승인자로 지정할 수 없습니다.'); + return false; + } + if (duplicated) { + _setError('동일한 승인자는 한 번만 추가할 수 있습니다.'); + return false; + } + _clearError(); + final step = ApprovalRequestStep( + stepOrder: _steps.length + 1, + approver: approver, + note: note, + ); + _steps.add(step); + _markDirty(); + notifyListeners(); + return true; + } + + /// 지정된 위치의 결재 단계를 제거한다. + void removeStepAt(int index) { + if (index < 0 || index >= _steps.length) { + return; + } + _clearError(); + _steps.removeAt(index); + _reassignStepOrders(); + _markDirty(); + notifyListeners(); + } + + /// 결재 단계의 순서를 이동한다. + void moveStep(int oldIndex, int newIndex) { + if (oldIndex < 0 || + oldIndex >= _steps.length || + newIndex < 0 || + newIndex >= _steps.length || + oldIndex == newIndex) { + return; + } + _clearError(); + final step = _steps.removeAt(oldIndex); + _steps.insert(newIndex, step); + _reassignStepOrders(); + _markDirty(); + notifyListeners(); + } + + /// 결재 단계를 수정한다. + /// + /// - 승인자를 변경할 경우 중복 여부를 검사한다. + bool updateStep( + int index, { + ApprovalRequestParticipant? approver, + String? note, + }) { + if (index < 0 || index >= _steps.length) { + return false; + } + final current = _steps[index]; + final nextApprover = approver ?? current.approver; + final duplicated = + approver != null && + _steps.asMap().entries.any( + (entry) => + entry.key != index && entry.value.approverId == nextApprover.id, + ); + if (_conflictsWithRequester(nextApprover)) { + _setError('상신자는 승인자로 지정할 수 없습니다.'); + return false; + } + if (duplicated) { + _setError('동일한 승인자는 한 번만 추가할 수 있습니다.'); + return false; + } + _clearError(); + _steps[index] = current.copyWith(approver: approver, note: note); + _markDirty(); + notifyListeners(); + return true; + } + + /// 최종 승인자를 지정한다. + /// + /// - 단계가 없으면 새로운 마지막 단계를 추가한다. + /// - 이미 존재하는 경우 마지막 단계만 해당 승인자로 교체한다. + bool setFinalApprover(ApprovalRequestParticipant approver, {String? note}) { + if (_conflictsWithRequester(approver)) { + _setError('최종 승인자는 상신자와 다른 사람이어야 합니다.'); + return false; + } + if (_steps.isEmpty) { + return addStep(approver: approver, note: note); + } + final duplicateOtherIndex = _steps + .sublist(0, _steps.length - 1) + .any((step) => step.approverId == approver.id); + if (duplicateOtherIndex) { + _setError('최종 승인자는 다른 단계와 중복될 수 없습니다.'); + return false; + } + final lastIndex = _steps.length - 1; + final last = _steps[lastIndex]; + _steps[lastIndex] = last.copyWith( + approver: approver, + note: note ?? last.note, + ); + _clearError(); + _markDirty(); + notifyListeners(); + return true; + } + + /// 템플릿 단계를 그대로 적용한다. + void applyTemplateSteps(List steps) { + if (_requester != null && + steps.any((step) => step.approverId == _requester!.id)) { + _markDirty(); + _setError('상신자는 승인자로 지정할 수 없습니다.'); + return; + } + _steps + ..clear() + ..addAll(steps); + _reassignStepOrders(); + _clearError(); + _markDirty(); + notifyListeners(); + } + + /// 템플릿 스냅샷을 기록한다. + void setTemplateSnapshot(ApprovalTemplateSnapshot? snapshot) { + _templateSnapshot = snapshot; + _markDirty(); + notifyListeners(); + } + + /// 템플릿 버전이 최신인지 간단히 확인한다. + bool isTemplateUpToDate(DateTime? serverUpdatedAt) { + final snapshot = _templateSnapshot; + if (snapshot == null) { + return true; + } + if (snapshot.updatedAt == null || serverUpdatedAt == null) { + return true; + } + return !snapshot.updatedAt!.isBefore(serverUpdatedAt); + } + + /// 현재 상태로부터 결재 상신 입력 모델을 생성한다. + ApprovalSubmissionInput buildSubmissionInput({ + int? transactionId, + int? templateId, + required int statusId, + DateTime? requestedAt, + DateTime? decidedAt, + DateTime? cancelledAt, + DateTime? lastActionAt, + String? title, + String? summary, + String? note, + Map? metadata, + }) { + final requester = _ensureRequester(); + final steps = _ensureSteps(); + return ApprovalSubmissionInput( + transactionId: transactionId, + templateId: templateId ?? _templateSnapshot?.templateId, + statusId: statusId, + requesterId: requester.id, + finalApproverId: steps.isEmpty ? null : steps.last.approverId, + requestedAt: requestedAt, + decidedAt: decidedAt, + cancelledAt: cancelledAt, + lastActionAt: lastActionAt, + title: title, + summary: summary, + note: note, + metadata: metadata, + steps: steps.map((step) => step.toAssignmentItem()).toList(), + ); + } + + /// 재고 전표 결재 입력 모델로 변환한다. + StockTransactionApprovalInput buildTransactionApprovalInput({ + int? approvalStatusId, + int? templateId, + DateTime? requestedAt, + DateTime? decidedAt, + DateTime? cancelledAt, + DateTime? lastActionAt, + String? title, + String? summary, + String? note, + Map? metadata, + }) { + final requester = _ensureRequester(); + final steps = _ensureSteps(); + return StockTransactionApprovalInput( + requestedById: requester.id, + approvalStatusId: approvalStatusId, + templateId: templateId ?? _templateSnapshot?.templateId, + finalApproverId: steps.isEmpty ? null : steps.last.approverId, + requestedAt: requestedAt, + decidedAt: decidedAt, + cancelledAt: cancelledAt, + lastActionAt: lastActionAt, + title: title, + summary: summary, + note: note, + metadata: metadata, + steps: steps.map((step) => step.toAssignmentItem()).toList(), + ); + } + + /// 현재 상태를 초기화한다. + void clear() { + _requester = null; + _steps.clear(); + _templateSnapshot = null; + _errorMessage = null; + _isDirty = false; + notifyListeners(); + } + + /// 템플릿을 저장 후 상태를 갱신한다. + /// + /// 외부에서 저장 유즈케이스를 주입한 경우에만 동작한다. + Future saveTemplate({ + int? templateId, + required ApprovalTemplateInput input, + List? steps, + }) async { + final useCase = _saveTemplateUseCase; + if (useCase == null) { + throw StateError('SaveApprovalTemplateUseCase가 주입되지 않았습니다.'); + } + final template = await useCase.call( + templateId: templateId, + input: input, + steps: steps, + ); + _templateSnapshot = ApprovalTemplateSnapshot( + templateId: template.id, + updatedAt: template.updatedAt, + ); + _clearError(); + _markDirty(); + notifyListeners(); + return template; + } + + /// 템플릿을 적용해 결재 단계를 갱신한다. + /// + /// - 템플릿 저장소와 Apply 유즈케이스가 모두 주입된 경우에만 지원한다. + Future applyTemplate({ + required int approvalId, + required int templateId, + }) async { + final repository = _templateRepository; + final useCase = _applyTemplateUseCase; + if (repository == null || useCase == null) { + throw StateError('템플릿 적용을 위한 의존성이 주입되지 않았습니다.'); + } + _isApplyingTemplate = true; + notifyListeners(); + try { + final template = await repository.fetchDetail( + templateId, + includeSteps: true, + ); + if (template.steps.isEmpty) { + throw StateError('단계가 없는 템플릿은 적용할 수 없습니다.'); + } + final flow = await useCase.call( + approvalId: approvalId, + templateId: templateId, + ); + _templateSnapshot = ApprovalTemplateSnapshot( + templateId: template.id, + updatedAt: template.updatedAt, + ); + final steps = template.steps + .map( + (step) => ApprovalRequestStep( + stepOrder: step.stepOrder, + approver: ApprovalRequestParticipant( + id: step.approver.id, + name: step.approver.name, + employeeNo: step.approver.employeeNo, + ), + note: step.note, + ), + ) + .toList(growable: false); + applyTemplateSteps(steps); + return flow; + } finally { + _isApplyingTemplate = false; + notifyListeners(); + } + } + + void _setError(String message) { + _errorMessage = message; + notifyListeners(); + } + + void _clearError() { + _errorMessage = null; + } + + void _reassignStepOrders() { + for (var index = 0; index < _steps.length; index++) { + final current = _steps[index]; + _steps[index] = current.copyWith(stepOrder: index + 1); + } + } + + List _ensureSteps() { + if (_steps.isEmpty) { + throw StateError('최소 한 개 이상의 결재 단계를 추가해야 합니다.'); + } + if (hasDuplicateApprover) { + throw StateError('동일한 승인자가 중복되어 있습니다.'); + } + final requester = _requester; + if (requester != null) { + for (var index = 0; index < _steps.length; index++) { + final step = _steps[index]; + if (step.approverId != requester.id) { + continue; + } + if (index == _steps.length - 1) { + throw StateError('최종 승인자는 상신자와 다른 사람이어야 합니다.'); + } + throw StateError('상신자는 승인자로 지정할 수 없습니다.'); + } + } + return List.unmodifiable(_steps); + } + + ApprovalRequestParticipant _ensureRequester() { + final requester = _requester; + if (requester == null) { + throw StateError('상신자를 선택해야 합니다.'); + } + return requester; + } + + void _markDirty() { + _isDirty = true; + } + + bool _conflictsWithRequester(ApprovalRequestParticipant participant) { + final requester = _requester; + if (requester == null) { + return false; + } + return requester.id == participant.id; + } +} diff --git a/lib/features/approvals/request/presentation/utils/approval_form_initializer.dart b/lib/features/approvals/request/presentation/utils/approval_form_initializer.dart new file mode 100644 index 0000000..e0e8c74 --- /dev/null +++ b/lib/features/approvals/request/presentation/utils/approval_form_initializer.dart @@ -0,0 +1,96 @@ +import '../../../domain/entities/approval.dart'; +import '../../../shared/approver_catalog.dart'; +import '../controllers/approval_request_controller.dart'; +import '../../../../inventory/transactions/domain/entities/stock_transaction_input.dart'; + +/// 재고 전표 결재 섹션에서 공통으로 사용하는 초기화 유틸리티. +/// +/// - 기존 결재 정보 또는 저장된 초안을 기반으로 단계/상신자를 세팅한다. +class ApprovalFormInitializer { + ApprovalFormInitializer._(); + + /// 결재 구성 컨트롤러에 기본값을 주입한다. + static void populate({ + required ApprovalRequestController controller, + Approval? existingApproval, + StockTransactionApprovalInput? draft, + ApprovalRequestParticipant? defaultRequester, + }) { + if (existingApproval != null) { + _applyExistingApproval(controller, existingApproval); + return; + } + if (defaultRequester != null) { + controller.setRequester(defaultRequester); + } + if (draft != null) { + _applyDraft(controller, draft); + } + } + + static void _applyExistingApproval( + ApprovalRequestController controller, + Approval approval, + ) { + controller.setRequester( + ApprovalRequestParticipant( + id: approval.requester.id, + name: approval.requester.name, + employeeNo: approval.requester.employeeNo, + ), + ); + final steps = approval.steps + .map( + (step) => ApprovalRequestStep( + stepOrder: step.stepOrder, + approver: ApprovalRequestParticipant( + id: step.approver.id, + name: step.approver.name, + employeeNo: step.approver.employeeNo, + ), + note: step.note, + ), + ) + .toList(growable: false); + if (steps.isNotEmpty) { + controller.applyTemplateSteps(steps); + } + } + + static void _applyDraft( + ApprovalRequestController controller, + StockTransactionApprovalInput draft, + ) { + final requesterCatalog = ApprovalApproverCatalog.byId(draft.requestedById); + if (requesterCatalog != null) { + controller.setRequester( + ApprovalRequestParticipant( + id: requesterCatalog.id, + name: requesterCatalog.name, + employeeNo: requesterCatalog.employeeNo, + ), + ); + } + final steps = draft.steps + .map((step) { + final catalog = ApprovalApproverCatalog.byId(step.approverId); + if (catalog == null) { + return null; + } + return ApprovalRequestStep( + stepOrder: step.stepOrder, + approver: ApprovalRequestParticipant( + id: catalog.id, + name: catalog.name, + employeeNo: catalog.employeeNo, + ), + note: step.note, + ); + }) + .whereType() + .toList(growable: false); + if (steps.isNotEmpty) { + controller.applyTemplateSteps(steps); + } + } +} diff --git a/lib/features/approvals/request/presentation/widgets/approval_step_configurator.dart b/lib/features/approvals/request/presentation/widgets/approval_step_configurator.dart new file mode 100644 index 0000000..b71a417 --- /dev/null +++ b/lib/features/approvals/request/presentation/widgets/approval_step_configurator.dart @@ -0,0 +1,564 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import '../../../../../widgets/components/feedback.dart'; +import '../../../../../widgets/components/superport_dialog.dart'; +import '../../../shared/approver_catalog.dart'; +import '../../../shared/widgets/approver_autocomplete_field.dart'; +import '../controllers/approval_request_controller.dart'; +import 'approval_step_row.dart'; +import 'approval_template_picker.dart'; + +/// 결재 단계 구성을 요약하고 모달을 통해 편집할 수 있는 UI 섹션. +class ApprovalStepConfigurator extends StatefulWidget { + const ApprovalStepConfigurator({ + super.key, + required this.controller, + this.readOnly = false, + }); + + /// 결재 단계 상태를 제어하는 컨트롤러. + final ApprovalRequestController controller; + + /// 읽기 전용 모드 여부. + final bool readOnly; + + @override + State createState() => + _ApprovalStepConfiguratorState(); +} + +class _ApprovalStepConfiguratorState extends State { + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: widget.controller, + builder: (context, _) { + final theme = ShadTheme.of(context); + final steps = widget.controller.steps; + final requester = widget.controller.requester; + final finalApprover = widget.controller.finalApprover; + final templateSnapshot = widget.controller.templateSnapshot; + + return ShadCard( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '결재 단계 구성', + style: theme.textTheme.h4.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + '상신자, 중간 승인자, 최종 승인자를 정의하고 템플릿으로 저장할 수 있습니다.', + style: theme.textTheme.muted, + ), + ], + ), + ), + ShadButton( + onPressed: widget.readOnly + ? null + : () => _openConfiguratorDialog(context), + leading: const Icon(lucide.LucideIcons.settings2, size: 16), + child: const Text('단계 구성 편집'), + ), + ], + ), + const SizedBox(height: 20), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _InfoBadge( + icon: lucide.LucideIcons.user, + label: '상신자', + value: requester?.name ?? '미지정', + ), + _InfoBadge( + icon: lucide.LucideIcons.badgeCheck, + label: '최종 승인자', + value: finalApprover?.name ?? '미지정', + ), + _InfoBadge( + icon: lucide.LucideIcons.listOrdered, + label: '총 단계', + value: '${steps.length}개', + ), + if (templateSnapshot != null) + _InfoBadge( + icon: lucide.LucideIcons.bookmarkCheck, + label: '적용 템플릿', + value: '#${templateSnapshot.templateId}', + ), + ], + ), + const SizedBox(height: 16), + if (steps.isEmpty) + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 20, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: theme.colorScheme.border), + ), + child: Text( + '등록된 결재 단계가 없습니다. 단계 구성 편집을 눌러 승인자를 추가하세요.', + style: theme.textTheme.muted, + ), + ) + else + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ..._buildStepSummary(theme, steps), + if (steps.length > 4) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + '+ ${steps.length - 4}개 단계 더 있음', + style: theme.textTheme.muted, + ), + ), + ], + ), + if (widget.controller.errorMessage != null) ...[ + const SizedBox(height: 16), + Text( + widget.controller.errorMessage!, + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.destructive, + ), + ), + ], + ], + ), + ); + }, + ); + } + + List _buildStepSummary( + ShadThemeData theme, + List steps, + ) { + final limit = steps.length > 4 ? 4 : steps.length; + return [ + for (var index = 0; index < limit; index++) + Padding( + padding: EdgeInsets.only(top: index == 0 ? 0 : 8), + child: Row( + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: theme.colorScheme.secondary.withValues(alpha: 0.12), + ), + alignment: Alignment.center, + child: Text( + '${steps[index].stepOrder}', + style: theme.textTheme.small.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + '${steps[index].approver.name} · ${steps[index].approver.employeeNo}', + style: theme.textTheme.small, + ), + ), + ], + ), + ), + ]; + } + + Future _openConfiguratorDialog(BuildContext context) { + return SuperportDialog.show( + context: context, + barrierDismissible: true, + dialog: SuperportDialog( + title: '결재 단계 구성', + description: '승인자 목록을 편집하고 템플릿을 적용하거나 저장합니다.', + child: _ConfiguratorDialogBody( + controller: widget.controller, + readOnly: widget.readOnly, + ), + ), + ); + } +} + +class _ConfiguratorDialogBody extends StatefulWidget { + const _ConfiguratorDialogBody({ + required this.controller, + required this.readOnly, + }); + + final ApprovalRequestController controller; + final bool readOnly; + + @override + State<_ConfiguratorDialogBody> createState() => + _ConfiguratorDialogBodyState(); +} + +class _ConfiguratorDialogBodyState extends State<_ConfiguratorDialogBody> { + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + + return AnimatedBuilder( + animation: widget.controller, + builder: (context, _) { + final steps = widget.controller.steps; + final duplicates = _collectDuplicateApproverIds(steps); + final isApplyingTemplate = widget.controller.isApplyingTemplate; + final hasReachedLimit = widget.controller.hasReachedStepLimit; + final requester = widget.controller.requester; + final hasRequesterConflict = widget.controller.hasRequesterConflict; + + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 720), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '상신자', + style: theme.textTheme.small.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 6), + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 14, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: theme.colorScheme.border), + ), + child: Text( + requester == null + ? '상신자가 아직 지정되지 않았습니다.' + : '${requester.name} · ${requester.employeeNo}', + style: theme.textTheme.small, + ), + ), + const SizedBox(height: 20), + IgnorePointer( + ignoring: widget.readOnly, + child: Opacity( + opacity: widget.readOnly ? 0.6 : 1, + child: ApprovalTemplatePicker( + controller: widget.controller, + ), + ), + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + '결재 단계 목록', + style: theme.textTheme.small.copyWith( + fontWeight: FontWeight.w700, + ), + ), + Row( + children: [ + if (isApplyingTemplate) ...[ + SizedBox( + width: 16, + height: 16, + child: const CircularProgressIndicator( + strokeWidth: 2, + ), + ), + const SizedBox(width: 8), + Text( + '템플릿을 적용하는 중입니다...', + style: theme.textTheme.muted, + ), + const SizedBox(width: 16), + ], + ShadButton.outline( + onPressed: widget.readOnly || hasReachedLimit + ? null + : _openAddStepDialog, + child: Row( + mainAxisSize: MainAxisSize.min, + children: const [ + Icon(lucide.LucideIcons.plus, size: 16), + SizedBox(width: 8), + Text('단계 추가'), + ], + ), + ), + ], + ), + ], + ), + const SizedBox(height: 12), + if (steps.isEmpty) + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 24, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: theme.colorScheme.border), + ), + child: Text( + '결재 단계를 추가해 주세요. 마지막 단계가 자동으로 최종 승인자로 사용됩니다.', + style: theme.textTheme.muted, + ), + ) + else + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 420), + child: ListView.separated( + shrinkWrap: true, + itemCount: steps.length, + physics: const BouncingScrollPhysics(), + separatorBuilder: (_, __) => const SizedBox(height: 12), + itemBuilder: (context, index) { + final step = steps[index]; + final isFinal = index == steps.length - 1; + return ApprovalStepRow( + key: ValueKey('approval_step_row_$index'), + controller: widget.controller, + step: step, + index: index, + isFinal: isFinal, + readOnly: widget.readOnly, + hasDuplicateApprover: duplicates.contains( + step.approverId, + ), + isRequesterConflict: requester?.id == step.approverId, + onRemove: widget.readOnly + ? null + : () => widget.controller.removeStepAt(index), + ); + }, + ), + ), + if (steps.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Text( + '각 단계 오른쪽 화살표 버튼으로 순서를 조정할 수 있습니다.', + style: theme.textTheme.muted, + ), + ), + if (duplicates.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Text( + '동일한 승인자가 중복된 단계가 있습니다. 승인자를 조정해 주세요.', + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.destructive, + ), + ), + ), + if (hasRequesterConflict) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Text( + '상신자와 동일한 승인자는 구성에 포함될 수 없습니다. 다른 승인자를 선택하세요.', + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.destructive, + ), + ), + ), + if (hasReachedLimit) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Text( + '결재 단계는 최대 ${widget.controller.maxSteps}개까지 추가할 수 있습니다.', + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.mutedForeground, + ), + ), + ), + if (widget.controller.errorMessage != null) ...[ + const SizedBox(height: 12), + Text( + widget.controller.errorMessage!, + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.destructive, + ), + ), + ], + ], + ), + ), + ); + }, + ); + } + + Set _collectDuplicateApproverIds(List steps) { + final seen = {}; + final duplicates = {}; + for (final step in steps) { + final id = step.approverId; + if (!seen.add(id)) { + duplicates.add(id); + } + } + return duplicates; + } + + Future _openAddStepDialog() async { + ApprovalApproverCatalogItem? selected; + final idController = TextEditingController(); + + final result = await SuperportDialog.show( + context: context, + barrierDismissible: false, + dialog: SuperportDialog( + title: '결재 단계 추가', + description: '승인자를 검색해 새로운 결재 단계를 추가합니다.', + primaryAction: ShadButton( + onPressed: () => Navigator.of(context, rootNavigator: true).pop(true), + child: const Text('추가'), + ), + secondaryAction: ShadButton.ghost( + onPressed: () => + Navigator.of(context, rootNavigator: true).pop(false), + child: const Text('취소'), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + const Text('승인자'), + const SizedBox(height: 8), + ApprovalApproverAutocompleteField( + idController: idController, + onSelected: (item) => selected = item, + ), + const SizedBox(height: 12), + Text( + '상신자와 중복되지 않도록 다른 승인자를 선택해야 합니다.', + style: ShadTheme.of(context).textTheme.muted, + ), + ], + ), + ), + ); + + if (!mounted) { + idController.dispose(); + return; + } + + if (result != true) { + idController.dispose(); + return; + } + + final participant = _resolveParticipant(selected, idController.text.trim()); + if (participant == null) { + SuperportToast.warning(context, '유효한 승인자를 선택해주세요.'); + idController.dispose(); + return; + } + + final added = widget.controller.addStep(approver: participant); + if (!added) { + final message = widget.controller.errorMessage ?? '결재 단계를 추가하지 못했습니다.'; + SuperportToast.error(context, message); + } else { + SuperportToast.success( + context, + '"${participant.name}" 님을 단계 ${widget.controller.totalSteps}에 추가했습니다.', + ); + } + idController.dispose(); + } + + ApprovalRequestParticipant? _resolveParticipant( + ApprovalApproverCatalogItem? selected, + String manualInput, + ) { + if (selected != null) { + return ApprovalRequestParticipant( + id: selected.id, + name: selected.name, + employeeNo: selected.employeeNo, + ); + } + final manualId = int.tryParse(manualInput); + if (manualId == null) { + return null; + } + final match = ApprovalApproverCatalog.byId(manualId); + if (match == null) { + return null; + } + return ApprovalRequestParticipant( + id: match.id, + name: match.name, + employeeNo: match.employeeNo, + ); + } +} + +class _InfoBadge extends StatelessWidget { + const _InfoBadge({ + required this.icon, + required this.label, + required this.value, + }); + + final IconData icon; + final String label; + final String value; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + border: Border.all(color: theme.colorScheme.border), + color: theme.colorScheme.muted.withValues(alpha: 0.12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 16, color: theme.colorScheme.mutedForeground), + const SizedBox(width: 6), + Text('$label: $value', style: theme.textTheme.small), + ], + ), + ); + } +} diff --git a/lib/features/approvals/request/presentation/widgets/approval_step_row.dart b/lib/features/approvals/request/presentation/widgets/approval_step_row.dart new file mode 100644 index 0000000..83fb33d --- /dev/null +++ b/lib/features/approvals/request/presentation/widgets/approval_step_row.dart @@ -0,0 +1,386 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import '../../../../../widgets/components/feedback.dart'; +import '../../../shared/approver_catalog.dart'; +import '../../../shared/widgets/approver_autocomplete_field.dart'; +import '../controllers/approval_request_controller.dart'; + +/// 결재 단계 테이블에서 단일 행을 편집하기 위한 위젯. +/// +/// - 순번, 승인자 자동완성, 역할/메모 입력, 삭제 버튼을 한 번에 제공한다. +class ApprovalStepRow extends StatefulWidget { + const ApprovalStepRow({ + super.key, + required this.controller, + required this.step, + required this.index, + this.onRemove, + this.isFinal = false, + this.hasDuplicateApprover = false, + this.isRequesterConflict = false, + this.readOnly = false, + }); + + /// 결재 단계 상태를 관리하는 컨트롤러. + final ApprovalRequestController controller; + + /// 현재 행에 해당하는 단계 데이터. + final ApprovalRequestStep step; + + /// 행 인덱스(0-base). + final int index; + + /// 행 삭제 시 실행할 콜백. + final VoidCallback? onRemove; + + /// 마지막 단계(최종 승인자)인지 여부. + final bool isFinal; + + /// 승인자 중복 오류가 있는지 여부. + final bool hasDuplicateApprover; + + /// 상신자와 중복되는 승인자인지 여부. + final bool isRequesterConflict; + + /// 읽기 전용 모드 여부. + final bool readOnly; + + @override + State createState() => _ApprovalStepRowState(); +} + +class _ApprovalStepRowState extends State { + late final TextEditingController _approverIdController; + late final TextEditingController _noteController; + late ApprovalRequestParticipant _currentApprover; + int _fieldVersion = 0; + + @override + void initState() { + super.initState(); + _currentApprover = widget.step.approver; + _approverIdController = TextEditingController( + text: widget.step.approverId.toString(), + ); + _noteController = TextEditingController(text: widget.step.note ?? ''); + } + + @override + void didUpdateWidget(covariant ApprovalStepRow oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.step.approverId != widget.step.approverId) { + _currentApprover = widget.step.approver; + _approverIdController.text = widget.step.approverId.toString(); + _refreshAutocompleteField(); + } + if (oldWidget.step.note != widget.step.note && + widget.step.note != _noteController.text) { + _noteController.text = widget.step.note ?? ''; + } + } + + @override + void dispose() { + _approverIdController.dispose(); + _noteController.dispose(); + super.dispose(); + } + + void _refreshAutocompleteField() { + setState(() { + _fieldVersion += 1; + }); + } + + Future _handleApproverSelected( + BuildContext context, + ApprovalApproverCatalogItem? item, + ) async { + if (widget.readOnly) { + return; + } + ApprovalRequestParticipant? nextParticipant; + + if (item != null) { + nextParticipant = ApprovalRequestParticipant( + id: item.id, + name: item.name, + employeeNo: item.employeeNo, + ); + } else { + final manualId = int.tryParse(_approverIdController.text.trim()); + if (manualId == null) { + SuperportToast.warning(context, '승인자를 다시 선택해주세요.'); + _restorePreviousApprover(); + return; + } + final catalogMatch = ApprovalApproverCatalog.byId(manualId); + if (catalogMatch == null) { + SuperportToast.warning(context, '등록되지 않은 승인자입니다.'); + _restorePreviousApprover(); + return; + } + nextParticipant = ApprovalRequestParticipant( + id: catalogMatch.id, + name: catalogMatch.name, + employeeNo: catalogMatch.employeeNo, + ); + } + + final updated = widget.controller.updateStep( + widget.index, + approver: nextParticipant, + ); + if (!updated) { + SuperportToast.error( + context, + widget.controller.errorMessage ?? '승인자 변경에 실패했습니다.', + ); + _restorePreviousApprover(); + return; + } + setState(() { + _currentApprover = nextParticipant!; + _approverIdController.text = nextParticipant.id.toString(); + }); + if (widget.hasDuplicateApprover) { + SuperportToast.warning(context, '동일한 승인자가 존재하지 않도록 구성해주세요.'); + } else { + SuperportToast.info( + context, + '단계 ${widget.step.stepOrder} 승인자를 ${nextParticipant.name} 님으로 변경했습니다.', + ); + } + } + + void _restorePreviousApprover() { + _approverIdController.text = _currentApprover.id.toString(); + _refreshAutocompleteField(); + } + + void _handleNoteChanged(String value) { + if (widget.readOnly) { + return; + } + final trimmed = value.trim(); + widget.controller.updateStep( + widget.index, + note: trimmed.isEmpty ? null : trimmed, + ); + } + + void _handleMove(int offset) { + if (widget.readOnly) { + return; + } + final targetIndex = widget.index + offset; + final total = widget.controller.totalSteps; + if (targetIndex < 0 || targetIndex >= total) { + return; + } + widget.controller.moveStep(widget.index, targetIndex); + final direction = offset < 0 ? '위로' : '아래로'; + SuperportToast.info(context, '결재 단계 순서를 $direction 조정했습니다.'); + } + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + final hasError = widget.hasDuplicateApprover || widget.isRequesterConflict; + final borderColor = hasError + ? theme.colorScheme.destructive + : theme.colorScheme.border; + final badgeColor = hasError + ? theme.colorScheme.destructive + : theme.colorScheme.secondary; + + return Container( + decoration: BoxDecoration( + border: Border.all(color: borderColor, width: 1), + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _StepBadge(order: widget.step.stepOrder, badgeColor: badgeColor), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.isFinal ? '최종 승인자' : '승인자', + style: theme.textTheme.small.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + IgnorePointer( + ignoring: widget.readOnly, + child: Opacity( + opacity: widget.readOnly ? 0.6 : 1, + child: ApprovalApproverAutocompleteField( + key: ValueKey( + 'approver_field_${widget.index}_$_fieldVersion', + ), + idController: _approverIdController, + onSelected: (item) => + _handleApproverSelected(context, item), + hintText: '승인자 이름 또는 사번 검색', + ), + ), + ), + ], + ), + ), + const SizedBox(width: 12), + SizedBox( + width: 220, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '역할/메모', + style: theme.textTheme.small.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + ShadInput( + controller: _noteController, + onChanged: _handleNoteChanged, + enabled: !widget.readOnly, + placeholder: const Text('예: 팀장 승인'), + ), + ], + ), + ), + const SizedBox(width: 12), + SizedBox( + width: 44, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: widget.readOnly || widget.index == 0 + ? null + : () => _handleMove(-1), + tooltip: '위로 이동', + icon: const Icon(lucide.LucideIcons.chevronUp, size: 18), + ), + IconButton( + onPressed: + widget.readOnly || + widget.index >= widget.controller.totalSteps - 1 + ? null + : () => _handleMove(1), + tooltip: '아래로 이동', + icon: const Icon( + lucide.LucideIcons.chevronDown, + size: 18, + ), + ), + ], + ), + ), + const SizedBox(width: 4), + IconButton( + onPressed: widget.readOnly || widget.onRemove == null + ? null + : widget.onRemove, + tooltip: '단계 삭제', + icon: Icon( + lucide.LucideIcons.trash2, + color: widget.readOnly || widget.onRemove == null + ? theme.colorScheme.mutedForeground + : theme.colorScheme.destructive, + ), + ), + ], + ), + if (hasError) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Row( + children: [ + Icon( + lucide.LucideIcons.triangleAlert, + size: 16, + color: theme.colorScheme.destructive, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + widget.hasDuplicateApprover + ? '동일한 승인자가 중복되어 있습니다. 다른 승인자를 선택해주세요.' + : '상신자는 승인자로 지정할 수 없습니다. 다른 승인자를 선택해주세요.', + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.destructive, + ), + ), + ), + ], + ), + ), + if (widget.isFinal && !hasError) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Row( + children: [ + Icon( + lucide.LucideIcons.circleCheck, + size: 16, + color: theme.colorScheme.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + '마지막 단계가 최종 승인자로 처리됩니다.', + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.primary, + ), + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _StepBadge extends StatelessWidget { + const _StepBadge({required this.order, required this.badgeColor}); + + final int order; + final Color badgeColor; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: badgeColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: badgeColor.withValues(alpha: 0.4)), + ), + alignment: Alignment.center, + child: Text( + order.toString(), + style: theme.textTheme.large.copyWith( + fontWeight: FontWeight.w700, + color: badgeColor, + ), + ), + ); + } +} diff --git a/lib/features/approvals/request/presentation/widgets/approval_template_picker.dart b/lib/features/approvals/request/presentation/widgets/approval_template_picker.dart new file mode 100644 index 0000000..462e4f6 --- /dev/null +++ b/lib/features/approvals/request/presentation/widgets/approval_template_picker.dart @@ -0,0 +1,626 @@ +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import '../../../../../core/network/failure.dart'; +import '../../../../../widgets/components/feedback.dart'; +import '../../../domain/entities/approval_template.dart'; +import '../../../domain/repositories/approval_template_repository.dart'; +import '../../../domain/usecases/save_approval_template_use_case.dart'; +import '../controllers/approval_request_controller.dart'; + +/// 템플릿을 불러오고 적용/저장할 수 있는 결재 템플릿 선택 위젯. +class ApprovalTemplatePicker extends StatefulWidget { + const ApprovalTemplatePicker({ + super.key, + required this.controller, + this.repository, + this.saveUseCase, + this.onTemplateApplied, + this.onTemplatesChanged, + }); + + /// 결재 단계 상태를 제어하는 컨트롤러. + final ApprovalRequestController controller; + + /// 템플릿 저장소. 지정하지 않으면 [GetIt]에서 조회한다. + final ApprovalTemplateRepository? repository; + + /// 템플릿 저장 유즈케이스. 지정하지 않으면 [GetIt]에서 조회한다. + final SaveApprovalTemplateUseCase? saveUseCase; + + /// 템플릿 적용이 완료됐을 때 호출되는 콜백. + final void Function(ApprovalTemplate template)? onTemplateApplied; + + /// 템플릿 목록이 갱신됐을 때 호출되는 콜백. + final void Function(List templates)? onTemplatesChanged; + + @override + State createState() => _ApprovalTemplatePickerState(); +} + +class _ApprovalTemplatePickerState extends State { + List _templates = const []; + int? _selectedTemplateId; + bool _isLoading = false; + bool _isSaving = false; + String? _error; + + ApprovalTemplateRepository? get _repository => + widget.repository ?? + (GetIt.I.isRegistered() + ? GetIt.I() + : null); + + SaveApprovalTemplateUseCase? get _saveUseCase => + widget.saveUseCase ?? + (GetIt.I.isRegistered() + ? GetIt.I() + : null); + + @override + void initState() { + super.initState(); + _selectedTemplateId = widget.controller.templateSnapshot?.templateId; + WidgetsBinding.instance.addPostFrameCallback((_) { + _loadTemplates(); + }); + } + + @override + void didUpdateWidget(covariant ApprovalTemplatePicker oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.controller.templateSnapshot?.templateId != + oldWidget.controller.templateSnapshot?.templateId) { + setState(() { + _selectedTemplateId = widget.controller.templateSnapshot?.templateId; + }); + } + } + + Future _loadTemplates() async { + final repository = _repository; + if (repository == null) { + setState(() { + _error = '결재 템플릿 저장소가 등록되지 않아 목록을 불러올 수 없습니다.'; + }); + return; + } + setState(() { + _isLoading = true; + _error = null; + }); + try { + final result = await repository.list( + page: 1, + pageSize: 30, + isActive: true, + ); + setState(() { + _templates = result.items; + if (_selectedTemplateId != null && + !_templates.any((template) => template.id == _selectedTemplateId)) { + _selectedTemplateId = null; + } + }); + widget.onTemplatesChanged?.call(result.items); + } catch (error) { + final failure = Failure.from(error); + setState(() { + _error = failure.describe(); + }); + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + Future _applyTemplate(BuildContext context) async { + final repository = _repository; + final templateId = _selectedTemplateId; + if (repository == null || templateId == null) { + return; + } + setState(() { + _isLoading = true; + _error = null; + }); + try { + final detail = await repository.fetchDetail( + templateId, + includeSteps: true, + ); + if (!context.mounted) { + return; + } + if (detail.steps.isEmpty) { + throw StateError('단계가 없는 템플릿은 적용할 수 없습니다.'); + } + final steps = detail.steps + .map( + (step) => ApprovalRequestStep( + stepOrder: step.stepOrder, + approver: ApprovalRequestParticipant( + id: step.approver.id, + name: step.approver.name, + employeeNo: step.approver.employeeNo, + ), + note: step.note, + ), + ) + .toList(growable: false); + widget.controller.applyTemplateSteps(steps); + widget.controller.setTemplateSnapshot( + ApprovalTemplateSnapshot( + templateId: detail.id, + updatedAt: detail.updatedAt, + ), + ); + widget.onTemplateApplied?.call(detail); + SuperportToast.success(context, '템플릿 "${detail.name}"을(를) 적용했습니다.'); + } catch (error) { + final failure = Failure.from(error); + if (!mounted) { + return; + } + setState(() { + _error = failure.describe(); + }); + if (context.mounted) { + SuperportToast.error(context, failure.describe()); + } + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + Future _openTemplatePreview(BuildContext context) async { + final repository = _repository; + final templateId = _selectedTemplateId; + if (repository == null || templateId == null) { + SuperportToast.info(context, '미리볼 템플릿을 먼저 선택하세요.'); + return; + } + try { + final detail = await repository.fetchDetail( + templateId, + includeSteps: true, + ); + if (!context.mounted) return; + await showDialog( + context: context, + builder: (_) { + final theme = ShadTheme.of(context); + return Dialog( + insetPadding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 480), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '템플릿 미리보기', + style: theme.textTheme.h4.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 12), + Text(detail.name, style: theme.textTheme.small), + if (detail.description?.isNotEmpty ?? false) ...[ + const SizedBox(height: 4), + Text(detail.description!, style: theme.textTheme.muted), + ], + const SizedBox(height: 16), + if (detail.steps.isEmpty) + Text('등록된 단계가 없습니다.', style: theme.textTheme.muted) + else + Column( + children: [ + for (final step in detail.steps) ...[ + Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: theme.colorScheme.secondary + .withValues(alpha: 0.12), + ), + alignment: Alignment.center, + child: Text( + step.stepOrder.toString(), + style: theme.textTheme.small.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + step.approver.name, + style: theme.textTheme.small.copyWith( + fontWeight: FontWeight.w600, + ), + ), + Text( + '사번 ${step.approver.employeeNo}', + style: theme.textTheme.muted, + ), + if (step.note?.isNotEmpty ?? false) + Text( + step.note!, + style: theme.textTheme.muted, + ), + ], + ), + ), + ], + ), + const SizedBox(height: 12), + ], + ], + ), + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('닫기'), + ), + ), + ], + ), + ), + ), + ); + }, + ); + } catch (error) { + final failure = Failure.from(error); + if (!context.mounted) { + return; + } + SuperportToast.error(context, failure.describe()); + } + } + + Future _openSaveTemplateDialog(BuildContext context) async { + final steps = widget.controller.steps; + if (steps.isEmpty) { + SuperportToast.warning(context, '저장할 결재 단계가 없습니다.'); + return; + } + final saveUseCase = _saveUseCase; + if (saveUseCase == null) { + SuperportToast.error(context, '템플릿 저장 유즈케이스가 등록되지 않았습니다.'); + return; + } + + final nameController = TextEditingController(); + final codeController = TextEditingController(); + final descriptionController = TextEditingController(); + final noteController = TextEditingController(); + String? errorText; + + await showDialog( + context: context, + barrierDismissible: !_isSaving, + builder: (dialogContext) { + final theme = ShadTheme.of(dialogContext); + return StatefulBuilder( + builder: (context, setModalState) { + Future handleSubmit() async { + if (_isSaving) return; + final nameText = nameController.text.trim(); + if (nameText.isEmpty) { + setModalState(() { + errorText = '템플릿명을 입력해주세요.'; + }); + return; + } + final stepInputs = steps + .map( + (step) => ApprovalTemplateStepInput( + stepOrder: step.stepOrder, + approverId: step.approverId, + note: step.note, + ), + ) + .toList(growable: false); + final input = ApprovalTemplateInput( + code: codeController.text.trim().isEmpty + ? null + : codeController.text.trim(), + name: nameText, + description: descriptionController.text.trim().isEmpty + ? null + : descriptionController.text.trim(), + note: noteController.text.trim().isEmpty + ? null + : noteController.text.trim(), + isActive: true, + ); + setModalState(() { + _isSaving = true; + errorText = null; + }); + try { + final template = await saveUseCase.call( + templateId: null, + input: input, + steps: stepInputs, + ); + if (!context.mounted) return; + Navigator.of(dialogContext).pop(true); + if (!context.mounted) return; + SuperportToast.success( + context, + '템플릿 "${template.name}"을(를) 저장했습니다.', + ); + widget.controller.setTemplateSnapshot( + ApprovalTemplateSnapshot( + templateId: template.id, + updatedAt: template.updatedAt, + ), + ); + await _loadTemplates(); + if (mounted) { + setState(() { + _selectedTemplateId = template.id; + _error = null; + }); + } + } catch (error) { + final failure = Failure.from(error); + setModalState(() { + _isSaving = false; + errorText = failure.describe(); + }); + if (context.mounted) { + SuperportToast.error(context, failure.describe()); + } + } + } + + return Dialog( + insetPadding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 520), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '템플릿으로 저장', + style: theme.textTheme.h4.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 12), + ShadInput( + controller: nameController, + placeholder: const Text('템플릿명 (필수)'), + ), + const SizedBox(height: 12), + ShadInput( + controller: codeController, + placeholder: const Text('템플릿 코드 (선택)'), + ), + const SizedBox(height: 12), + ShadTextarea( + controller: descriptionController, + minHeight: 80, + maxHeight: 160, + placeholder: const Text('설명 (선택)'), + ), + const SizedBox(height: 12), + ShadTextarea( + controller: noteController, + minHeight: 80, + maxHeight: 160, + placeholder: const Text('비고/안내 문구 (선택)'), + ), + if (errorText != null) ...[ + const SizedBox(height: 12), + Text( + errorText!, + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.destructive, + ), + ), + ], + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: _isSaving + ? null + : () => Navigator.of(dialogContext).pop(false), + child: const Text('취소'), + ), + const SizedBox(width: 12), + ShadButton( + onPressed: _isSaving ? null : handleSubmit, + child: _isSaving + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Text('저장'), + ), + ], + ), + ], + ), + ), + ), + ); + }, + ); + }, + ); + + nameController.dispose(); + codeController.dispose(); + descriptionController.dispose(); + noteController.dispose(); + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + final snapshot = widget.controller.templateSnapshot; + ApprovalTemplate? selectedTemplate; + for (final template in _templates) { + if (template.id == _selectedTemplateId) { + selectedTemplate = template; + break; + } + } + final isUpToDate = selectedTemplate == null + ? true + : widget.controller.isTemplateUpToDate(selectedTemplate.updatedAt); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: ShadSelect( + placeholder: const Text('템플릿 선택'), + initialValue: selectedTemplate?.id, + enabled: !_isLoading, + onChanged: (value) { + setState(() { + _selectedTemplateId = value; + }); + }, + selectedOptionBuilder: (context, value) { + if (value == null) { + return const Text('템플릿 선택'); + } + ApprovalTemplate? match; + for (final template in _templates) { + if (template.id == value) { + match = template; + break; + } + } + match ??= selectedTemplate; + return Text(match?.name ?? '템플릿 선택'); + }, + options: _templates + .map( + (template) => ShadOption( + value: template.id, + child: Text(template.name), + ), + ) + .toList(), + ), + ), + const SizedBox(width: 12), + ShadButton.outline( + onPressed: _isLoading ? null : () => _loadTemplates(), + child: Row( + mainAxisSize: MainAxisSize.min, + children: const [ + Icon(lucide.LucideIcons.refreshCw, size: 16), + SizedBox(width: 6), + Text('새로고침'), + ], + ), + ), + const SizedBox(width: 12), + ShadButton.ghost( + onPressed: () => _openTemplatePreview(context), + child: const Text('미리보기'), + ), + const SizedBox(width: 12), + ShadButton( + onPressed: (_isLoading || _selectedTemplateId == null) + ? null + : () => _applyTemplate(context), + child: _isLoading && _selectedTemplateId != null + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('템플릿 적용'), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + ShadButton.outline( + onPressed: _isSaving + ? null + : () => _openSaveTemplateDialog(context), + child: Row( + mainAxisSize: MainAxisSize.min, + children: const [ + Icon(lucide.LucideIcons.save, size: 16), + SizedBox(width: 6), + Text('현재 단계를 템플릿으로 저장'), + ], + ), + ), + if (snapshot != null) ...[ + const SizedBox(width: 12), + Text( + '선택됨: #${snapshot.templateId}', + style: theme.textTheme.small, + ), + ], + ], + ), + if (_error != null) ...[ + const SizedBox(height: 12), + Text( + _error!, + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.destructive, + ), + ), + ] else if (selectedTemplate != null) ...[ + const SizedBox(height: 12), + Text( + isUpToDate + ? '템플릿 "${selectedTemplate.name}"이(가) 적용 대기 중입니다.' + : '템플릿 "${selectedTemplate.name}"이(가) 서버 버전과 달라져 재적용이 필요합니다.', + style: theme.textTheme.small.copyWith( + color: isUpToDate + ? theme.colorScheme.mutedForeground + : theme.colorScheme.destructive, + ), + ), + ], + ], + ); + } +} diff --git a/lib/features/approvals/request/presentation/widgets/widgets.dart b/lib/features/approvals/request/presentation/widgets/widgets.dart new file mode 100644 index 0000000..6c0bdca --- /dev/null +++ b/lib/features/approvals/request/presentation/widgets/widgets.dart @@ -0,0 +1,3 @@ +export 'approval_step_configurator.dart'; +export 'approval_step_row.dart'; +export 'approval_template_picker.dart'; diff --git a/lib/features/approvals/shared/approver_catalog.dart b/lib/features/approvals/shared/approver_catalog.dart index 5b989a5..4a134b4 100644 --- a/lib/features/approvals/shared/approver_catalog.dart +++ b/lib/features/approvals/shared/approver_catalog.dart @@ -27,31 +27,31 @@ class ApprovalApproverCatalog { const ApprovalApproverCatalogItem( id: 101, employeeNo: 'EMP101', - name: '김결재', + name: '이검토', team: '물류운영팀', ), const ApprovalApproverCatalogItem( id: 102, employeeNo: 'EMP102', - name: '박승인', - team: '재무팀', + name: '최검수', + team: '품질보증팀', ), const ApprovalApproverCatalogItem( id: 103, employeeNo: 'EMP103', - name: '이반려', + name: '문회수', team: '품질보증팀', ), const ApprovalApproverCatalogItem( id: 104, employeeNo: 'EMP104', - name: '최리뷰', + name: '박팀장', team: '운영혁신팀', ), const ApprovalApproverCatalogItem( id: 105, employeeNo: 'EMP105', - name: '정검토', + name: '정차장', team: '구매팀', ), const ApprovalApproverCatalogItem( @@ -72,6 +72,36 @@ class ApprovalApproverCatalog { name: '문서결', team: '경영기획팀', ), + const ApprovalApproverCatalogItem( + id: 110, + employeeNo: 'EMP110', + name: '문검토', + team: '물류운영팀', + ), + const ApprovalApproverCatalogItem( + id: 120, + employeeNo: 'EMP120', + name: '신품질', + team: '품질관리팀', + ), + const ApprovalApproverCatalogItem( + id: 201, + employeeNo: 'EMP201', + name: '한임원', + team: '경영진', + ), + const ApprovalApproverCatalogItem( + id: 210, + employeeNo: 'EMP210', + name: '강팀장', + team: '물류운영팀', + ), + const ApprovalApproverCatalogItem( + id: 221, + employeeNo: 'EMP221', + name: '노부장', + team: '경영관리팀', + ), ]); static final Map _byId = { diff --git a/lib/features/approvals/shared/widgets/approval_ui_helpers.dart b/lib/features/approvals/shared/widgets/approval_ui_helpers.dart new file mode 100644 index 0000000..9984884 --- /dev/null +++ b/lib/features/approvals/shared/widgets/approval_ui_helpers.dart @@ -0,0 +1,220 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide; +import 'package:shadcn_ui/shadcn_ui.dart'; + +/// 결재 승인자를 테이블/다이얼로그에서 일관되게 표시하기 위한 셀 위젯. +/// +/// - 아바타에 이름 이니셜을 렌더링하고, 이름/사번/부가 설명을 함께 노출한다. +class ApprovalApproverCell extends StatelessWidget { + const ApprovalApproverCell({ + super.key, + required this.name, + required this.employeeNo, + this.subtitle, + this.backgroundColor, + this.textColor, + this.avatarSize = 32, + }); + + /// 승인자 이름. + final String name; + + /// 승인자 사번. + final String employeeNo; + + /// 이름 아래에 노출할 부가 설명. + final String? subtitle; + + /// 아바타 배경색. 지정하지 않으면 테마 보조색을 사용한다. + final Color? backgroundColor; + + /// 텍스트 색상. 지정하지 않으면 테마 기본 텍스트 색을 사용한다. + final Color? textColor; + + /// 아바타 지름. + final double avatarSize; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + final baseColor = backgroundColor ?? theme.colorScheme.secondary; + final labelColor = textColor ?? theme.colorScheme.foreground; + final avatarTextColor = theme.colorScheme.secondaryForeground; + final initials = _buildInitials(name); + + return Row( + mainAxisSize: MainAxisSize.max, + children: [ + CircleAvatar( + radius: avatarSize / 2, + backgroundColor: baseColor.withValues(alpha: 0.14), + foregroundColor: avatarTextColor, + child: Text( + initials, + style: theme.textTheme.small.copyWith(fontWeight: FontWeight.w700), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.small.copyWith( + fontWeight: FontWeight.w600, + color: labelColor, + ), + ), + const SizedBox(height: 2), + Text( + employeeNo, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.muted, + ), + if (subtitle != null && subtitle!.trim().isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + subtitle!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.muted.copyWith( + fontSize: (theme.textTheme.muted.fontSize ?? 12).clamp( + 11, + 13, + ), + ), + ), + ], + ], + ), + ), + ], + ); + } + + String _buildInitials(String value) { + final segments = value.trim().split(RegExp(r'[\s\u00A0]+')); + if (segments.length == 1) { + final entry = segments.first; + if (entry.length <= 2) { + return entry.toUpperCase(); + } + return entry.substring(0, 2).toUpperCase(); + } + return (segments[0].isNotEmpty ? segments[0][0] : '') + + (segments[1].isNotEmpty ? segments[1][0] : ''); + } +} + +/// 결재 상태를 배경색/테두리와 함께 표시하는 배지 위젯. +class ApprovalStatusBadge extends StatelessWidget { + const ApprovalStatusBadge({super.key, required this.label, this.colorHex}); + + /// 상태 라벨. + final String label; + + /// 백엔드에서 내려오는 HEX 문자열(예: `#12ABFF`). + final String? colorHex; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + final baseColor = _resolveColor(colorHex, theme.colorScheme.primary); + final background = baseColor.withValues(alpha: 0.12); + final borderColor = baseColor.withValues(alpha: 0.36); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(999), + border: Border.all(color: borderColor), + ), + child: Text( + label, + style: theme.textTheme.small.copyWith( + fontWeight: FontWeight.w600, + color: baseColor, + ), + ), + ); + } + + Color _resolveColor(String? value, Color fallback) { + if (value == null || value.isEmpty) { + return fallback; + } + var hex = value.trim(); + if (hex.startsWith('#')) { + hex = hex.substring(1); + } + if (hex.length == 6) { + hex = 'FF$hex'; + } + final parsed = int.tryParse(hex, radix: 16); + if (parsed == null) { + return fallback; + } + return Color(parsed); + } +} + +/// 결재 메모를 아이콘과 함께 툴팁으로 노출하는 위젯. +class ApprovalNoteTooltip extends StatelessWidget { + const ApprovalNoteTooltip({ + super.key, + required this.note, + this.placeholder = '-', + this.maxWidth = 220, + }); + + /// 메모 본문. 비어 있으면 [placeholder]를 표시한다. + final String? note; + + /// 메모가 없을 때 대체로 표시할 텍스트. + final String placeholder; + + /// 한 줄로 보여줄 때의 최대 너비. + final double maxWidth; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + final trimmed = note?.trim(); + if (trimmed == null || trimmed.isEmpty) { + return Text(placeholder, style: theme.textTheme.muted); + } + + return Tooltip( + message: trimmed, + waitDuration: const Duration(milliseconds: 300), + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxWidth), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + lucide.LucideIcons.stickyNote, + size: 16, + color: theme.colorScheme.mutedForeground, + ), + const SizedBox(width: 6), + Flexible( + child: Text( + trimmed, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.small, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/approvals/shared/widgets/widgets.dart b/lib/features/approvals/shared/widgets/widgets.dart new file mode 100644 index 0000000..598da7c --- /dev/null +++ b/lib/features/approvals/shared/widgets/widgets.dart @@ -0,0 +1 @@ +export 'approval_ui_helpers.dart'; diff --git a/lib/features/approvals/step/data/dtos/approval_step_record_dto.dart b/lib/features/approvals/step/data/dtos/approval_step_record_dto.dart index d0484fb..aee4159 100644 --- a/lib/features/approvals/step/data/dtos/approval_step_record_dto.dart +++ b/lib/features/approvals/step/data/dtos/approval_step_record_dto.dart @@ -1,6 +1,6 @@ import 'package:superport_v2/core/common/models/paginated_result.dart'; import 'package:superport_v2/core/common/utils/json_utils.dart'; -import 'package:superport_v2/features/approvals/data/dtos/approval_dto.dart'; +import 'package:superport_v2/features/approvals/data/dtos/approval_step_dto.dart'; import 'package:superport_v2/features/approvals/domain/entities/approval.dart'; import '../../domain/entities/approval_step_record.dart'; diff --git a/lib/features/approvals/template/presentation/controllers/approval_template_controller.dart b/lib/features/approvals/template/presentation/controllers/approval_template_controller.dart index d109bdd..2623e82 100644 --- a/lib/features/approvals/template/presentation/controllers/approval_template_controller.dart +++ b/lib/features/approvals/template/presentation/controllers/approval_template_controller.dart @@ -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 _templateVersions = {}; + final Map> _templateStepSummaries = + >{}; PaginatedResult? _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 get templateVersions => + UnmodifiableMapView(_templateVersions); + UnmodifiableMapView> + get templateStepSummaries => UnmodifiableMapView(_templateStepSummaries); + + /// 캐시된 템플릿 버전 정보를 반환한다. + DateTime? versionOf(int templateId) => _templateVersions[templateId]; + + /// 캐시된 단계 요약을 반환한다. + List? stepSummaryOf(int templateId) => + _templateStepSummaries[templateId]; + + /// 단계 요약이 없으면 상세를 조회해 캐시한다. + Future?> 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 create( ApprovalTemplateInput input, List 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? 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 save({ + int? templateId, + required ApprovalTemplateInput input, + List? 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 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 _saveTemplate({ + int? templateId, + required ApprovalTemplateInput input, + List? 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 _performSave( + int? templateId, + ApprovalTemplateInput input, + List? 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 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.from( + template.steps, + ); + } } diff --git a/lib/features/approvals/template/presentation/pages/approval_template_page.dart b/lib/features/approvals/template/presentation/pages/approval_template_page.dart index c5bcb80..cc1019f 100644 --- a/lib/features/approvals/template/presentation/pages/approval_template_page.dart +++ b/lib/features/approvals/template/presentation/pages/approval_template_page.dart @@ -13,6 +13,8 @@ import '../../../../../widgets/components/feature_disabled_placeholder.dart'; import '../../../shared/widgets/approver_autocomplete_field.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'; import '../controllers/approval_template_controller.dart'; /// 결재 템플릿 관리 페이지. 기능 플래그에 따라 준비중 화면을 노출한다. @@ -76,6 +78,8 @@ class _ApprovalTemplateEnabledPageState super.initState(); _controller = ApprovalTemplateController( repository: GetIt.I(), + saveTemplateUseCase: GetIt.I(), + applyTemplateUseCase: GetIt.I(), )..addListener(_handleControllerUpdate); WidgetsBinding.instance.addPostFrameCallback((_) async { await _controller.fetch(); @@ -208,6 +212,7 @@ class _ApprovalTemplateEnabledPageState ShadTableCell.header(child: Text('ID')), ShadTableCell.header(child: Text('템플릿코드')), ShadTableCell.header(child: Text('템플릿명')), + ShadTableCell.header(child: Text('결재 단계 요약')), ShadTableCell.header(child: Text('설명')), ShadTableCell.header(child: Text('사용')), ShadTableCell.header(child: Text('변경일시')), @@ -218,6 +223,13 @@ class _ApprovalTemplateEnabledPageState ShadTableCell(child: Text('${template.id}')), ShadTableCell(child: Text(template.code)), ShadTableCell(child: Text(template.name)), + ShadTableCell( + child: _TemplateStepSummaryCell( + key: ValueKey('template_steps_${template.id}'), + controller: _controller, + template: template, + ), + ), ShadTableCell( child: Text( template.description?.isNotEmpty == true @@ -243,7 +255,17 @@ class _ApprovalTemplateEnabledPageState alignment: Alignment.centerRight, child: Wrap( spacing: 8, + runSpacing: 6, children: [ + ShadButton.ghost( + key: ValueKey( + 'template_preview_${template.id}', + ), + size: ShadButtonSize.sm, + onPressed: () => + _openTemplatePreview(template.id), + child: const Text('보기'), + ), ShadButton.ghost( key: ValueKey( 'template_edit_${template.id}', @@ -274,20 +296,26 @@ class _ApprovalTemplateEnabledPageState ), ]; }).toList(), - rowHeight: 56, - maxHeight: 480, + rowHeight: 58, + maxHeight: 520, columnSpanExtent: (index) { switch (index) { - case 2: - return const FixedTableSpanExtent(220); - case 3: - return const FixedTableSpanExtent(260); - case 4: - return const FixedTableSpanExtent(100); - case 5: - return const FixedTableSpanExtent(180); - case 6: + case 0: + return const FixedTableSpanExtent(80); + case 1: return const FixedTableSpanExtent(160); + case 2: + return const FixedTableSpanExtent(200); + case 3: + return const FixedTableSpanExtent(300); + case 4: + return const FixedTableSpanExtent(220); + case 5: + return const FixedTableSpanExtent(100); + case 6: + return const FixedTableSpanExtent(180); + case 7: + return const FixedTableSpanExtent(220); default: return const FixedTableSpanExtent(140); } @@ -326,6 +354,99 @@ class _ApprovalTemplateEnabledPageState _searchFocus.requestFocus(); } + Future _openTemplatePreview(int templateId) async { + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => const Center(child: CircularProgressIndicator()), + ); + final detail = await _controller.fetchDetail(templateId); + if (mounted) { + Navigator.of(context, rootNavigator: true).pop(); + } + if (!mounted) { + return; + } + if (detail == null) { + final messenger = ScaffoldMessenger.maybeOf(context); + messenger?.showSnackBar( + const SnackBar(content: Text('템플릿 정보를 불러오지 못했습니다. 다시 시도하세요.')), + ); + return; + } + + final theme = ShadTheme.of(context); + await SuperportDialog.show( + context: context, + dialog: SuperportDialog( + title: detail.name, + description: detail.description, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 540), + child: detail.steps.isEmpty + ? Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Text('등록된 결재 단계가 없습니다.', style: theme.textTheme.muted), + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + for (final step in detail.steps) ...[ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: theme.colorScheme.secondary.withValues( + alpha: 0.12, + ), + ), + alignment: Alignment.center, + child: Text( + '${step.stepOrder}', + style: theme.textTheme.small.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + step.approver.name, + style: theme.textTheme.small.copyWith( + fontWeight: FontWeight.w600, + ), + ), + Text( + '사번 ${step.approver.employeeNo}', + style: theme.textTheme.muted, + ), + if (step.note?.isNotEmpty ?? false) + Text( + step.note!, + style: theme.textTheme.muted, + ), + ], + ), + ), + ], + ), + const SizedBox(height: 12), + ], + ], + ), + ), + ), + ); + } + Future _openEditTemplate(ApprovalTemplate template) async { showDialog( context: context, @@ -700,6 +821,96 @@ class _ApprovalTemplateEnabledPageState } } +class _TemplateStepSummaryCell extends StatefulWidget { + const _TemplateStepSummaryCell({ + super.key, + required this.controller, + required this.template, + }); + + final ApprovalTemplateController controller; + final ApprovalTemplate template; + + @override + State<_TemplateStepSummaryCell> createState() => + _TemplateStepSummaryCellState(); +} + +class _TemplateStepSummaryCellState extends State<_TemplateStepSummaryCell> { + bool _isLoading = false; + + Future _loadSummary() async { + if (_isLoading) return; + setState(() { + _isLoading = true; + }); + try { + await widget.controller.ensureStepSummary(widget.template.id); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return AnimatedBuilder( + animation: widget.controller, + builder: (context, _) { + final steps = + widget.controller.stepSummaryOf(widget.template.id) ?? + widget.template.steps; + if (steps.isEmpty) { + return ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: _isLoading ? null : _loadSummary, + child: _isLoading + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('단계 불러오기'), + ); + } + + final displaySteps = steps.take(3).toList(); + final overflow = steps.length - displaySteps.length; + final summaryText = steps + .map((step) => '${step.stepOrder}. ${step.approver.name}') + .join(' → '); + + return Tooltip( + message: summaryText, + preferBelow: false, + child: Wrap( + spacing: 6, + runSpacing: 6, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + for (final step in displaySteps) + ShadBadge( + child: Text( + '${step.stepOrder}. ${step.approver.name}', + style: theme.textTheme.small, + ), + ), + if (overflow > 0) + ShadBadge.outline( + child: Text('+$overflow', style: theme.textTheme.small), + ), + ], + ), + ); + }, + ); + } +} + class _FormField extends StatelessWidget { const _FormField({required this.label, required this.child}); diff --git a/lib/features/dashboard/data/dtos/dashboard_summary_dto.dart b/lib/features/dashboard/data/dtos/dashboard_summary_dto.dart index 051ed58..7d90268 100644 --- a/lib/features/dashboard/data/dtos/dashboard_summary_dto.dart +++ b/lib/features/dashboard/data/dtos/dashboard_summary_dto.dart @@ -147,7 +147,7 @@ class DashboardApprovalDto { final String approvalNo; final String title; final String stepSummary; - final String? requestedAt; + final DateTime? requestedAt; factory DashboardApprovalDto.fromJson(Map json) { num? rawId = _readNum(json, 'approval_id'); @@ -175,7 +175,7 @@ class DashboardApprovalDto { approvalNo: _readString(json, 'approval_no') ?? '', title: _readString(json, 'title') ?? '', stepSummary: _readString(json, 'step_summary') ?? '', - requestedAt: _readString(json, 'requested_at'), + requestedAt: _parseDate(json['requested_at']), ); } diff --git a/lib/features/dashboard/domain/entities/dashboard_pending_approval.dart b/lib/features/dashboard/domain/entities/dashboard_pending_approval.dart index e5f68e1..6ed6704 100644 --- a/lib/features/dashboard/domain/entities/dashboard_pending_approval.dart +++ b/lib/features/dashboard/domain/entities/dashboard_pending_approval.dart @@ -20,6 +20,6 @@ class DashboardPendingApproval { /// 현재 단계/승인자 요약 final String stepSummary; - /// 상신 일시(문자열) - final String? requestedAt; + /// 상신 일시 + final DateTime? requestedAt; } diff --git a/lib/features/dashboard/presentation/pages/dashboard_page.dart b/lib/features/dashboard/presentation/pages/dashboard_page.dart index d5ebc6f..f7c2570 100644 --- a/lib/features/dashboard/presentation/pages/dashboard_page.dart +++ b/lib/features/dashboard/presentation/pages/dashboard_page.dart @@ -393,50 +393,36 @@ class _PendingApprovalCard extends StatelessWidget { return ShadCard( title: Text('내 결재 대기', style: theme.textTheme.h3), description: Text( - '현재 승인 대기 중인 결재 요청입니다.', + '최종 승인 대기 전표는 기본 목록에 노출되지 않습니다.', style: theme.textTheme.muted, ), child: const SuperportEmptyState( title: '대기 중인 결재가 없습니다', - description: '새로운 결재 요청이 등록되면 이곳에서 바로 확인할 수 있습니다.', + description: '최종 승인 대기 전표가 생성되면 이곳에 표시됩니다.', ), ); } + final now = DateTime.now(); + final dateFormat = intl.DateFormat('yyyy-MM-dd HH:mm'); + return ShadCard( title: Text('내 결재 대기', style: theme.textTheme.h3), - description: Text('현재 승인 대기 중인 결재 요청입니다.', style: theme.textTheme.muted), + description: Text( + '최종 승인 대기 전표를 한곳에서 확인하고 처리할 수 있습니다.', + style: theme.textTheme.muted, + ), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - for (final approval in approvals) ...[ - ListTile( - leading: const Icon(lucide.LucideIcons.fileCheck, size: 20), - title: Text(approval.approvalNo, style: theme.textTheme.small), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(approval.title, style: theme.textTheme.p), - const SizedBox(height: 4), - Text(approval.stepSummary, style: theme.textTheme.muted), - if (approval.requestedAt != null && - approval.requestedAt!.trim().isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: 4), - child: Text( - '상신: ${approval.requestedAt}', - style: theme.textTheme.small, - ), - ), - ], - ), - trailing: ShadButton.ghost( - size: ShadButtonSize.sm, - onPressed: () => _handleViewDetail(context, approval), - child: const Text('상세'), - ), + for (var index = 0; index < approvals.length; index++) ...[ + _PendingApprovalListTile( + approval: approvals[index], + now: now, + dateFormat: dateFormat, + onViewDetail: () => _handleViewDetail(context, approvals[index]), ), - const Divider(), + if (index < approvals.length - 1) const Divider(), ], ], ), @@ -525,6 +511,191 @@ class _PendingApprovalCard extends StatelessWidget { } } +class _PendingApprovalListTile extends StatelessWidget { + const _PendingApprovalListTile({ + required this.approval, + required this.now, + required this.dateFormat, + required this.onViewDetail, + }); + + final DashboardPendingApproval approval; + final DateTime now; + final intl.DateFormat dateFormat; + final VoidCallback onViewDetail; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + final summary = _PendingApprovalSummary.parse(approval.stepSummary); + final requestedAt = approval.requestedAt; + final timestampLabel = requestedAt == null + ? '상신일시 확인 불가' + : dateFormat.format(requestedAt.toLocal()); + final elapsed = requestedAt == null + ? null + : _formatElapsedKorean(now.difference(requestedAt)); + final chips = [ShadBadge(child: Text(approval.approvalNo))]; + if (summary.stage != null && summary.stage!.isNotEmpty) { + chips.add(ShadBadge.outline(child: Text(summary.stage!))); + } + if (summary.actor != null && summary.actor!.isNotEmpty) { + chips.add(ShadBadge.outline(child: Text('승인자 ${summary.actor!}'))); + } + if (summary.status != null && summary.status!.isNotEmpty) { + chips.add(ShadBadge.outline(child: Text('상태 ${summary.status!}'))); + } + final description = summary.description; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 4), + child: Icon( + lucide.LucideIcons.fileCheck, + size: 18, + color: theme.colorScheme.mutedForeground, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + approval.title, + style: theme.textTheme.p.copyWith( + fontWeight: FontWeight.w600, + color: theme.colorScheme.foreground, + ), + ), + if (chips.isNotEmpty) ...[ + const SizedBox(height: 8), + Wrap(spacing: 8, runSpacing: 6, children: chips), + ], + if (description != null) ...[ + const SizedBox(height: 8), + Text(description, style: theme.textTheme.small), + ], + const SizedBox(height: 8), + Text( + elapsed == null + ? '상신: $timestampLabel' + : '상신: $timestampLabel · 경과 $elapsed', + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.mutedForeground, + ), + ), + ], + ), + ), + const SizedBox(width: 12), + ShadButton.ghost( + size: ShadButtonSize.sm, + onPressed: onViewDetail, + child: const Text('상세'), + ), + ], + ), + ); + } +} + +class _PendingApprovalSummary { + const _PendingApprovalSummary({this.stage, this.actor, this.status}); + + final String? stage; + final String? actor; + final String? status; + + static _PendingApprovalSummary parse(String? raw) { + if (raw == null) { + return const _PendingApprovalSummary(); + } + var text = raw.trim(); + if (text.isEmpty) { + return const _PendingApprovalSummary(); + } + String? status; + final statusMatch = RegExp(r'\(([^)]+)\)$').firstMatch(text); + if (statusMatch != null) { + status = statusMatch.group(1)?.trim(); + text = text.substring(0, statusMatch.start).trim(); + } + final parts = text.split(RegExp(r'\s*[·/→>]+\s*')); + String? stage; + String? actor; + if (parts.isNotEmpty) { + final value = parts.first.trim(); + if (value.isNotEmpty) { + stage = value; + } + } + if (parts.length >= 2) { + final joined = parts.sublist(1).join(' · ').trim(); + if (joined.isNotEmpty) { + actor = joined; + } + } + return _PendingApprovalSummary(stage: stage, actor: actor, status: status); + } + + String? get description { + final segments = []; + if (stage != null && stage!.isNotEmpty) { + segments.add('현재 단계 $stage'); + } + if (actor != null && actor!.isNotEmpty) { + segments.add('승인자 $actor'); + } + if (status != null && status!.isNotEmpty) { + segments.add('상태 $status'); + } + if (segments.isEmpty) { + return null; + } + return segments.join(' · '); + } +} + +String _formatElapsedKorean(Duration duration) { + var value = duration; + if (value.isNegative) { + value = Duration(seconds: -value.inSeconds); + } + if (value.inMinutes < 1) { + return '1분 미만'; + } + if (value.inHours < 1) { + return '${value.inMinutes}분'; + } + if (value.inHours < 24) { + final hours = value.inHours; + final minutes = value.inMinutes % 60; + if (minutes == 0) { + return '$hours시간'; + } + return '$hours시간 $minutes분'; + } + if (value.inDays < 7) { + final days = value.inDays; + final hours = value.inHours % 24; + if (hours == 0) { + return '$days일'; + } + return '$days일 $hours시간'; + } + final weeks = value.inDays ~/ 7; + final days = value.inDays % 7; + if (days == 0) { + return '$weeks주'; + } + return '$weeks주 $days일'; +} + class _DashboardApprovalDetailContent extends StatelessWidget { const _DashboardApprovalDetailContent({required this.approval}); diff --git a/lib/features/inventory/inbound/domain/entities/create_inbound_request_input.dart b/lib/features/inventory/inbound/domain/entities/create_inbound_request_input.dart new file mode 100644 index 0000000..f6e4898 --- /dev/null +++ b/lib/features/inventory/inbound/domain/entities/create_inbound_request_input.dart @@ -0,0 +1,43 @@ +import '../../../transactions/domain/entities/stock_transaction_input.dart'; + +/// 입고 생성 요청 입력 값. +/// +/// - 재고 트랜잭션 생성에 필요한 필드와 결재 구성을 함께 전달한다. +class CreateInboundRequestInput { + CreateInboundRequestInput({ + required this.transactionTypeId, + required this.transactionStatusId, + required this.warehouseId, + required this.transactionDate, + required this.createdById, + required this.approval, + this.note, + this.lines = const [], + this.customers = const [], + }); + + final int transactionTypeId; + final int transactionStatusId; + final int warehouseId; + final DateTime transactionDate; + final int createdById; + final String? note; + final List lines; + final List customers; + final StockTransactionApprovalInput approval; + + /// 재고 트랜잭션 생성 입력 모델로 변환한다. + StockTransactionCreateInput toTransactionInput() { + return StockTransactionCreateInput( + transactionTypeId: transactionTypeId, + transactionStatusId: transactionStatusId, + warehouseId: warehouseId, + transactionDate: transactionDate, + createdById: createdById, + note: note, + lines: lines, + customers: customers, + approval: approval, + ); + } +} diff --git a/lib/features/inventory/inbound/presentation/controllers/inbound_controller.dart b/lib/features/inventory/inbound/presentation/controllers/inbound_controller.dart index ba9f3af..1c0265a 100644 --- a/lib/features/inventory/inbound/presentation/controllers/inbound_controller.dart +++ b/lib/features/inventory/inbound/presentation/controllers/inbound_controller.dart @@ -1,8 +1,13 @@ +import 'dart:async'; 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 'package:superport_v2/features/approvals/domain/entities/approval_draft.dart'; +import 'package:superport_v2/features/approvals/domain/usecases/get_approval_draft_use_case.dart'; +import 'package:superport_v2/features/approvals/domain/usecases/list_approval_drafts_use_case.dart'; +import 'package:superport_v2/features/approvals/domain/usecases/save_approval_draft_use_case.dart'; import 'package:superport_v2/features/inventory/inbound/presentation/models/inbound_record.dart'; import 'package:superport_v2/features/inventory/lookups/domain/entities/lookup_item.dart'; import 'package:superport_v2/features/inventory/lookups/domain/repositories/inventory_lookup_repository.dart'; @@ -20,10 +25,16 @@ class InboundController extends ChangeNotifier { required InventoryLookupRepository lookupRepository, List fallbackStatusOptions = const ['작성중', '승인대기', '승인완료'], List transactionTypeKeywords = const ['입고', 'inbound'], + SaveApprovalDraftUseCase? saveDraftUseCase, + GetApprovalDraftUseCase? getDraftUseCase, + ListApprovalDraftsUseCase? listDraftsUseCase, }) : _transactionRepository = transactionRepository, _lineRepository = lineRepository, _customerRepository = customerRepository, _lookupRepository = lookupRepository, + _saveDraftUseCase = saveDraftUseCase, + _getDraftUseCase = getDraftUseCase, + _listDraftsUseCase = listDraftsUseCase, _fallbackStatusOptions = List.unmodifiable( fallbackStatusOptions, ), @@ -37,6 +48,9 @@ class InboundController extends ChangeNotifier { final TransactionLineRepository _lineRepository; final TransactionCustomerRepository _customerRepository; final InventoryLookupRepository _lookupRepository; + final SaveApprovalDraftUseCase? _saveDraftUseCase; + final GetApprovalDraftUseCase? _getDraftUseCase; + final ListApprovalDraftsUseCase? _listDraftsUseCase; final List _fallbackStatusOptions; final List _transactionTypeKeywords; @@ -51,6 +65,7 @@ class InboundController extends ChangeNotifier { final Set _processingTransactionIds = {}; List _approvalStatuses = const []; LookupItem? _defaultApprovalStatus; + StockTransactionApprovalInput? _approvalDraft; UnmodifiableListView get statusOptions => UnmodifiableListView(_statusOptions); @@ -78,6 +93,7 @@ class InboundController extends ChangeNotifier { UnmodifiableSetView get processingTransactionIds => UnmodifiableSetView(_processingTransactionIds); + StockTransactionApprovalInput? get approvalDraft => _approvalDraft; /// 트랜잭션 상태 목록을 서버에서 읽어온다. Future loadStatusOptions() async { @@ -139,6 +155,64 @@ class InboundController extends ChangeNotifier { } } + /// 결재 구성 초안 상태를 갱신한다. + void updateApprovalDraft(StockTransactionApprovalInput approval) { + _approvalDraft = approval; + notifyListeners(); + _persistApprovalDraft(approval); + } + + /// 결재 구성 초안을 초기화한다. + void clearApprovalDraft() { + if (_approvalDraft == null) { + return; + } + _approvalDraft = null; + notifyListeners(); + } + + Future loadApprovalDraftFromServer({ + required int requesterId, + int? transactionId, + }) async { + final listUseCase = _listDraftsUseCase; + final getUseCase = _getDraftUseCase; + if (listUseCase == null || getUseCase == null) { + return; + } + try { + final result = await listUseCase.call( + ApprovalDraftListFilter( + requesterId: requesterId, + transactionId: transactionId, + pageSize: 10, + ), + ); + if (result.items.isEmpty) { + return; + } + final sessionKey = _draftSessionKey( + requesterId, + transactionId: transactionId, + ); + final summary = result.items.firstWhere( + (item) => item.sessionKey == sessionKey, + orElse: () => result.items.first, + ); + final detail = await getUseCase.call( + id: summary.id, + requesterId: requesterId, + ); + if (detail == null) { + return; + } + _approvalDraft = _toTransactionApproval(detail); + notifyListeners(); + } catch (error, stackTrace) { + debugPrint('[InboundController] 초안 복구 실패: $error\n$stackTrace'); + } + } + /// 필터에 맞는 입고 트랜잭션 목록을 조회한다. Future fetchTransactions({ required StockTransactionListFilter filter, @@ -179,6 +253,84 @@ class InboundController extends ChangeNotifier { await fetchTransactions(filter: target); } + void _persistApprovalDraft(StockTransactionApprovalInput approval) { + final useCase = _saveDraftUseCase; + if (useCase == null) { + return; + } + if (approval.steps.isEmpty) { + return; + } + final input = _buildApprovalDraftSaveInput(approval); + if (!input.hasSteps) { + return; + } + unawaited( + Future(() async { + try { + await useCase.call(input); + } catch (error, stackTrace) { + debugPrint('[InboundController] 초안 저장 실패: $error\n$stackTrace'); + } + }), + ); + } + + ApprovalDraftSaveInput _buildApprovalDraftSaveInput( + StockTransactionApprovalInput approval, + ) { + final steps = approval.steps + .map( + (step) => ApprovalDraftStep( + stepOrder: step.stepOrder, + approverId: step.approverId, + note: step.note, + ), + ) + .toList(growable: false); + return ApprovalDraftSaveInput( + requesterId: approval.requestedById, + transactionId: null, + templateId: approval.templateId, + title: approval.title, + summary: approval.summary, + note: approval.note, + metadata: approval.metadata, + sessionKey: _draftSessionKey(approval.requestedById), + statusId: approval.approvalStatusId, + steps: steps, + ); + } + + String _draftSessionKey(int requesterId, {int? transactionId}) { + final base = 'inventory_inbound_$requesterId'; + if (transactionId == null) { + return base; + } + return '${base}_$transactionId'; + } + + StockTransactionApprovalInput _toTransactionApproval( + ApprovalDraftDetail detail, + ) { + final submission = detail.toSubmissionInput(defaultStatusId: null); + return StockTransactionApprovalInput( + requestedById: submission.requesterId, + approvalStatusId: submission.statusId == 0 ? null : submission.statusId, + templateId: submission.templateId, + finalApproverId: submission.finalApproverId, + requestedAt: submission.requestedAt, + decidedAt: submission.decidedAt, + cancelledAt: submission.cancelledAt, + lastActionAt: submission.lastActionAt, + title: submission.title, + summary: submission.summary, + note: submission.note, + metadata: submission.metadata, + steps: submission.steps, + ); + } + LookupItem? _resolveDefaultApprovalStatus(List items) { for (final item in items) { if (item.isDefault) { @@ -194,6 +346,7 @@ class InboundController extends ChangeNotifier { bool refreshAfter = true, StockTransactionListFilter? refreshFilter, }) { + _approvalDraft = input.approval; return _executeMutation( () => _transactionRepository.create(input), refreshAfter: refreshAfter, diff --git a/lib/features/inventory/inbound/presentation/pages/inbound_page.dart b/lib/features/inventory/inbound/presentation/pages/inbound_page.dart index 4f75b1b..cf45ea2 100644 --- a/lib/features/inventory/inbound/presentation/pages/inbound_page.dart +++ b/lib/features/inventory/inbound/presentation/pages/inbound_page.dart @@ -17,6 +17,13 @@ import 'package:superport_v2/widgets/components/superport_date_picker.dart'; import 'package:superport_v2/features/inventory/shared/widgets/partner_select_field.dart'; import 'package:superport_v2/features/inventory/shared/widgets/product_autocomplete_field.dart'; import 'package:superport_v2/features/inventory/shared/widgets/warehouse_select_field.dart'; +import 'package:superport_v2/features/approvals/request/presentation/controllers/approval_request_controller.dart'; +import 'package:superport_v2/features/approvals/request/presentation/utils/approval_form_initializer.dart'; +import 'package:superport_v2/features/approvals/request/presentation/widgets/approval_step_configurator.dart'; +import 'package:superport_v2/features/approvals/request/presentation/widgets/approval_template_picker.dart'; +import 'package:superport_v2/features/approvals/domain/usecases/get_approval_draft_use_case.dart'; +import 'package:superport_v2/features/approvals/domain/usecases/list_approval_drafts_use_case.dart'; +import 'package:superport_v2/features/approvals/domain/usecases/save_approval_draft_use_case.dart'; import 'package:superport_v2/core/config/environment.dart'; import 'package:superport_v2/core/network/failure.dart'; import 'package:superport_v2/core/permissions/permission_manager.dart'; @@ -126,6 +133,15 @@ class _InboundPageState extends State { lookupRepository: getIt(), fallbackStatusOptions: InboundTableSpec.fallbackStatusOptions, transactionTypeKeywords: InboundTableSpec.transactionTypeKeywords, + saveDraftUseCase: getIt.isRegistered() + ? getIt() + : null, + getDraftUseCase: getIt.isRegistered() + ? getIt() + : null, + listDraftsUseCase: getIt.isRegistered() + ? getIt() + : null, ); } @@ -137,6 +153,10 @@ class _InboundPageState extends State { Future.microtask(() async { await controller.loadStatusOptions(); await controller.loadApprovalStatuses(); + final requester = _resolveCurrentWriter(); + if (requester != null) { + await controller.loadApprovalDraftFromServer(requesterId: requester.id); + } final hasType = await controller.resolveTransactionType(); if (!mounted) { return; @@ -1483,6 +1503,25 @@ class _InboundPageState extends State { return '${suggestion.name} (${suggestion.employeeNo})'; } + final approvalController = ApprovalRequestController(); + final defaultRequester = () { + final writer = writerSelection; + if (writer == null) { + return null; + } + return ApprovalRequestParticipant( + id: writer.id, + name: writer.name, + employeeNo: writer.employeeNo, + ); + }(); + ApprovalFormInitializer.populate( + controller: approvalController, + existingApproval: initial?.raw?.approval, + draft: _controller?.approvalDraft, + defaultRequester: defaultRequester, + ); + final writerController = TextEditingController( text: writerLabel(writerSelection), ); @@ -1718,6 +1757,23 @@ class _InboundPageState extends State { return; } + StockTransactionApprovalInput approvalInput; + try { + approvalInput = approvalController.buildTransactionApprovalInput( + approvalStatusId: approvalStatusId, + note: approvalNoteValue.isEmpty ? null : approvalNoteValue, + ); + } on StateError catch (error) { + updateSaving(false); + SuperportToast.error(context, error.message); + return; + } catch (_) { + updateSaving(false); + SuperportToast.error(context, '결재 구성을 확인하고 다시 시도하세요.'); + return; + } + controller.updateApprovalDraft(approvalInput); + final createLines = lineDrafts .map( (draft) => TransactionLineCreateInput( @@ -1738,11 +1794,7 @@ class _InboundPageState extends State { note: remarkValue, lines: createLines, customers: createCustomers, - approval: StockTransactionApprovalInput( - requestedById: createdById, - approvalStatusId: approvalStatusId, - note: approvalNoteValue.isEmpty ? null : approvalNoteValue, - ), + approval: approvalInput, ); assert(() { debugPrint( @@ -1995,6 +2047,15 @@ class _InboundPageState extends State { ), ], ), + const SizedBox(height: 16), + if (initial == null) ...[ + ApprovalTemplatePicker(controller: approvalController), + const SizedBox(height: 16), + ], + ApprovalStepConfigurator( + controller: approvalController, + readOnly: initial != null, + ), const SizedBox(height: 24), if (headerNotice != null) Padding( @@ -2112,6 +2173,7 @@ class _InboundPageState extends State { final disposeApprovalNoteController = approvalNoteController; final disposeTransactionTypeController = transactionTypeController; final disposeProcessedAt = processedAt; + final disposeApprovalController = approvalController; WidgetsBinding.instance.addPostFrameCallback((_) { for (final draft in disposeDrafts) { @@ -2123,6 +2185,7 @@ class _InboundPageState extends State { disposeApprovalNoteController.dispose(); disposeTransactionTypeController.dispose(); disposeProcessedAt.dispose(); + disposeApprovalController.dispose(); }); return result; diff --git a/lib/features/inventory/outbound/domain/entities/create_outbound_request_input.dart b/lib/features/inventory/outbound/domain/entities/create_outbound_request_input.dart new file mode 100644 index 0000000..1591b02 --- /dev/null +++ b/lib/features/inventory/outbound/domain/entities/create_outbound_request_input.dart @@ -0,0 +1,43 @@ +import '../../../transactions/domain/entities/stock_transaction_input.dart'; + +/// 출고 생성 요청 입력 값. +/// +/// - 결재 구성을 포함한 재고 트랜잭션 생성 데이터를 보관한다. +class CreateOutboundRequestInput { + CreateOutboundRequestInput({ + required this.transactionTypeId, + required this.transactionStatusId, + required this.warehouseId, + required this.transactionDate, + required this.createdById, + required this.approval, + this.note, + this.lines = const [], + this.customers = const [], + }); + + final int transactionTypeId; + final int transactionStatusId; + final int warehouseId; + final DateTime transactionDate; + final int createdById; + final String? note; + final List lines; + final List customers; + final StockTransactionApprovalInput approval; + + /// 공통 재고 트랜잭션 입력 모델로 변환한다. + StockTransactionCreateInput toTransactionInput() { + return StockTransactionCreateInput( + transactionTypeId: transactionTypeId, + transactionStatusId: transactionStatusId, + warehouseId: warehouseId, + transactionDate: transactionDate, + createdById: createdById, + note: note, + lines: lines, + customers: customers, + approval: approval, + ); + } +} diff --git a/lib/features/inventory/outbound/presentation/controllers/outbound_controller.dart b/lib/features/inventory/outbound/presentation/controllers/outbound_controller.dart index ac20287..dbed0a4 100644 --- a/lib/features/inventory/outbound/presentation/controllers/outbound_controller.dart +++ b/lib/features/inventory/outbound/presentation/controllers/outbound_controller.dart @@ -1,8 +1,13 @@ +import 'dart:async'; 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 'package:superport_v2/features/approvals/domain/entities/approval_draft.dart'; +import 'package:superport_v2/features/approvals/domain/usecases/get_approval_draft_use_case.dart'; +import 'package:superport_v2/features/approvals/domain/usecases/list_approval_drafts_use_case.dart'; +import 'package:superport_v2/features/approvals/domain/usecases/save_approval_draft_use_case.dart'; import 'package:superport_v2/features/inventory/lookups/domain/entities/lookup_item.dart'; import 'package:superport_v2/features/inventory/lookups/domain/repositories/inventory_lookup_repository.dart'; import 'package:superport_v2/features/inventory/outbound/presentation/models/outbound_record.dart'; @@ -20,10 +25,16 @@ class OutboundController extends ChangeNotifier { required InventoryLookupRepository lookupRepository, List fallbackStatusOptions = const ['작성중', '출고대기', '출고완료'], List transactionTypeKeywords = const ['출고', 'outbound'], + SaveApprovalDraftUseCase? saveDraftUseCase, + GetApprovalDraftUseCase? getDraftUseCase, + ListApprovalDraftsUseCase? listDraftsUseCase, }) : _transactionRepository = transactionRepository, _lineRepository = lineRepository, _customerRepository = customerRepository, _lookupRepository = lookupRepository, + _saveDraftUseCase = saveDraftUseCase, + _getDraftUseCase = getDraftUseCase, + _listDraftsUseCase = listDraftsUseCase, _fallbackStatusOptions = List.unmodifiable( fallbackStatusOptions, ), @@ -37,6 +48,9 @@ class OutboundController extends ChangeNotifier { final TransactionLineRepository _lineRepository; final TransactionCustomerRepository _customerRepository; final InventoryLookupRepository _lookupRepository; + final SaveApprovalDraftUseCase? _saveDraftUseCase; + final GetApprovalDraftUseCase? _getDraftUseCase; + final ListApprovalDraftsUseCase? _listDraftsUseCase; final List _fallbackStatusOptions; final List _transactionTypeKeywords; @@ -49,6 +63,7 @@ class OutboundController extends ChangeNotifier { String? _errorMessage; StockTransactionListFilter? _lastFilter; final Set _processingTransactionIds = {}; + StockTransactionApprovalInput? _approvalDraft; UnmodifiableListView get statusOptions => UnmodifiableListView(_statusOptions); @@ -73,6 +88,7 @@ class OutboundController extends ChangeNotifier { UnmodifiableSetView get processingTransactionIds => UnmodifiableSetView(_processingTransactionIds); + StockTransactionApprovalInput? get approvalDraft => _approvalDraft; /// 트랜잭션 상태 값을 불러온다. Future loadStatusOptions() async { @@ -119,6 +135,64 @@ class OutboundController extends ChangeNotifier { } } + /// 결재 구성 초안 상태를 갱신한다. + void updateApprovalDraft(StockTransactionApprovalInput approval) { + _approvalDraft = approval; + notifyListeners(); + _persistApprovalDraft(approval); + } + + /// 결재 구성 초안을 초기화한다. + void clearApprovalDraft() { + if (_approvalDraft == null) { + return; + } + _approvalDraft = null; + notifyListeners(); + } + + Future loadApprovalDraftFromServer({ + required int requesterId, + int? transactionId, + }) async { + final listUseCase = _listDraftsUseCase; + final getUseCase = _getDraftUseCase; + if (listUseCase == null || getUseCase == null) { + return; + } + try { + final result = await listUseCase.call( + ApprovalDraftListFilter( + requesterId: requesterId, + transactionId: transactionId, + pageSize: 10, + ), + ); + if (result.items.isEmpty) { + return; + } + final sessionKey = _draftSessionKey( + requesterId, + transactionId: transactionId, + ); + final summary = result.items.firstWhere( + (item) => item.sessionKey == sessionKey, + orElse: () => result.items.first, + ); + final detail = await getUseCase.call( + id: summary.id, + requesterId: requesterId, + ); + if (detail == null) { + return; + } + _approvalDraft = _toTransactionApproval(detail); + notifyListeners(); + } catch (error, stackTrace) { + debugPrint('[OutboundController] 초안 복구 실패: $error\n$stackTrace'); + } + } + /// 조건에 맞는 출고 트랜잭션 목록을 요청한다. Future fetchTransactions({ required StockTransactionListFilter filter, @@ -159,12 +233,91 @@ class OutboundController extends ChangeNotifier { await fetchTransactions(filter: target); } + void _persistApprovalDraft(StockTransactionApprovalInput approval) { + final useCase = _saveDraftUseCase; + if (useCase == null) { + return; + } + if (approval.steps.isEmpty) { + return; + } + final input = _buildApprovalDraftSaveInput(approval); + if (!input.hasSteps) { + return; + } + unawaited( + Future(() async { + try { + await useCase.call(input); + } catch (error, stackTrace) { + debugPrint('[OutboundController] 초안 저장 실패: $error\n$stackTrace'); + } + }), + ); + } + + ApprovalDraftSaveInput _buildApprovalDraftSaveInput( + StockTransactionApprovalInput approval, + ) { + final steps = approval.steps + .map( + (step) => ApprovalDraftStep( + stepOrder: step.stepOrder, + approverId: step.approverId, + note: step.note, + ), + ) + .toList(growable: false); + return ApprovalDraftSaveInput( + requesterId: approval.requestedById, + transactionId: null, + templateId: approval.templateId, + title: approval.title, + summary: approval.summary, + note: approval.note, + metadata: approval.metadata, + sessionKey: _draftSessionKey(approval.requestedById), + statusId: approval.approvalStatusId, + steps: steps, + ); + } + + String _draftSessionKey(int requesterId, {int? transactionId}) { + final base = 'inventory_outbound_$requesterId'; + if (transactionId == null) { + return base; + } + return '${base}_$transactionId'; + } + + StockTransactionApprovalInput _toTransactionApproval( + ApprovalDraftDetail detail, + ) { + final submission = detail.toSubmissionInput(defaultStatusId: null); + return StockTransactionApprovalInput( + requestedById: submission.requesterId, + approvalStatusId: submission.statusId == 0 ? null : submission.statusId, + templateId: submission.templateId, + finalApproverId: submission.finalApproverId, + requestedAt: submission.requestedAt, + decidedAt: submission.decidedAt, + cancelledAt: submission.cancelledAt, + lastActionAt: submission.lastActionAt, + title: submission.title, + summary: submission.summary, + note: submission.note, + metadata: submission.metadata, + steps: submission.steps, + ); + } + /// 출고 트랜잭션을 생성한다. Future createTransaction( StockTransactionCreateInput input, { bool refreshAfter = true, StockTransactionListFilter? refreshFilter, }) { + _approvalDraft = input.approval; return _executeMutation( () => _transactionRepository.create(input), refreshAfter: refreshAfter, diff --git a/lib/features/inventory/outbound/presentation/pages/outbound_page.dart b/lib/features/inventory/outbound/presentation/pages/outbound_page.dart index 87d51db..10ba5a9 100644 --- a/lib/features/inventory/outbound/presentation/pages/outbound_page.dart +++ b/lib/features/inventory/outbound/presentation/pages/outbound_page.dart @@ -16,6 +16,13 @@ import 'package:superport_v2/features/inventory/shared/widgets/product_autocompl import 'package:superport_v2/features/inventory/shared/widgets/employee_autocomplete_field.dart'; import 'package:superport_v2/features/inventory/shared/widgets/customer_multi_select_field.dart'; import 'package:superport_v2/features/inventory/shared/widgets/warehouse_select_field.dart'; +import 'package:superport_v2/features/approvals/request/presentation/controllers/approval_request_controller.dart'; +import 'package:superport_v2/features/approvals/request/presentation/utils/approval_form_initializer.dart'; +import 'package:superport_v2/features/approvals/request/presentation/widgets/approval_step_configurator.dart'; +import 'package:superport_v2/features/approvals/request/presentation/widgets/approval_template_picker.dart'; +import 'package:superport_v2/features/approvals/domain/usecases/get_approval_draft_use_case.dart'; +import 'package:superport_v2/features/approvals/domain/usecases/list_approval_drafts_use_case.dart'; +import 'package:superport_v2/features/approvals/domain/usecases/save_approval_draft_use_case.dart'; import 'package:superport_v2/core/config/environment.dart'; import 'package:superport_v2/core/network/failure.dart'; import 'package:superport_v2/core/common/utils/pagination_utils.dart'; @@ -31,6 +38,7 @@ import 'package:superport_v2/features/inventory/transactions/presentation/servic import 'package:superport_v2/features/inventory/transactions/presentation/widgets/transaction_detail_dialog.dart'; import 'package:superport_v2/features/masters/customer/domain/entities/customer.dart'; import 'package:superport_v2/features/masters/customer/domain/repositories/customer_repository.dart'; +import 'package:superport_v2/features/auth/application/auth_service.dart'; import '../../../lookups/domain/entities/lookup_item.dart'; import '../../../lookups/domain/repositories/inventory_lookup_repository.dart'; import '../widgets/outbound_detail_view.dart'; @@ -133,6 +141,15 @@ class _OutboundPageState extends State { lookupRepository: getIt(), fallbackStatusOptions: OutboundTableSpec.fallbackStatusOptions, transactionTypeKeywords: OutboundTableSpec.transactionTypeKeywords, + saveDraftUseCase: getIt.isRegistered() + ? getIt() + : null, + getDraftUseCase: getIt.isRegistered() + ? getIt() + : null, + listDraftsUseCase: getIt.isRegistered() + ? getIt() + : null, ); } @@ -143,6 +160,10 @@ class _OutboundPageState extends State { } Future.microtask(() async { await controller.loadStatusOptions(); + final requester = _resolveCurrentWriter(); + if (requester != null) { + await controller.loadApprovalDraftFromServer(requesterId: requester.id); + } final hasType = await controller.resolveTransactionType(); if (!mounted) { return; @@ -213,6 +234,26 @@ class _OutboundPageState extends State { } } + InventoryEmployeeSuggestion? _resolveCurrentWriter() { + final getIt = GetIt.I; + if (!getIt.isRegistered()) { + return null; + } + final session = getIt().session; + final user = session?.user; + if (user == null) { + return null; + } + final employeeNo = (user.employeeNo ?? '').trim().isEmpty + ? user.id.toString() + : user.employeeNo!.trim(); + return InventoryEmployeeSuggestion( + id: user.id, + employeeNo: employeeNo, + name: user.name, + ); + } + void _handleControllerChanged() { if (!mounted) { return; @@ -1556,6 +1597,25 @@ class _OutboundPageState extends State { return '${suggestion.name} (${suggestion.employeeNo})'; } + final approvalController = ApprovalRequestController(); + final defaultRequester = () { + final writer = writerSelection; + if (writer == null) { + return null; + } + return ApprovalRequestParticipant( + id: writer.id, + name: writer.name, + employeeNo: writer.employeeNo, + ); + }(); + ApprovalFormInitializer.populate( + controller: approvalController, + existingApproval: initial?.raw?.approval, + draft: _controller?.approvalDraft, + defaultRequester: defaultRequester, + ); + final writerController = TextEditingController( text: writerLabel(writerSelection), ); @@ -1814,6 +1874,23 @@ class _OutboundPageState extends State { ) .toList(growable: false); + StockTransactionApprovalInput approvalInput; + try { + approvalInput = approvalController.buildTransactionApprovalInput( + approvalStatusId: null, + note: approvalNoteValue.isEmpty ? null : approvalNoteValue, + ); + } on StateError catch (error) { + updateSaving(false); + SuperportToast.error(context, error.message); + return; + } catch (_) { + updateSaving(false); + SuperportToast.error(context, '결재 구성을 확인하고 다시 시도하세요.'); + return; + } + controller.updateApprovalDraft(approvalInput); + final created = await controller.createTransaction( StockTransactionCreateInput( transactionTypeId: transactionTypeLookup.id, @@ -1824,10 +1901,7 @@ class _OutboundPageState extends State { note: remarkValue, lines: createLines, customers: createCustomers, - approval: StockTransactionApprovalInput( - requestedById: createdById, - note: approvalNoteValue.isEmpty ? null : approvalNoteValue, - ), + approval: approvalInput, ), ); result = created; @@ -2011,6 +2085,15 @@ class _OutboundPageState extends State { enabled: initial == null, onSuggestionSelected: (suggestion) { writerSelection = suggestion; + approvalController.setRequester( + suggestion == null + ? null + : ApprovalRequestParticipant( + id: suggestion.id, + name: suggestion.name, + employeeNo: suggestion.employeeNo, + ), + ); if (writerError != null) { setState(() { writerError = null; @@ -2026,6 +2109,7 @@ class _OutboundPageState extends State { if (currentText.isEmpty || currentText != selectedLabel) { writerSelection = null; + approvalController.setRequester(null); } if (writerError != null) { setState(() { @@ -2099,6 +2183,15 @@ class _OutboundPageState extends State { ), ], ), + const SizedBox(height: 16), + if (initial == null) ...[ + ApprovalTemplatePicker(controller: approvalController), + const SizedBox(height: 16), + ], + ApprovalStepConfigurator( + controller: approvalController, + readOnly: initial != null, + ), const SizedBox(height: 24), if (headerNotice != null) Padding( @@ -2215,6 +2308,7 @@ class _OutboundPageState extends State { transactionTypeController.dispose(); approvalNoteController.dispose(); processedAt.dispose(); + approvalController.dispose(); return result; } diff --git a/lib/features/inventory/outbound/presentation/widgets/outbound_detail_view.dart b/lib/features/inventory/outbound/presentation/widgets/outbound_detail_view.dart index e175869..68f19d2 100644 --- a/lib/features/inventory/outbound/presentation/widgets/outbound_detail_view.dart +++ b/lib/features/inventory/outbound/presentation/widgets/outbound_detail_view.dart @@ -27,10 +27,7 @@ class OutboundDetailView extends StatelessWidget { children: [ if (!transitionsEnabled) ...[ ShadBadge.outline( - child: Text( - '재고 상태 전이가 비활성화된 상태입니다.', - style: theme.textTheme.small, - ), + child: Text('재고 상태 전이가 비활성화된 상태입니다.', style: theme.textTheme.small), ), const SizedBox(height: 16), ], @@ -76,8 +73,10 @@ class OutboundDetailView extends StatelessWidget { for (final customer in record.customers) ShadBadge( child: Padding( - padding: - const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), child: Text('${customer.name} · ${customer.code}'), ), ), @@ -111,9 +110,7 @@ class OutboundDetailView extends StatelessWidget { ShadTableCell(child: Text(item.manufacturer)), ShadTableCell(child: Text(item.unit)), ShadTableCell(child: Text('${item.quantity}')), - ShadTableCell( - child: Text(currencyFormatter.format(item.price)), - ), + ShadTableCell(child: Text(currencyFormatter.format(item.price))), ShadTableCell( child: Text(item.remark.isEmpty ? '-' : item.remark), ), @@ -146,13 +143,13 @@ class _DetailChip extends StatelessWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: [ - Text(label, style: theme.textTheme.small, textAlign: TextAlign.center), - const SizedBox(height: 4), Text( - value, - style: theme.textTheme.p, + label, + style: theme.textTheme.small, textAlign: TextAlign.center, ), + const SizedBox(height: 4), + Text(value, style: theme.textTheme.p, textAlign: TextAlign.center), ], ), ); diff --git a/lib/features/inventory/rental/domain/entities/create_rental_request_input.dart b/lib/features/inventory/rental/domain/entities/create_rental_request_input.dart new file mode 100644 index 0000000..9bd42e9 --- /dev/null +++ b/lib/features/inventory/rental/domain/entities/create_rental_request_input.dart @@ -0,0 +1,46 @@ +import '../../../transactions/domain/entities/stock_transaction_input.dart'; + +/// 대여 생성 요청 입력 값. +/// +/// - 대여 반납 예정일과 결재 구성을 포함한다. +class CreateRentalRequestInput { + CreateRentalRequestInput({ + required this.transactionTypeId, + required this.transactionStatusId, + required this.warehouseId, + required this.transactionDate, + required this.createdById, + required this.approval, + this.expectedReturnDate, + this.note, + this.lines = const [], + this.customers = const [], + }); + + final int transactionTypeId; + final int transactionStatusId; + final int warehouseId; + final DateTime transactionDate; + final int createdById; + final DateTime? expectedReturnDate; + final String? note; + final List lines; + final List customers; + final StockTransactionApprovalInput approval; + + /// 재고 트랜잭션 입력 모델로 변환한다. + StockTransactionCreateInput toTransactionInput() { + return StockTransactionCreateInput( + transactionTypeId: transactionTypeId, + transactionStatusId: transactionStatusId, + warehouseId: warehouseId, + transactionDate: transactionDate, + createdById: createdById, + note: note, + expectedReturnDate: expectedReturnDate, + lines: lines, + customers: customers, + approval: approval, + ); + } +} diff --git a/lib/features/inventory/rental/presentation/controllers/rental_controller.dart b/lib/features/inventory/rental/presentation/controllers/rental_controller.dart index 02aa295..3179897 100644 --- a/lib/features/inventory/rental/presentation/controllers/rental_controller.dart +++ b/lib/features/inventory/rental/presentation/controllers/rental_controller.dart @@ -1,8 +1,13 @@ +import 'dart:async'; 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 'package:superport_v2/features/approvals/domain/entities/approval_draft.dart'; +import 'package:superport_v2/features/approvals/domain/usecases/get_approval_draft_use_case.dart'; +import 'package:superport_v2/features/approvals/domain/usecases/list_approval_drafts_use_case.dart'; +import 'package:superport_v2/features/approvals/domain/usecases/save_approval_draft_use_case.dart'; import 'package:superport_v2/features/inventory/lookups/domain/entities/lookup_item.dart'; import 'package:superport_v2/features/inventory/lookups/domain/repositories/inventory_lookup_repository.dart'; import 'package:superport_v2/features/inventory/rental/presentation/models/rental_record.dart'; @@ -21,10 +26,16 @@ class RentalController extends ChangeNotifier { List fallbackStatusOptions = const ['대여중', '반납대기', '완료'], List rentTransactionKeywords = const ['대여', 'rent'], List returnTransactionKeywords = const ['반납', 'return'], + SaveApprovalDraftUseCase? saveDraftUseCase, + GetApprovalDraftUseCase? getDraftUseCase, + ListApprovalDraftsUseCase? listDraftsUseCase, }) : _transactionRepository = transactionRepository, _lineRepository = lineRepository, _customerRepository = customerRepository, _lookupRepository = lookupRepository, + _saveDraftUseCase = saveDraftUseCase, + _getDraftUseCase = getDraftUseCase, + _listDraftsUseCase = listDraftsUseCase, _fallbackStatusOptions = List.unmodifiable( fallbackStatusOptions, ), @@ -41,6 +52,9 @@ class RentalController extends ChangeNotifier { final TransactionLineRepository _lineRepository; final TransactionCustomerRepository _customerRepository; final InventoryLookupRepository _lookupRepository; + final SaveApprovalDraftUseCase? _saveDraftUseCase; + final GetApprovalDraftUseCase? _getDraftUseCase; + final ListApprovalDraftsUseCase? _listDraftsUseCase; final List _fallbackStatusOptions; final List _rentTransactionKeywords; final List _returnTransactionKeywords; @@ -56,6 +70,7 @@ class RentalController extends ChangeNotifier { StockTransactionListFilter? _lastFilter; final Set _processingTransactionIds = {}; bool _lastFilterByRentalTypes = true; + StockTransactionApprovalInput? _approvalDraft; UnmodifiableListView get statusOptions => UnmodifiableListView(_statusOptions); @@ -84,6 +99,7 @@ class RentalController extends ChangeNotifier { UnmodifiableSetView(_processingTransactionIds); bool get lastFilterByRentalTypes => _lastFilterByRentalTypes; + StockTransactionApprovalInput? get approvalDraft => _approvalDraft; /// 트랜잭션 상태 목록을 조회한다. Future loadStatusOptions() async { @@ -132,6 +148,64 @@ class RentalController extends ChangeNotifier { } } + /// 결재 구성 초안 상태를 갱신한다. + void updateApprovalDraft(StockTransactionApprovalInput approval) { + _approvalDraft = approval; + notifyListeners(); + _persistApprovalDraft(approval); + } + + /// 결재 구성 초안을 초기화한다. + void clearApprovalDraft() { + if (_approvalDraft == null) { + return; + } + _approvalDraft = null; + notifyListeners(); + } + + Future loadApprovalDraftFromServer({ + required int requesterId, + int? transactionId, + }) async { + final listUseCase = _listDraftsUseCase; + final getUseCase = _getDraftUseCase; + if (listUseCase == null || getUseCase == null) { + return; + } + try { + final result = await listUseCase.call( + ApprovalDraftListFilter( + requesterId: requesterId, + transactionId: transactionId, + pageSize: 10, + ), + ); + if (result.items.isEmpty) { + return; + } + final sessionKey = _draftSessionKey( + requesterId, + transactionId: transactionId, + ); + final summary = result.items.firstWhere( + (item) => item.sessionKey == sessionKey, + orElse: () => result.items.first, + ); + final detail = await getUseCase.call( + id: summary.id, + requesterId: requesterId, + ); + if (detail == null) { + return; + } + _approvalDraft = _toTransactionApproval(detail); + notifyListeners(); + } catch (error, stackTrace) { + debugPrint('[RentalController] 초안 복구 실패: $error\n$stackTrace'); + } + } + /// 필터 조건에 맞는 대여/반납 트랜잭션을 조회한다. Future fetchTransactions({ required StockTransactionListFilter filter, @@ -188,6 +262,84 @@ class RentalController extends ChangeNotifier { ); } + void _persistApprovalDraft(StockTransactionApprovalInput approval) { + final useCase = _saveDraftUseCase; + if (useCase == null) { + return; + } + if (approval.steps.isEmpty) { + return; + } + final input = _buildApprovalDraftSaveInput(approval); + if (!input.hasSteps) { + return; + } + unawaited( + Future(() async { + try { + await useCase.call(input); + } catch (error, stackTrace) { + debugPrint('[RentalController] 초안 저장 실패: $error\n$stackTrace'); + } + }), + ); + } + + ApprovalDraftSaveInput _buildApprovalDraftSaveInput( + StockTransactionApprovalInput approval, + ) { + final steps = approval.steps + .map( + (step) => ApprovalDraftStep( + stepOrder: step.stepOrder, + approverId: step.approverId, + note: step.note, + ), + ) + .toList(growable: false); + return ApprovalDraftSaveInput( + requesterId: approval.requestedById, + transactionId: null, + templateId: approval.templateId, + title: approval.title, + summary: approval.summary, + note: approval.note, + metadata: approval.metadata, + sessionKey: _draftSessionKey(approval.requestedById), + statusId: approval.approvalStatusId, + steps: steps, + ); + } + + String _draftSessionKey(int requesterId, {int? transactionId}) { + final base = 'inventory_rental_$requesterId'; + if (transactionId == null) { + return base; + } + return '${base}_$transactionId'; + } + + StockTransactionApprovalInput _toTransactionApproval( + ApprovalDraftDetail detail, + ) { + final submission = detail.toSubmissionInput(defaultStatusId: null); + return StockTransactionApprovalInput( + requestedById: submission.requesterId, + approvalStatusId: submission.statusId == 0 ? null : submission.statusId, + templateId: submission.templateId, + finalApproverId: submission.finalApproverId, + requestedAt: submission.requestedAt, + decidedAt: submission.decidedAt, + cancelledAt: submission.cancelledAt, + lastActionAt: submission.lastActionAt, + title: submission.title, + summary: submission.summary, + note: submission.note, + metadata: submission.metadata, + steps: submission.steps, + ); + } + /// 대여/반납 트랜잭션을 생성한다. Future createTransaction( StockTransactionCreateInput input, { @@ -195,6 +347,7 @@ class RentalController extends ChangeNotifier { StockTransactionListFilter? refreshFilter, bool? refreshFilterByRentalTypes, }) { + _approvalDraft = input.approval; return _executeMutation( () => _transactionRepository.create(input), refreshAfter: refreshAfter, diff --git a/lib/features/inventory/rental/presentation/pages/rental_page.dart b/lib/features/inventory/rental/presentation/pages/rental_page.dart index deb85d0..d8ba81c 100644 --- a/lib/features/inventory/rental/presentation/pages/rental_page.dart +++ b/lib/features/inventory/rental/presentation/pages/rental_page.dart @@ -17,6 +17,13 @@ import 'package:superport_v2/features/inventory/shared/widgets/product_autocompl import 'package:superport_v2/features/inventory/shared/widgets/employee_autocomplete_field.dart'; import 'package:superport_v2/features/inventory/shared/widgets/customer_multi_select_field.dart'; import 'package:superport_v2/features/inventory/shared/widgets/warehouse_select_field.dart'; +import 'package:superport_v2/features/approvals/request/presentation/controllers/approval_request_controller.dart'; +import 'package:superport_v2/features/approvals/request/presentation/utils/approval_form_initializer.dart'; +import 'package:superport_v2/features/approvals/request/presentation/widgets/approval_step_configurator.dart'; +import 'package:superport_v2/features/approvals/request/presentation/widgets/approval_template_picker.dart'; +import 'package:superport_v2/features/approvals/domain/usecases/get_approval_draft_use_case.dart'; +import 'package:superport_v2/features/approvals/domain/usecases/list_approval_drafts_use_case.dart'; +import 'package:superport_v2/features/approvals/domain/usecases/save_approval_draft_use_case.dart'; import 'package:superport_v2/core/config/environment.dart'; import 'package:superport_v2/core/network/failure.dart'; import 'package:superport_v2/core/permissions/permission_manager.dart'; @@ -29,6 +36,7 @@ import 'package:superport_v2/features/inventory/transactions/domain/entities/sto import 'package:superport_v2/features/inventory/transactions/domain/repositories/stock_transaction_repository.dart'; import 'package:superport_v2/features/inventory/transactions/presentation/services/transaction_detail_sync_service.dart'; import 'package:superport_v2/features/inventory/transactions/presentation/widgets/transaction_detail_dialog.dart'; +import 'package:superport_v2/features/auth/application/auth_service.dart'; import '../../../lookups/domain/entities/lookup_item.dart'; import '../../../lookups/domain/repositories/inventory_lookup_repository.dart'; import '../widgets/rental_detail_view.dart'; @@ -130,6 +138,15 @@ class _RentalPageState extends State { fallbackStatusOptions: RentalTableSpec.fallbackStatusOptions, rentTransactionKeywords: RentalTableSpec.rentTransactionKeywords, returnTransactionKeywords: RentalTableSpec.returnTransactionKeywords, + saveDraftUseCase: getIt.isRegistered() + ? getIt() + : null, + getDraftUseCase: getIt.isRegistered() + ? getIt() + : null, + listDraftsUseCase: getIt.isRegistered() + ? getIt() + : null, ); } @@ -140,6 +157,10 @@ class _RentalPageState extends State { } Future.microtask(() async { await controller.loadStatusOptions(); + final requester = _resolveCurrentWriter(); + if (requester != null) { + await controller.loadApprovalDraftFromServer(requesterId: requester.id); + } final hasTypes = await controller.resolveTransactionTypes(); if (!mounted) { return; @@ -219,6 +240,26 @@ class _RentalPageState extends State { } } + InventoryEmployeeSuggestion? _resolveCurrentWriter() { + final getIt = GetIt.I; + if (!getIt.isRegistered()) { + return null; + } + final session = getIt().session; + final user = session?.user; + if (user == null) { + return null; + } + final employeeNo = (user.employeeNo ?? '').trim().isEmpty + ? user.id.toString() + : user.employeeNo!.trim(); + return InventoryEmployeeSuggestion( + id: user.id, + employeeNo: employeeNo, + name: user.name, + ); + } + @override void didUpdateWidget(covariant RentalPage oldWidget) { super.didUpdateWidget(oldWidget); @@ -1538,6 +1579,25 @@ class _RentalPageState extends State { return '${suggestion.name} (${suggestion.employeeNo})'; } + final approvalController = ApprovalRequestController(); + final defaultRequester = () { + final writer = writerSelection; + if (writer == null) { + return null; + } + return ApprovalRequestParticipant( + id: writer.id, + name: writer.name, + employeeNo: writer.employeeNo, + ); + }(); + ApprovalFormInitializer.populate( + controller: approvalController, + existingApproval: initial?.raw?.approval, + draft: _controller?.approvalDraft, + defaultRequester: defaultRequester, + ); + final writerController = TextEditingController( text: writerLabel(writerSelection), ); @@ -1792,6 +1852,23 @@ class _RentalPageState extends State { ) .toList(growable: false); + StockTransactionApprovalInput approvalInput; + try { + approvalInput = approvalController.buildTransactionApprovalInput( + approvalStatusId: null, + note: approvalNoteValue.isEmpty ? null : approvalNoteValue, + ); + } on StateError catch (error) { + updateSaving(false); + SuperportToast.error(context, error.message); + return; + } catch (_) { + updateSaving(false); + SuperportToast.error(context, '결재 구성을 확인하고 다시 시도하세요.'); + return; + } + controller.updateApprovalDraft(approvalInput); + final transactionTypeId = selectedLookup.id; final created = await controller.createTransaction( StockTransactionCreateInput( @@ -1804,10 +1881,7 @@ class _RentalPageState extends State { expectedReturnDate: returnDue.value, lines: createLines, customers: createCustomers, - approval: StockTransactionApprovalInput( - requestedById: createdById, - note: approvalNoteValue.isEmpty ? null : approvalNoteValue, - ), + approval: approvalInput, ), ); result = created; @@ -2093,6 +2167,15 @@ class _RentalPageState extends State { enabled: initial == null, onSuggestionSelected: (suggestion) { writerSelection = suggestion; + approvalController.setRequester( + suggestion == null + ? null + : ApprovalRequestParticipant( + id: suggestion.id, + name: suggestion.name, + employeeNo: suggestion.employeeNo, + ), + ); if (writerError != null) { setState(() { writerError = null; @@ -2109,6 +2192,7 @@ class _RentalPageState extends State { if (currentText.isEmpty || currentText != selectedLabel) { writerSelection = null; + approvalController.setRequester(null); } if (writerError != null) { setState(() { @@ -2154,6 +2238,15 @@ class _RentalPageState extends State { ), ], ), + const SizedBox(height: 16), + if (initial == null) ...[ + ApprovalTemplatePicker(controller: approvalController), + const SizedBox(height: 16), + ], + ApprovalStepConfigurator( + controller: approvalController, + readOnly: initial != null, + ), const SizedBox(height: 24), Wrap( spacing: 12, @@ -2274,6 +2367,7 @@ class _RentalPageState extends State { approvalNoteController.dispose(); processedAt.dispose(); returnDue.dispose(); + approvalController.dispose(); return result; } diff --git a/lib/features/inventory/rental/presentation/widgets/rental_detail_view.dart b/lib/features/inventory/rental/presentation/widgets/rental_detail_view.dart index fe799a8..69803a3 100644 --- a/lib/features/inventory/rental/presentation/widgets/rental_detail_view.dart +++ b/lib/features/inventory/rental/presentation/widgets/rental_detail_view.dart @@ -27,10 +27,7 @@ class RentalDetailView extends StatelessWidget { children: [ if (!transitionsEnabled) ...[ ShadBadge.outline( - child: Text( - '재고 상태 전이가 비활성화된 상태입니다.', - style: theme.textTheme.small, - ), + child: Text('재고 상태 전이가 비활성화된 상태입니다.', style: theme.textTheme.small), ), const SizedBox(height: 16), ], @@ -73,8 +70,10 @@ class RentalDetailView extends StatelessWidget { for (final customer in record.customers) ShadBadge( child: Padding( - padding: - const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), child: Text('${customer.name} · ${customer.code}'), ), ), @@ -108,9 +107,7 @@ class RentalDetailView extends StatelessWidget { ShadTableCell(child: Text(item.manufacturer)), ShadTableCell(child: Text(item.unit)), ShadTableCell(child: Text('${item.quantity}')), - ShadTableCell( - child: Text(currencyFormatter.format(item.price)), - ), + ShadTableCell(child: Text(currencyFormatter.format(item.price))), ShadTableCell( child: Text(item.remark.isEmpty ? '-' : item.remark), ), @@ -143,13 +140,13 @@ class _DetailChip extends StatelessWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: [ - Text(label, style: theme.textTheme.small, textAlign: TextAlign.center), - const SizedBox(height: 4), Text( - value, - style: theme.textTheme.p, + label, + style: theme.textTheme.small, textAlign: TextAlign.center, ), + const SizedBox(height: 4), + Text(value, style: theme.textTheme.p, textAlign: TextAlign.center), ], ), ); diff --git a/lib/features/inventory/transactions/data/dtos/stock_transaction_dto.dart b/lib/features/inventory/transactions/data/dtos/stock_transaction_dto.dart index a0808c6..2b9ab2f 100644 --- a/lib/features/inventory/transactions/data/dtos/stock_transaction_dto.dart +++ b/lib/features/inventory/transactions/data/dtos/stock_transaction_dto.dart @@ -1,5 +1,6 @@ import 'package:superport_v2/core/common/models/paginated_result.dart'; import 'package:superport_v2/core/common/utils/json_utils.dart'; +import 'package:superport_v2/features/approvals/data/dtos/approval_dto.dart'; import '../../domain/entities/stock_transaction.dart'; @@ -38,7 +39,7 @@ class StockTransactionDto { final DateTime? updatedAt; final List lines; final List customers; - final StockTransactionApprovalSummary? approval; + final ApprovalDto? approval; final DateTime? expectedReturnDate; /// JSON 객체를 DTO로 변환한다. @@ -86,7 +87,7 @@ class StockTransactionDto { updatedAt: updatedAt, lines: lines, customers: customers, - approval: approval, + approval: approval?.toEntity(), expectedReturnDate: expectedReturnDate, ); } @@ -328,43 +329,21 @@ StockTransactionCustomerSummary _parseCustomer( ); } -StockTransactionApprovalSummary? _parseApproval(dynamic raw) { +ApprovalDto? _parseApproval(dynamic raw) { final map = _mapOrEmpty(raw); if (map.isEmpty) { return null; } - final statusMap = _firstNonEmptyMap([map['approval_status'], map['status']]); - return StockTransactionApprovalSummary( - id: map['id'] as int? ?? 0, - approvalNo: - map['approval_no'] as String? ?? map['approvalNo'] as String? ?? '', - status: statusMap.isEmpty - ? null - : StockTransactionApprovalStatusSummary( - id: statusMap['id'] as int? ?? statusMap['status_id'] as int? ?? 0, - name: - statusMap['name'] as String? ?? - statusMap['status_name'] as String? ?? - '-', - isBlocking: - statusMap['is_blocking_next'] as bool? ?? - statusMap['isBlocking'] as bool?, - ), - ); + try { + return ApprovalDto.fromJson(map); + } catch (_) { + return null; + } } Map _mapOrEmpty(dynamic value) => value is Map ? value : const {}; -Map _firstNonEmptyMap(List candidates) { - for (final candidate in candidates) { - if (candidate is Map && candidate.isNotEmpty) { - return candidate; - } - } - return const {}; -} - int _readQuantity(Object? value) { if (value is int) { return value; diff --git a/lib/features/inventory/transactions/data/repositories/transaction_line_repository_remote.dart b/lib/features/inventory/transactions/data/repositories/transaction_line_repository_remote.dart index 2c152c5..b9ec842 100644 --- a/lib/features/inventory/transactions/data/repositories/transaction_line_repository_remote.dart +++ b/lib/features/inventory/transactions/data/repositories/transaction_line_repository_remote.dart @@ -49,9 +49,6 @@ class TransactionLineRepositoryRemote implements TransactionLineRepository { @override Future restoreLine(int lineId) async { - await _api.post( - '$_linePath/$lineId/restore', - ); + await _api.post('$_linePath/$lineId/restore'); } - } diff --git a/lib/features/inventory/transactions/domain/entities/stock_transaction.dart b/lib/features/inventory/transactions/domain/entities/stock_transaction.dart index ef60cc3..0d6db0f 100644 --- a/lib/features/inventory/transactions/domain/entities/stock_transaction.dart +++ b/lib/features/inventory/transactions/domain/entities/stock_transaction.dart @@ -1,3 +1,5 @@ +import 'package:superport_v2/features/approvals/domain/entities/approval.dart'; + /// 재고 트랜잭션 도메인 엔티티 /// /// - 입고/출고/대여 공통으로 사용되는 헤더와 라인, 고객 연결 정보를 포함한다. @@ -33,7 +35,7 @@ class StockTransaction { final DateTime? updatedAt; final List lines; final List customers; - final StockTransactionApprovalSummary? approval; + final Approval? approval; final DateTime? expectedReturnDate; int get itemCount => lines.length; @@ -57,7 +59,7 @@ class StockTransaction { DateTime? updatedAt, List? lines, List? customers, - StockTransactionApprovalSummary? approval, + Approval? approval, DateTime? expectedReturnDate, }) { return StockTransaction( @@ -200,32 +202,6 @@ class StockTransactionUomSummary { final String name; } -/// 결재 요약 정보 -class StockTransactionApprovalSummary { - StockTransactionApprovalSummary({ - required this.id, - required this.approvalNo, - this.status, - }); - - final int id; - final String approvalNo; - final StockTransactionApprovalStatusSummary? status; -} - -/// 결재 상태 요약 정보 -class StockTransactionApprovalStatusSummary { - StockTransactionApprovalStatusSummary({ - required this.id, - required this.name, - this.isBlocking, - }); - - final int id; - final String name; - final bool? isBlocking; -} - extension StockTransactionLineX on List { /// 라인 품목 가격 총액을 계산한다. double get totalAmount => diff --git a/lib/features/inventory/transactions/domain/entities/stock_transaction_input.dart b/lib/features/inventory/transactions/domain/entities/stock_transaction_input.dart index c82402c..f39686f 100644 --- a/lib/features/inventory/transactions/domain/entities/stock_transaction_input.dart +++ b/lib/features/inventory/transactions/domain/entities/stock_transaction_input.dart @@ -1,3 +1,5 @@ +import 'package:superport_v2/features/approvals/domain/entities/approval.dart'; + /// 재고 트랜잭션 생성 입력 모델. class StockTransactionCreateInput { StockTransactionCreateInput({ @@ -10,7 +12,7 @@ class StockTransactionCreateInput { this.expectedReturnDate, this.lines = const [], this.customers = const [], - this.approval, + required this.approval, }); final int transactionTypeId; @@ -22,7 +24,7 @@ class StockTransactionCreateInput { final DateTime? expectedReturnDate; final List lines; final List customers; - final StockTransactionApprovalInput? approval; + final StockTransactionApprovalInput approval; Map toPayload() { final sanitizedNote = note?.trim(); @@ -43,7 +45,7 @@ class StockTransactionCreateInput { 'expected_return_date': _formatNaiveDate(expectedReturnDate!), 'lines': linePayloads, 'customers': customerPayloads, - if (approval != null) 'approval': approval!.toJson(), + 'approval': approval.toJson(), }; } } @@ -213,20 +215,59 @@ class StockTransactionApprovalInput { StockTransactionApprovalInput({ required this.requestedById, this.approvalStatusId, + this.templateId, + this.finalApproverId, + this.requestedAt, + this.decidedAt, + this.cancelledAt, + this.lastActionAt, + this.title, + this.summary, this.note, + this.metadata, + this.steps = const [], }); final int requestedById; final int? approvalStatusId; + final int? templateId; + final int? finalApproverId; + final DateTime? requestedAt; + final DateTime? decidedAt; + final DateTime? cancelledAt; + final DateTime? lastActionAt; + final String? title; + final String? summary; final String? note; + final Map? metadata; + final List steps; Map toJson() { final trimmedNote = note?.trim(); - return { - if (approvalStatusId != null) 'approval_status_id': approvalStatusId, + final trimmedTitle = title?.trim(); + final trimmedSummary = summary?.trim(); + final payload = { 'requested_by_id': requestedById, + if (approvalStatusId != null) 'approval_status_id': approvalStatusId, + if (templateId != null) 'template_id': templateId, + if (finalApproverId != null) 'final_approver_id': finalApproverId, + if (requestedAt != null) 'requested_at': _formatIsoUtc(requestedAt!), + if (decidedAt != null) 'decided_at': _formatIsoUtc(decidedAt!), + if (cancelledAt != null) 'cancelled_at': _formatIsoUtc(cancelledAt!), + if (lastActionAt != null) 'last_action_at': _formatIsoUtc(lastActionAt!), + if (trimmedTitle != null && trimmedTitle.isNotEmpty) + 'title': trimmedTitle, + if (trimmedSummary != null && trimmedSummary.isNotEmpty) + 'summary': trimmedSummary, if (trimmedNote != null && trimmedNote.isNotEmpty) 'note': trimmedNote, + if (metadata != null && metadata!.isNotEmpty) 'metadata': metadata, }; + if (steps.isNotEmpty) { + payload['steps'] = steps + .map((item) => _mapApprovalStep(item)) + .toList(growable: false); + } + return payload; } } @@ -236,3 +277,14 @@ String _formatNaiveDate(DateTime value) { final day = value.day.toString().padLeft(2, '0'); return '$year-$month-$day'; } + +String _formatIsoUtc(DateTime value) => value.toUtc().toIso8601String(); + +Map _mapApprovalStep(ApprovalStepAssignmentItem item) { + final trimmedNote = item.note?.trim(); + return { + 'step_order': item.stepOrder, + 'approver_id': item.approverId, + if (trimmedNote != null && trimmedNote.isNotEmpty) 'note': trimmedNote, + }; +} diff --git a/lib/injection_container.dart b/lib/injection_container.dart index ef5b17c..0a44ceb 100644 --- a/lib/injection_container.dart +++ b/lib/injection_container.dart @@ -7,6 +7,27 @@ import 'core/network/api_client.dart'; import 'core/network/api_error.dart'; import 'core/network/interceptors/auth_interceptor.dart'; import 'core/services/token_storage.dart'; +import 'features/approvals/data/repositories/approval_draft_repository_remote.dart'; +import 'features/approvals/data/repositories/approval_repository_remote.dart'; +import 'features/approvals/data/repositories/approval_template_repository_remote.dart'; +import 'features/approvals/domain/repositories/approval_draft_repository.dart'; +import 'features/approvals/domain/repositories/approval_repository.dart'; +import 'features/approvals/domain/repositories/approval_template_repository.dart'; +import 'features/approvals/domain/usecases/apply_approval_template_use_case.dart'; +import 'features/approvals/domain/usecases/approve_approval_use_case.dart'; +import 'features/approvals/domain/usecases/delete_approval_draft_use_case.dart'; +import 'features/approvals/domain/usecases/get_approval_draft_use_case.dart'; +import 'features/approvals/domain/usecases/list_approval_drafts_use_case.dart'; +import 'features/approvals/domain/usecases/recall_approval_use_case.dart'; +import 'features/approvals/domain/usecases/reject_approval_use_case.dart'; +import 'features/approvals/domain/usecases/resubmit_approval_use_case.dart'; +import 'features/approvals/domain/usecases/save_approval_draft_use_case.dart'; +import 'features/approvals/domain/usecases/save_approval_template_use_case.dart'; +import 'features/approvals/domain/usecases/submit_approval_use_case.dart'; +import 'features/approvals/history/data/repositories/approval_history_repository_remote.dart'; +import 'features/approvals/history/domain/repositories/approval_history_repository.dart'; +import 'features/approvals/step/data/repositories/approval_step_repository_remote.dart'; +import 'features/approvals/step/domain/repositories/approval_step_repository.dart'; import 'features/auth/application/auth_service.dart'; import 'features/auth/data/repositories/auth_repository_remote.dart'; import 'features/auth/domain/repositories/auth_repository.dart'; @@ -22,32 +43,24 @@ import 'features/masters/customer/data/repositories/customer_repository_remote.d import 'features/masters/customer/domain/repositories/customer_repository.dart'; import 'features/masters/group/data/repositories/group_repository_remote.dart'; import 'features/masters/group/domain/repositories/group_repository.dart'; -import 'features/masters/menu/data/repositories/menu_repository_remote.dart'; -import 'features/masters/menu/domain/repositories/menu_repository.dart'; import 'features/masters/group_permission/data/repositories/group_permission_repository_remote.dart'; import 'features/masters/group_permission/domain/repositories/group_permission_repository.dart'; +import 'features/masters/menu/data/repositories/menu_repository_remote.dart'; +import 'features/masters/menu/domain/repositories/menu_repository.dart'; import 'features/masters/product/data/repositories/product_repository_remote.dart'; import 'features/masters/product/domain/repositories/product_repository.dart'; +import 'features/masters/uom/data/repositories/uom_repository_remote.dart'; +import 'features/masters/uom/domain/repositories/uom_repository.dart'; import 'features/masters/user/data/repositories/user_repository_remote.dart'; import 'features/masters/user/domain/repositories/user_repository.dart'; import 'features/masters/vendor/data/repositories/vendor_repository_remote.dart'; import 'features/masters/vendor/domain/repositories/vendor_repository.dart'; import 'features/masters/warehouse/data/repositories/warehouse_repository_remote.dart'; import 'features/masters/warehouse/domain/repositories/warehouse_repository.dart'; -import 'features/masters/uom/data/repositories/uom_repository_remote.dart'; -import 'features/masters/uom/domain/repositories/uom_repository.dart'; -import 'features/approvals/data/repositories/approval_repository_remote.dart'; -import 'features/approvals/data/repositories/approval_template_repository_remote.dart'; -import 'features/approvals/history/data/repositories/approval_history_repository_remote.dart'; -import 'features/approvals/history/domain/repositories/approval_history_repository.dart'; -import 'features/approvals/step/data/repositories/approval_step_repository_remote.dart'; -import 'features/approvals/domain/repositories/approval_repository.dart'; -import 'features/approvals/domain/repositories/approval_template_repository.dart'; -import 'features/approvals/step/domain/repositories/approval_step_repository.dart'; -import 'features/util/postal_search/data/repositories/postal_search_repository_remote.dart'; -import 'features/util/postal_search/domain/repositories/postal_search_repository.dart'; import 'features/reporting/data/repositories/reporting_repository_remote.dart'; import 'features/reporting/domain/repositories/reporting_repository.dart'; +import 'features/util/postal_search/data/repositories/postal_search_repository_remote.dart'; +import 'features/util/postal_search/domain/repositories/postal_search_repository.dart'; /// 전역 DI 컨테이너 final GetIt sl = GetIt.instance; @@ -69,10 +82,12 @@ Future initInjection({ final dio = Dio(options); - // 인터셉터 등록 (Auth 등) final tokenStorage = createTokenStorage(); - sl.registerLazySingleton(() => tokenStorage); - sl.registerLazySingleton(ApiErrorMapper.new); + sl + ..registerSingleton(tokenStorage) + ..registerLazySingleton(ApiErrorMapper.new); + + // 인터셉터 등록 (Auth 등) final authInterceptor = AuthInterceptor( tokenStorage: tokenStorage, @@ -89,99 +104,154 @@ Future initInjection({ // 개발용 로거는 필요 시 추가 (pretty_dio_logger 등) // if (!kReleaseMode) { dio.interceptors.add(PrettyDioLogger(...)); } + sl.registerSingleton(dio); + // ApiClient 등록 sl.registerLazySingleton( - () => ApiClient(dio: dio, errorMapper: sl()), + () => ApiClient(dio: sl(), errorMapper: sl()), ); - // 인증 서비스 등록 - sl.registerLazySingleton( - () => AuthRepositoryRemote(apiClient: sl()), - ); - sl.registerLazySingleton( - () => AuthService( - repository: sl(), - tokenStorage: sl(), - ), - ); + _registerAuthDependencies(); + _registerDashboardDependencies(); + _registerMasterDependencies(); + _registerApprovalDependencies(); + _registerInventoryDependencies(); + _registerUtilityDependencies(); + _registerReportingDependencies(); +} +void _registerAuthDependencies() { + sl + ..registerLazySingleton( + () => AuthRepositoryRemote(apiClient: sl()), + ) + ..registerLazySingleton( + () => AuthService( + repository: sl(), + tokenStorage: sl(), + ), + ); +} + +void _registerDashboardDependencies() { sl.registerLazySingleton( () => DashboardRepositoryRemote(apiClient: sl()), ); +} - // 리포지토리 등록 (예: 벤더) - sl.registerLazySingleton( - () => VendorRepositoryRemote(apiClient: sl()), - ); +void _registerMasterDependencies() { + sl + ..registerLazySingleton( + () => VendorRepositoryRemote(apiClient: sl()), + ) + ..registerLazySingleton( + () => ProductRepositoryRemote(apiClient: sl()), + ) + ..registerLazySingleton( + () => WarehouseRepositoryRemote(apiClient: sl()), + ) + ..registerLazySingleton( + () => UomRepositoryRemote(apiClient: sl()), + ) + ..registerLazySingleton( + () => CustomerRepositoryRemote(apiClient: sl()), + ) + ..registerLazySingleton( + () => GroupRepositoryRemote(apiClient: sl()), + ) + ..registerLazySingleton( + () => UserRepositoryRemote(apiClient: sl()), + ) + ..registerLazySingleton( + () => MenuRepositoryRemote(apiClient: sl()), + ) + ..registerLazySingleton( + () => GroupPermissionRepositoryRemote(apiClient: sl()), + ); +} - sl.registerLazySingleton( - () => ProductRepositoryRemote(apiClient: sl()), - ); +void _registerApprovalDependencies() { + sl + ..registerLazySingleton( + () => ApprovalRepositoryRemote(apiClient: sl()), + ) + ..registerLazySingleton( + () => ApprovalTemplateRepositoryRemote(apiClient: sl()), + ) + ..registerLazySingleton( + () => ApprovalStepRepositoryRemote(apiClient: sl()), + ) + ..registerLazySingleton( + () => ApprovalHistoryRepositoryRemote(apiClient: sl()), + ) + ..registerLazySingleton( + () => ApprovalDraftRepositoryRemote(apiClient: sl()), + ) + ..registerLazySingleton( + () => SubmitApprovalUseCase(repository: sl()), + ) + ..registerLazySingleton( + () => ApproveApprovalUseCase(repository: sl()), + ) + ..registerLazySingleton( + () => RejectApprovalUseCase(repository: sl()), + ) + ..registerLazySingleton( + () => RecallApprovalUseCase(repository: sl()), + ) + ..registerLazySingleton( + () => ResubmitApprovalUseCase(repository: sl()), + ) + ..registerLazySingleton( + () => SaveApprovalTemplateUseCase( + repository: sl(), + ), + ) + ..registerLazySingleton( + () => SaveApprovalDraftUseCase(repository: sl()), + ) + ..registerLazySingleton( + () => GetApprovalDraftUseCase(repository: sl()), + ) + ..registerLazySingleton( + () => + ListApprovalDraftsUseCase(repository: sl()), + ) + ..registerLazySingleton( + () => + DeleteApprovalDraftUseCase(repository: sl()), + ) + ..registerLazySingleton( + () => ApplyApprovalTemplateUseCase( + templateRepository: sl(), + approvalRepository: sl(), + ), + ); +} - sl.registerLazySingleton( - () => WarehouseRepositoryRemote(apiClient: sl()), - ); - - sl.registerLazySingleton( - () => UomRepositoryRemote(apiClient: sl()), - ); - - sl.registerLazySingleton( - () => CustomerRepositoryRemote(apiClient: sl()), - ); - - sl.registerLazySingleton( - () => GroupRepositoryRemote(apiClient: sl()), - ); - - sl.registerLazySingleton( - () => UserRepositoryRemote(apiClient: sl()), - ); - - sl.registerLazySingleton( - () => MenuRepositoryRemote(apiClient: sl()), - ); - - sl.registerLazySingleton( - () => GroupPermissionRepositoryRemote(apiClient: sl()), - ); - - sl.registerLazySingleton( - () => ApprovalRepositoryRemote(apiClient: sl()), - ); - - sl.registerLazySingleton( - () => ApprovalTemplateRepositoryRemote(apiClient: sl()), - ); - - sl.registerLazySingleton( - () => ApprovalStepRepositoryRemote(apiClient: sl()), - ); - - sl.registerLazySingleton( - () => ApprovalHistoryRepositoryRemote(apiClient: sl()), - ); +void _registerInventoryDependencies() { + sl + ..registerLazySingleton( + () => InventoryLookupRepositoryRemote(apiClient: sl()), + ) + ..registerLazySingleton( + () => StockTransactionRepositoryRemote(apiClient: sl()), + ) + ..registerLazySingleton( + () => TransactionLineRepositoryRemote(apiClient: sl()), + ) + ..registerLazySingleton( + () => TransactionCustomerRepositoryRemote(apiClient: sl()), + ); +} +void _registerUtilityDependencies() { sl.registerLazySingleton( () => PostalSearchRepositoryRemote(apiClient: sl()), ); +} - sl.registerLazySingleton( - () => InventoryLookupRepositoryRemote(apiClient: sl()), - ); - - sl.registerLazySingleton( - () => StockTransactionRepositoryRemote(apiClient: sl()), - ); - - sl.registerLazySingleton( - () => TransactionLineRepositoryRemote(apiClient: sl()), - ); - - sl.registerLazySingleton( - () => TransactionCustomerRepositoryRemote(apiClient: sl()), - ); - +void _registerReportingDependencies() { sl.registerLazySingleton( () => ReportingRepositoryRemote(apiClient: sl()), ); diff --git a/lib/main.dart b/lib/main.dart index afe93e9..e61a0c1 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -46,10 +46,17 @@ class _SuperportAppState extends State { super.initState(); _themeController = ThemeController(); _permissionManager = PermissionManager(); + if (GetIt.I.isRegistered()) { + GetIt.I.unregister(); + } + GetIt.I.registerSingleton(_permissionManager); } @override void dispose() { + if (GetIt.I.isRegistered()) { + GetIt.I.unregister(); + } _themeController.dispose(); _permissionManager.dispose(); super.dispose(); diff --git a/lib/widgets/components/approval_widgets_guide.md b/lib/widgets/components/approval_widgets_guide.md new file mode 100644 index 0000000..a1d08b5 --- /dev/null +++ b/lib/widgets/components/approval_widgets_guide.md @@ -0,0 +1,82 @@ +# 결재 UI 컴포넌트 가이드 + +입·출·대여 등록 화면과 결재 템플릿/이력 화면에서 재사용하는 결재 전용 위젯 모음이다. `ApprovalRequestController` 등 프레젠테이션 계층 컨트롤러와 결합하도록 설계됐으며, 공통 UI 구성 요소(`SuperportDialog`, `SuperportTable`, `SuperportFormField`)와 함께 사용하는 것을 전제로 한다. + +## 1. 결재 단계 구성 섹션 — `ApprovalStepConfigurator` +- 경로: `lib/features/approvals/request/presentation/widgets/approval_step_configurator.dart` +- `ApprovalRequestController`를 주입하면 상신자·최종 승인자·중간 단계 목록을 요약 카드로 노출하고, “단계 구성 편집” 버튼을 통해 모달 편집기를 연다. +- `readOnly`를 `true`로 설정하면 카드만 렌더링하고 편집 트리거는 비활성화된다. + +```dart +final controller = ApprovalRequestController( + approvalUseCases: context.read(), + templateController: context.read(), +); + +ApprovalStepConfigurator( + controller: controller, + readOnly: state.isReadOnly, +); +``` + +모달은 `SuperportDialog` 위에서 동작하며, 승인자 검색에는 `ApproverAutocompleteField`를 재사용한다. 편집 완료 후 `controller.steps`에 반영된 값을 입·출·대여 제출 DTO 변환 시 그대로 넘겨야 한다. + +## 2. 승인자 셀·상태 배지·메모 툴팁 +- 경로: `lib/features/approvals/shared/widgets/approval_ui_helpers.dart` +- `ApprovalApproverCell`은 아바타(이니셜)·이름·사번을 테이블/다이얼로그에서 일관되게 표시하는 셀이다. +- `ApprovalStatusBadge`는 백엔드에서 내려오는 HEX 색상에 맞춰 배경/테두리/텍스트색을 구성한다. +- `ApprovalNoteTooltip`은 메모를 아이콘 툴팁으로 노출하고, 값이 없으면 플레이스홀더를 출력한다. + +```dart +final header = [ + ShadTableCell.header(child: const Text('승인자')), + ShadTableCell.header(child: const Text('결재 상태')), + ShadTableCell.header(child: const Text('메모')), +]; + +final rows = approvals.map((approval) { + return [ + ShadTableCell( + child: ApprovalApproverCell( + name: approval.approver.name, + employeeNo: approval.approver.employeeNo, + subtitle: approval.approver.role, + ), + ), + ShadTableCell( + child: ApprovalStatusBadge( + label: approval.status.label, + colorHex: approval.status.colorHex, + ), + ), + ShadTableCell( + child: ApprovalNoteTooltip(note: approval.note), + ), + ]; +}).toList(); + +return ShadTable.list(header: header, children: rows); +``` + +## 3. 모달 편집기 구성 요소 +- `ApprovalStepRow`(`lib/features/approvals/request/presentation/widgets/approval_step_row.dart`): 단계 순서, 승인자 오토컴플릿, 역할, 삭제 버튼을 한 행으로 묶는다. `ApprovalRequestController`가 노출하는 `updateStep`/`removeStep` 콜백을 그대로 연결한다. +- `ApprovalTemplatePicker`(`lib/features/approvals/request/presentation/widgets/approval_template_picker.dart`): 템플릿 목록과 미리보기를 제공하며, `ApprovalTemplateController`에서 주입된 상태를 바인딩한다. 저장 성공 시 `SuperportToast.success`로 토스트가 자동 표시된다. +- `widgets.dart` 배럴 파일을 통해 `ApprovalStepConfigurator`, `ApprovalTemplatePicker`, `ApprovalStepRow`를 한 번에 export하므로 화면에서는 `import 'package:superport_v2/features/approvals/request/presentation/widgets/widgets.dart';` 형태로 불러온다. + +```dart +ApprovalTemplatePicker( + controller: controller.templateController, + onTemplateApplied: controller.applyTemplate, + onTemplateCleared: controller.clearTemplate, +); +``` + +## 4. 도입 체크리스트 +- 결재 섹션을 추가하는 페이지에서는 `ApprovalRequestController.initializeWithTransaction`를 호출해 상신자/템플릿 스냅샷을 먼저 로딩한다. +- 제출 단계에서 `controller.validate()` 결과를 확인하고, 실패 시 `errorMessage`를 `ApprovalStepConfigurator`가 표시해 준다. +- 결재 이력/대시보드 테이블은 위 2절의 UI 헬퍼 조합을 사용해 승인자·상태·메모 UI를 통일한다. + +샘플 구현 경로: +- `lib/features/approvals/presentation/pages/approval_page.dart` +- `lib/features/approvals/template/presentation/pages/approval_template_page.dart` +- `lib/features/inventory/inbound/presentation/pages/inbound_page.dart` (결재 섹션 탭) diff --git a/lib/widgets/components/feedback.dart b/lib/widgets/components/feedback.dart index 407a728..28146ea 100644 --- a/lib/widgets/components/feedback.dart +++ b/lib/widgets/components/feedback.dart @@ -116,10 +116,13 @@ class SuperportSkeletonList extends StatelessWidget { /// 렌더링할 스켈레톤 행 개수. final int itemCount; + /// 각 항목 높이. final double height; + /// 행 사이 간격. final double gap; + /// 전체 패딩. final EdgeInsetsGeometry padding; diff --git a/lib/widgets/components/form_field.dart b/lib/widgets/components/form_field.dart index 433ede8..9ea9383 100644 --- a/lib/widgets/components/form_field.dart +++ b/lib/widgets/components/form_field.dart @@ -19,16 +19,22 @@ class SuperportFormField extends StatelessWidget { /// 폼 필드 라벨 텍스트. final String label; + /// 입력 영역으로 렌더링할 위젯. final Widget child; + /// 필수 여부. true면 라벨 옆에 `*` 표시를 추가한다. final bool required; + /// 보조 설명 문구. 에러가 없을 때만 출력된다. final String? caption; + /// 에러 메시지. 존재하면 캡션 대신 우선적으로 노출된다. final String? errorText; + /// 라벨 우측에 배치할 추가 위젯(예: 도움말 버튼). final Widget? trailing; + /// 라벨과 본문 사이 간격. final double spacing; @@ -88,22 +94,31 @@ class SuperportTextInput extends StatelessWidget { }); final TextEditingController? controller; + /// 입력 없을 때 보여줄 플레이스홀더 위젯. final Widget? placeholder; + /// 입력 변경 콜백. final ValueChanged? onChanged; + /// 제출(Enter) 시 호출되는 콜백. final ValueChanged? onSubmitted; + /// 키보드 타입. 숫자/이메일 등으로 지정 가능. final TextInputType? keyboardType; + /// 입력 활성 여부. final bool enabled; + /// 읽기 전용 여부. true면 수정 불가. final bool readOnly; + /// 최대 줄 수. 1보다 크면 멀티라인 입력을 지원한다. final int maxLines; + /// 앞에 붙일 위젯 (아이콘 등). final Widget? leading; + /// 뒤에 붙일 위젯 (버튼 등). final Widget? trailing; @@ -136,10 +151,13 @@ class SuperportSwitchField extends StatelessWidget { /// 스위치 현재 상태. final bool value; + /// 상태 변경 시 호출되는 콜백. final ValueChanged onChanged; + /// 스위치 상단에 표시할 제목. final String? label; + /// 보조 설명 문구. final String? caption; diff --git a/lib/widgets/components/responsive.dart b/lib/widgets/components/responsive.dart index 2b4a8a6..85f2457 100644 --- a/lib/widgets/components/responsive.dart +++ b/lib/widgets/components/responsive.dart @@ -2,6 +2,7 @@ import 'package:flutter/widgets.dart'; /// 데스크톱 레이아웃으로 간주할 최소 너비(px). const double desktopBreakpoint = 1200; + /// 태블릿 레이아웃을 구분하는 최소 너비(px). const double tabletBreakpoint = 960; @@ -47,13 +48,16 @@ class ResponsiveBreakpoints { /// 현재 뷰 가로 너비. final double width; + /// 너비에서 계산된 분기값. final DeviceBreakpoint breakpoint; /// 모바일 범위인지 여부. bool get isMobile => breakpoint == DeviceBreakpoint.mobile; + /// 태블릿 범위인지 여부. bool get isTablet => breakpoint == DeviceBreakpoint.tablet; + /// 데스크톱 범위인지 여부. bool get isDesktop => breakpoint == DeviceBreakpoint.desktop; @@ -75,8 +79,10 @@ class ResponsiveLayoutBuilder extends StatelessWidget { /// 모바일 뷰에서 사용할 빌더. final WidgetBuilder mobile; + /// 태블릿 뷰에서 사용할 빌더. 제공되지 않으면 데스크톱 빌더를 재사용한다. final WidgetBuilder? tablet; + /// 데스크톱 뷰에서 사용할 빌더. final WidgetBuilder desktop; @@ -114,8 +120,10 @@ class ResponsiveVisibility extends StatelessWidget { /// 조건을 만족할 때 보여줄 실제 위젯. final Widget child; + /// 조건을 만족하지 않을 때 대체로 렌더링할 위젯. final Widget replacement; + /// 어떤 분기에서 child를 노출할지 정의한 집합. final Set visibleOn; diff --git a/lib/widgets/components/superport_dialog.dart b/lib/widgets/components/superport_dialog.dart index b68d5f9..62c9609 100644 --- a/lib/widgets/components/superport_dialog.dart +++ b/lib/widgets/components/superport_dialog.dart @@ -42,6 +42,7 @@ class SuperportDialog extends StatelessWidget { this.insetPadding, this.onSubmit, this.enableFocusTrap = true, + this.headerActions, }); final String title; @@ -62,6 +63,11 @@ class SuperportDialog extends StatelessWidget { final FutureOr Function()? onSubmit; final bool enableFocusTrap; + /// 헤더 우측에 표시할 추가 액션 위젯 목록. + /// + /// - 닫기 버튼 왼쪽에 순서대로 렌더링된다. + final List? headerActions; + /// 공통 다이얼로그를 노출하는 헬퍼. `showDialog`와 동일하게 동작한다. static Future show({ required BuildContext context, @@ -97,6 +103,7 @@ class SuperportDialog extends StatelessWidget { description: description, showCloseButton: showCloseButton, onClose: handleClose, + actions: headerActions, ); final resolvedFooter = footer ?? _buildFooter(context); @@ -243,12 +250,14 @@ class _SuperportDialogHeader extends StatelessWidget { this.description, required this.showCloseButton, required this.onClose, + this.actions, }); final String title; final String? description; final bool showCloseButton; final VoidCallback onClose; + final List? actions; @override Widget build(BuildContext context) { @@ -276,6 +285,11 @@ class _SuperportDialogHeader extends StatelessWidget { ], ), ), + if (actions != null && actions!.isNotEmpty) + Padding( + padding: const EdgeInsets.only(right: 4, top: 2), + child: Wrap(spacing: 8, runSpacing: 8, children: actions!), + ), if (showCloseButton) IconButton( icon: const Icon(lucide.LucideIcons.x, size: 18), @@ -306,6 +320,7 @@ Future showSuperportDialog({ VoidCallback? onClose, FutureOr Function()? onSubmit, bool enableFocusTrap = true, + List? headerActions, }) { return SuperportDialog.show( context: context, @@ -324,6 +339,7 @@ Future showSuperportDialog({ onClose: onClose, onSubmit: onSubmit, enableFocusTrap: enableFocusTrap, + headerActions: headerActions, child: body, ), ); diff --git a/test/core/network/api_client_test.dart b/test/core/network/api_client_test.dart index 7883067..1e33a0c 100644 --- a/test/core/network/api_client_test.dart +++ b/test/core/network/api_client_test.dart @@ -150,5 +150,56 @@ void main() { expect(client.unwrap(response), {'status': 'ok'}); }); }); + + group('buildPath', () { + test('선행 슬래시를 유지하며 세그먼트를 결합한다', () { + final path = ApiClient.buildPath('/api/v1', ['approvals', 1, 'steps']); + expect(path, '/api/v1/approvals/1/steps'); + }); + + test('세그먼트의 중복 슬래시를 제거한다', () { + final path = ApiClient.buildPath('/api/v1/', ['/approval/', '/submit']); + expect(path, '/api/v1/approval/submit'); + }); + }); + + group('buildQuery', () { + test('페이지네이션/검색 파라미터를 정규화한다', () { + final now = DateTime.utc(2025, 1, 5, 12, 30); + final query = ApiClient.buildQuery( + page: 2, + pageSize: 50, + q: ' 품번 ', + sort: ' transaction_date ', + order: ' DESC ', + include: const ['steps', 'histories', 'steps'], + updatedSince: now, + filters: { + 'transaction_id': 10, + 'status': ' 진행 ', + 'keyword': ' ', + 'from': DateTime.utc(2025, 1, 1, 9), + 'ids': [' 1 ', null, '2'], + }, + ); + + expect(query['page'], 2); + expect(query['page_size'], 50); + expect(query['q'], '품번'); + expect(query['sort'], 'transaction_date'); + expect(query['order'], 'desc'); + expect(query['include'], 'steps,histories'); + expect(query['updated_since'], now.toUtc().toIso8601String()); + expect(query['transaction_id'], 10); + expect(query['status'], '진행'); + expect(query.containsKey('keyword'), isFalse); + expect( + query['from'], + DateTime.utc(2025, 1, 1, 9).toUtc().toIso8601String(), + ); + expect(query['ids'], '1,2'); + expect(() => query['page'] = 1, throwsUnsupportedError); + }); + }); }); } diff --git a/test/core/network/api_error_test.dart b/test/core/network/api_error_test.dart index e804cff..8257a64 100644 --- a/test/core/network/api_error_test.dart +++ b/test/core/network/api_error_test.dart @@ -92,9 +92,7 @@ void main() { requestOptions: requestOptions, statusCode: 401, data: { - 'error': { - 'message': 'invalid credentials', - }, + 'error': {'message': 'invalid credentials'}, }, ), ); diff --git a/test/features/approvals/approval_page_permission_test.dart b/test/features/approvals/approval_page_permission_test.dart index dfd55a0..b05abeb 100644 --- a/test/features/approvals/approval_page_permission_test.dart +++ b/test/features/approvals/approval_page_permission_test.dart @@ -18,14 +18,38 @@ import 'package:superport_v2/features/inventory/lookups/domain/entities/lookup_i import 'package:superport_v2/features/inventory/lookups/domain/repositories/inventory_lookup_repository.dart'; import '../../helpers/test_app.dart'; +import '../../helpers/fixture_loader.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); + late Map permissionFixture; + late Set viewerPermissions; + late Set approverPermissions; + + Set parseActions(String key) { + final raw = permissionFixture[key]; + if (raw is! List) { + return {}; + } + return raw + .whereType() + .map( + (action) => PermissionAction.values.firstWhere( + (candidate) => candidate.name == action, + orElse: () => PermissionAction.view, + ), + ) + .toSet(); + } + setUpAll(() async { dotenv.testLoad(fileInput: 'FEATURE_APPROVALS_ENABLED=true'); await Environment.initialize(); dotenv.env['FEATURE_APPROVALS_ENABLED'] = 'true'; + permissionFixture = loadJsonFixture('approvals/approval_permissions.json'); + viewerPermissions = parseActions('viewer'); + approverPermissions = parseActions('approver'); }); tearDown(() async { @@ -52,9 +76,7 @@ void main() { GetIt.I.registerSingleton(lookupRepo); final permissionManager = PermissionManager( - overrides: { - PermissionResources.approvals: {PermissionAction.view}, - }, + overrides: {PermissionResources.approvals: viewerPermissions}, ); final view = tester.view; @@ -95,12 +117,7 @@ void main() { GetIt.I.registerSingleton(lookupRepo); final permissionManager = PermissionManager( - overrides: { - PermissionResources.approvals: { - PermissionAction.view, - PermissionAction.approve, - }, - }, + overrides: {PermissionResources.approvals: approverPermissions}, ); await pumpApprovalPage(tester, permissionManager); @@ -133,12 +150,7 @@ void main() { GetIt.I.registerSingleton(lookupRepo); final permissionManager = PermissionManager( - overrides: { - PermissionResources.approvals: { - PermissionAction.view, - PermissionAction.approve, - }, - }, + overrides: {PermissionResources.approvals: approverPermissions}, ); await pumpApprovalPage(tester, permissionManager); @@ -200,6 +212,8 @@ class _StubApprovalRepository implements ApprovalRepository { int? transactionId, int? approvalStatusId, int? requestedById, + List? statusCodes, + bool includePending = false, bool includeHistories = false, bool includeSteps = false, }) async { @@ -220,6 +234,31 @@ class _StubApprovalRepository implements ApprovalRepository { return _approval; } + @override + Future submit(ApprovalSubmissionInput input) async { + return _approval; + } + + @override + Future resubmit(ApprovalResubmissionInput input) async { + return _approval; + } + + @override + Future approve(ApprovalDecisionInput input) async { + return _approval; + } + + @override + Future reject(ApprovalDecisionInput input) async { + return _approval; + } + + @override + Future recall(ApprovalRecallInput input) async { + return _approval; + } + @override Future> listActions({bool activeOnly = true}) async { return [ @@ -229,6 +268,24 @@ class _StubApprovalRepository implements ApprovalRepository { ]; } + @override + Future> listHistory({ + required int approvalId, + int page = 1, + int pageSize = 20, + DateTime? from, + DateTime? to, + int? actorId, + int? approvalActionId, + }) async { + return PaginatedResult( + items: const [], + page: page, + pageSize: pageSize, + total: 0, + ); + } + @override Future performStepAction(ApprovalStepActionInput input) async { return _approval; diff --git a/test/features/approvals/data/approval_draft_repository_remote_test.dart b/test/features/approvals/data/approval_draft_repository_remote_test.dart new file mode 100644 index 0000000..c691dff --- /dev/null +++ b/test/features/approvals/data/approval_draft_repository_remote_test.dart @@ -0,0 +1,193 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import 'package:superport_v2/core/network/api_client.dart'; +import 'package:superport_v2/features/approvals/data/repositories/approval_draft_repository_remote.dart'; +import 'package:superport_v2/features/approvals/domain/entities/approval_draft.dart'; + +class _MockApiClient extends Mock implements ApiClient {} + +void main() { + late ApiClient apiClient; + late ApprovalDraftRepositoryRemote repository; + + setUpAll(() { + registerFallbackValue(Options()); + registerFallbackValue(CancelToken()); + registerFallbackValue( + Response(requestOptions: RequestOptions(path: '/')), + ); + }); + + setUp(() { + apiClient = _MockApiClient(); + repository = ApprovalDraftRepositoryRemote(apiClient: apiClient); + }); + + test('list는 requester_id와 include_expired 플래그를 전달한다', () async { + const path = '/api/v1/approval-drafts'; + when( + () => apiClient.get>( + path, + query: any(named: 'query'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).thenAnswer( + (_) async => Response>( + data: {'items': const [], 'page': 1, 'page_size': 20, 'total': 0}, + statusCode: 200, + requestOptions: RequestOptions(path: path), + ), + ); + + await repository.list( + const ApprovalDraftListFilter(requesterId: 7, includeExpired: true), + ); + + final captured = verify( + () => apiClient.get>( + captureAny(), + query: captureAny(named: 'query'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).captured; + + expect(captured.first, equals(path)); + final query = captured[1] as Map; + expect(query['requester_id'], 7); + expect(query['include_expired'], isTrue); + }); + + test('fetch는 requester_id 쿼리로 상세를 조회한다', () async { + const id = 11; + const path = '/api/v1/approval-drafts/$id'; + when( + () => apiClient.get>( + path, + query: any(named: 'query'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).thenAnswer( + (_) async => Response>( + data: { + 'data': { + 'id': id, + 'requester_id': 5, + 'saved_at': '2025-01-01T00:00:00Z', + 'payload': {'steps': const []}, + }, + }, + statusCode: 200, + requestOptions: RequestOptions(path: path), + ), + ); + + final detail = await repository.fetch(id: id, requesterId: 5); + + expect(detail, isNotNull); + expect(detail!.id, id); + + final captured = verify( + () => apiClient.get>( + captureAny(), + query: captureAny(named: 'query'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).captured; + expect(captured.first, equals(path)); + final query = captured[1] as Map; + expect(query['requester_id'], 5); + }); + + test('save는 초안 상세를 반환한다', () async { + const path = '/api/v1/approval-drafts'; + when( + () => apiClient.post>( + path, + data: any(named: 'data'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).thenAnswer( + (_) async => Response>( + data: { + 'data': { + 'id': 22, + 'requester_id': 9, + 'saved_at': '2025-01-02T00:00:00Z', + 'payload': { + 'steps': const [ + {'step_order': 1, 'approver_id': 30}, + ], + }, + }, + }, + statusCode: 200, + requestOptions: RequestOptions(path: path), + ), + ); + + final input = ApprovalDraftSaveInput( + requesterId: 9, + steps: [ApprovalDraftStep(stepOrder: 1, approverId: 30)], + ); + + final detail = await repository.save(input); + + expect(detail.id, 22); + expect(detail.requesterId, 9); + + final captured = verify( + () => apiClient.post>( + captureAny(), + data: captureAny(named: 'data'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).captured; + expect(captured.first, equals(path)); + final payload = captured[1] as Map; + expect(payload['requester_id'], 9); + expect(payload['steps'], hasLength(1)); + }); + + test('delete는 requester_id를 포함해 호출한다', () async { + const id = 44; + const path = '/api/v1/approval-drafts/$id'; + when( + () => apiClient.delete( + path, + data: any(named: 'data'), + query: any(named: 'query'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).thenAnswer( + (_) async => Response( + data: null, + statusCode: 204, + requestOptions: RequestOptions(path: path), + ), + ); + + await repository.delete(id: id, requesterId: 5); + + final captured = verify( + () => apiClient.delete( + captureAny(), + data: any(named: 'data'), + query: captureAny(named: 'query'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).captured; + expect(captured.first, equals(path)); + final query = captured[1] as Map; + expect(query['requester_id'], 5); + }); +} diff --git a/test/features/approvals/data/approval_repository_remote_test.dart b/test/features/approvals/data/approval_repository_remote_test.dart index 3b7a7e9..1f5d3e8 100644 --- a/test/features/approvals/data/approval_repository_remote_test.dart +++ b/test/features/approvals/data/approval_repository_remote_test.dart @@ -82,7 +82,45 @@ void main() { expect(query['transaction_id'], 10); expect(query['approval_status_id'], 5); expect(query['requested_by_id'], 7); - expect(query['include'], 'steps,histories'); + expect(query['include'], 'requested_by,transaction,steps,histories'); + }); + + test('list는 status 코드와 include_pending 옵션을 전달한다', () async { + const path = '/api/v1/approvals'; + when( + () => apiClient.get>( + path, + query: any(named: 'query'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).thenAnswer( + (_) async => Response>( + data: {'items': const [], 'page': 1, 'page_size': 20, 'total': 0}, + statusCode: 200, + requestOptions: RequestOptions(path: path), + ), + ); + + await repository.list( + statusCodes: const ['draft', 'submitted'], + includePending: true, + ); + + final captured = verify( + () => apiClient.get>( + captureAny(), + query: captureAny(named: 'query'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).captured; + + expect(captured.first, equals(path)); + final query = captured[1] as Map; + expect(query['status'], 'draft,submitted'); + expect(query['include_pending'], true); + expect(query['include'], 'requested_by,transaction'); }); test('create는 필수 필드를 전달한다', () async { @@ -353,6 +391,56 @@ void main() { ).captured.first as Map; - expect(query['include'], 'steps,histories'); + expect(query['include'], 'transaction,requested_by,steps,histories'); + }); + + test('listHistory는 날짜 필터를 ISO 문자열로 직렬화한다', () async { + const path = '/api/v1/approval/history'; + when( + () => apiClient.get>( + path, + query: any(named: 'query'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).thenAnswer( + (_) async => Response>( + data: {'items': const [], 'page': 1, 'page_size': 20, 'total': 0}, + statusCode: 200, + requestOptions: RequestOptions(path: path), + ), + ); + + final from = DateTime.utc(2025, 1, 1, 9); + final to = DateTime.utc(2025, 1, 2, 12); + + await repository.listHistory( + approvalId: 99, + page: 3, + pageSize: 40, + from: from, + to: to, + actorId: 7, + approvalActionId: 1, + ); + + final captured = verify( + () => apiClient.get>( + captureAny(), + query: captureAny(named: 'query'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).captured; + + final query = captured[1] as Map; + expect(captured.first, equals(path)); + expect(query['approval_id'], 99); + expect(query['page'], 3); + expect(query['page_size'], 40); + expect(query['action_from'], from.toUtc().toIso8601String()); + expect(query['action_to'], to.toUtc().toIso8601String()); + expect(query['approver_id'], 7); + expect(query['approval_action_id'], 1); }); } diff --git a/test/features/approvals/data/dtos/approval_draft_dto_test.dart b/test/features/approvals/data/dtos/approval_draft_dto_test.dart new file mode 100644 index 0000000..bceedc2 --- /dev/null +++ b/test/features/approvals/data/dtos/approval_draft_dto_test.dart @@ -0,0 +1,65 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:superport_v2/features/approvals/data/dtos/approval_draft_dto.dart'; +import 'package:superport_v2/features/approvals/domain/entities/approval_draft.dart'; + +void main() { + group('ApprovalDraftDto', () { + test('parsePaginated가 요약 리스트를 변환한다', () { + final result = ApprovalDraftDto.parsePaginated({ + 'items': [ + { + 'id': 10, + 'requester_id': 77, + 'status': 'active', + 'saved_at': '2025-01-01T00:00:00Z', + 'step_count': 2, + 'session_key': 'session-1', + }, + ], + 'page': 2, + 'page_size': 50, + 'total': 90, + }); + + expect(result.page, 2); + expect(result.pageSize, 50); + expect(result.total, 90); + expect(result.items, hasLength(1)); + final summary = result.items.first; + expect(summary.id, 10); + expect(summary.requesterId, 77); + expect(summary.status, ApprovalDraftStatus.active); + expect(summary.stepCount, 2); + expect(summary.sessionKey, 'session-1'); + }); + + test('parseDetail이 상세 정보를 반환한다', () { + final detail = ApprovalDraftDto.parseDetail({ + 'data': { + 'id': 5, + 'requester_id': 11, + 'saved_at': '2025-01-02T12:00:00Z', + 'payload': { + 'title': '대여 결재', + 'summary': '사전 확인', + 'metadata': { + '_client_state': {'status_id': 4}, + }, + 'steps': [ + {'step_order': 1, 'approver_id': 20, 'note': '검토'}, + ], + }, + }, + }); + + expect(detail, isNotNull); + final value = detail!; + expect(value.id, 5); + expect(value.requesterId, 11); + expect(value.payload.title, '대여 결재'); + expect(value.payload.steps, hasLength(1)); + expect(value.payload.steps.first.approverId, 20); + }); + }); +} diff --git a/test/features/approvals/data/dtos/approval_request_dto_test.dart b/test/features/approvals/data/dtos/approval_request_dto_test.dart new file mode 100644 index 0000000..11b974b --- /dev/null +++ b/test/features/approvals/data/dtos/approval_request_dto_test.dart @@ -0,0 +1,138 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:superport_v2/features/approvals/data/dtos/approval_request_dto.dart'; +import 'package:superport_v2/features/approvals/domain/entities/approval.dart'; + +void main() { + group('ApprovalSubmitRequestDto', () { + test('toJson은 상신 본문과 단계를 직렬화한다', () { + final submission = ApprovalSubmissionInput( + transactionId: 1001, + templateId: 55, + statusId: 7, + requesterId: 22, + finalApproverId: 99, + requestedAt: DateTime.utc(2025, 1, 1, 9, 30), + lastActionAt: DateTime.utc(2025, 1, 3, 8, 15), + title: ' 결재 제목 ', + summary: ' 결재 요약 ', + note: ' 비고 ', + metadata: const {'channel': 'web'}, + steps: [ + ApprovalStepAssignmentItem( + stepOrder: 1, + approverId: 300, + note: ' 1차 ', + ), + ], + ); + + final dto = ApprovalSubmitRequestDto( + approval: ApprovalCreatePayloadDto.fromSubmission(submission), + steps: submission.steps + .map(ApprovalStepInputDto.fromDomain) + .toList(growable: false), + ); + + final json = dto.toJson(); + expect(json['approval'], isA>()); + expect(json['steps'], isA>()); + final approvalJson = json['approval'] as Map; + expect(approvalJson['transaction_id'], 1001); + expect(approvalJson['template_id'], 55); + expect(approvalJson['approval_status_id'], 7); + expect(approvalJson['requested_by_id'], 22); + expect(approvalJson['final_approver_id'], 99); + expect( + approvalJson['requested_at'], + DateTime.utc(2025, 1, 1, 9, 30).toIso8601String(), + ); + expect(approvalJson.containsKey('decided_at'), isFalse); + expect( + approvalJson['last_action_at'], + DateTime.utc(2025, 1, 3, 8, 15).toIso8601String(), + ); + expect(approvalJson['title'], '결재 제목'); + expect(approvalJson['summary'], '결재 요약'); + expect(approvalJson['note'], '비고'); + expect(approvalJson['metadata'], {'channel': 'web'}); + + final steps = json['steps'] as List; + expect(steps, hasLength(1)); + final stepJson = steps.first as Map; + expect(stepJson['step_order'], 1); + expect(stepJson['approver_id'], 300); + expect(stepJson['note'], '1차'); + }); + }); + + group('ApprovalResubmitRequestDto', () { + test('toJson은 옵션 필드와 타임스탬프를 포함한다', () { + final dto = ApprovalResubmitRequestDto( + approvalId: 700, + actorId: 123, + steps: [ + ApprovalStepInputDto(stepOrder: 1, approverId: 45, note: ' 의견 '), + ], + note: ' 재상신 ', + expectedUpdatedAt: DateTime.utc(2025, 2, 1, 10, 0), + transactionExpectedUpdatedAt: DateTime.utc(2025, 2, 1, 11, 0), + ); + + final json = dto.toJson(); + expect(json['approval_id'], 700); + expect(json['actor_id'], 123); + expect(json['note'], '재상신'); + expect( + json['expected_updated_at'], + DateTime.utc(2025, 2, 1, 10, 0).toIso8601String(), + ); + expect( + json['transaction_expected_updated_at'], + DateTime.utc(2025, 2, 1, 11, 0).toIso8601String(), + ); + final steps = json['steps'] as List; + expect(steps, hasLength(1)); + expect((steps.first as Map)['note'], '의견'); + }); + }); + + group('ApprovalRecallRequestDto', () { + test('회수 요청은 메모가 없으면 note를 누락한다', () { + final dto = ApprovalRecallRequestDto( + approvalId: 501, + actorId: 88, + expectedUpdatedAt: DateTime.utc(2025, 3, 1, 12, 0), + transactionExpectedUpdatedAt: DateTime.utc(2025, 3, 1, 13, 0), + ); + + final json = dto.toJson(); + expect(json['approval_id'], 501); + expect(json['actor_id'], 88); + expect(json.containsKey('note'), isFalse); + expect( + json['expected_updated_at'], + DateTime.utc(2025, 3, 1, 12, 0).toIso8601String(), + ); + expect( + json['transaction_expected_updated_at'], + DateTime.utc(2025, 3, 1, 13, 0).toIso8601String(), + ); + }); + }); + + group('ApprovalDecisionRequestDto', () { + test('toJson은 비어 있는 메모를 포함하지 않는다', () { + final dto = ApprovalDecisionRequestDto( + approvalId: 301, + actorId: 44, + note: ' ', + ); + + final json = dto.toJson(); + expect(json['approval_id'], 301); + expect(json['actor_id'], 44); + expect(json.containsKey('note'), isFalse); + }); + }); +} diff --git a/test/features/approvals/data/dtos/approval_response_dto_test.dart b/test/features/approvals/data/dtos/approval_response_dto_test.dart new file mode 100644 index 0000000..de61d65 --- /dev/null +++ b/test/features/approvals/data/dtos/approval_response_dto_test.dart @@ -0,0 +1,148 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:superport_v2/features/approvals/data/dtos/approval_audit_dto.dart'; +import 'package:superport_v2/features/approvals/data/dtos/approval_dto.dart'; +import 'package:superport_v2/features/approvals/data/dtos/approval_request_dto.dart'; + +void main() { + group('ApprovalAuditListDto', () { + test('fromJson은 감사 로그 목록과 페이지 정보를 생성한다', () { + final dto = ApprovalAuditListDto.fromJson({ + 'items': [ + { + 'id': 1, + 'action': {'id': 10, 'name': '상신'}, + 'to_status': {'id': 3, 'name': '진행중'}, + 'actor': {'id': 20, 'employee_no': 'EMP20', 'name': '홍길동'}, + 'action_at': '2025-01-01T09:00:00Z', + 'note': '테스트', + }, + ], + 'page': 2, + 'page_size': 50, + 'total': 80, + }); + + expect(dto.page, 2); + expect(dto.pageSize, 50); + expect(dto.total, 80); + expect(dto.items, hasLength(1)); + final item = dto.items.first; + expect(item.id, 1); + expect(item.action.id, 10); + expect(item.toStatus.name, '진행중'); + expect(item.actor.employeeNo, 'EMP20'); + expect(item.actionAt.toUtc(), DateTime.utc(2025, 1, 1, 9)); + expect(item.note, '테스트'); + }); + + test('ApprovalAuditDto.fromJson은 액션 이름 누락 시 예외를 발생시킨다', () { + expect( + () => ApprovalAuditDto.fromJson({ + 'id': 9, + 'action': {'id': 33}, + 'to_status': {'id': 2, 'name': '진행'}, + 'actor': {'id': 5, 'employee_no': 'EMP5', 'name': '최사용'}, + 'action_at': '2025-01-01T09:00:00Z', + }), + throwsFormatException, + ); + }); + }); + + group('ApprovalDto', () { + test('fromJson은 중첩 구조에서도 필드를 추출한다', () { + final dto = ApprovalDto.fromJson(_approvalJson()); + final entity = dto.toEntity(); + + expect(entity.id, 5001); + expect(entity.approvalNo, 'APP-2025-0001'); + expect(entity.transactionId, 77); + expect(entity.transactionNo, 'TRX-77'); + expect(entity.transactionUpdatedAt, DateTime.utc(2025, 1, 1, 9, 30)); + expect(entity.status.name, '진행중'); + expect(entity.requester.employeeNo, 'EMP-700'); + expect(entity.currentStep?.approver.name, '김승인'); + expect(entity.steps, hasLength(1)); + expect(entity.histories, hasLength(1)); + expect(entity.histories.first.action.name, '상신'); + expect(entity.createdAt, DateTime.utc(2025, 1, 1, 8)); + expect(entity.updatedAt, DateTime.utc(2025, 1, 1, 9)); + }); + + test('parsePaginated는 페이징 결과를 반환한다', () { + final result = ApprovalDto.parsePaginated({ + 'items': [_approvalJson()], + 'page': 3, + 'page_size': 25, + 'total': 40, + }); + + expect(result.page, 3); + expect(result.pageSize, 25); + expect(result.total, 40); + expect(result.items, hasLength(1)); + expect(result.items.first.approvalNo, 'APP-2025-0001'); + }); + + test('fromJson은 요청자 요약 누락 시 기본 ID를 보존한다', () { + final dto = ApprovalDto.fromJson({ + 'id': 6001, + 'approval_no': 'APP-2025-06001', + 'status': {'id': 1, 'name': '대기'}, + 'requested_at': '2025-01-01T00:00:00Z', + 'requester_id': 77, + 'requested_by_id': 77, + 'requester': const {}, + }); + + expect(dto.requester.id, 77); + expect(dto.requester.employeeNo, '-'); + expect(dto.requester.name, '-'); + }); + }); +} + +Map _approvalJson() { + return { + 'id': 5001, + 'approval': { + 'approval_no': 'APP-2025-0001', + 'requested_at': '2025-01-01T08:00:00Z', + 'status': {'id': 30, 'name': '진행중'}, + 'requester': {'id': 700, 'employee_no': 'EMP-700', 'name': '상신자'}, + 'transaction': { + 'id': 77, + 'transaction_no': 'TRX-77', + 'updated_at': '2025-01-01T09:30:00Z', + }, + 'steps': [ + { + 'id': 800, + 'step_order': 1, + 'approver': {'id': 910, 'employee_no': 'EMP-910', 'name': '김승인'}, + 'status': {'id': 30, 'name': '진행중'}, + 'assigned_at': '2025-01-01T08:00:00Z', + }, + ], + 'histories': [ + { + 'id': 1, + 'action': {'id': 10, 'name': '상신'}, + 'to_status': {'id': 30, 'name': '진행중'}, + 'actor': {'id': 700, 'employee_no': 'EMP-700', 'name': '상신자'}, + 'action_at': '2025-01-01T08:05:00Z', + }, + ], + 'created_at': '2025-01-01T08:00:00Z', + 'updated_at': '2025-01-01T09:00:00Z', + }, + 'current_step': { + 'id': 800, + 'step_order': 1, + 'approver': {'id': 910, 'employee_no': 'EMP-910', 'name': '김승인'}, + 'status': {'id': 30, 'name': '진행중'}, + 'assigned_at': '2025-01-01T08:00:00Z', + }, + }; +} diff --git a/test/features/approvals/domain/entities/approval_draft_test.dart b/test/features/approvals/domain/entities/approval_draft_test.dart new file mode 100644 index 0000000..16cb728 --- /dev/null +++ b/test/features/approvals/domain/entities/approval_draft_test.dart @@ -0,0 +1,55 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:superport_v2/features/approvals/domain/entities/approval_draft.dart'; + +void main() { + group('ApprovalDraftSaveInput', () { + test('metadata에 상태 ID를 병합한다', () { + final input = ApprovalDraftSaveInput( + requesterId: 99, + statusId: 7, + metadata: const {'foo': 'bar'}, + steps: [ApprovalDraftStep(stepOrder: 1, approverId: 2)], + ); + + final json = input.toJson(); + expect(json['metadata'], isA>()); + final metadata = json['metadata'] as Map; + expect(metadata['_client_state'], isA>()); + final client = metadata['_client_state'] as Map; + expect(client['status_id'], 7); + expect(metadata['foo'], 'bar'); + }); + }); + + group('ApprovalDraftDetail', () { + test('toSubmissionInput이 metadata에서 상태 ID를 복원한다', () { + final detail = ApprovalDraftDetail( + id: 1, + requesterId: 10, + savedAt: DateTime.utc(2025, 1, 1), + payload: ApprovalDraftPayload( + templateId: 5, + title: '임시 저장', + metadata: const { + '_client_state': {'status_id': 3}, + 'note': 'memo', + }, + steps: [ApprovalDraftStep(stepOrder: 1, approverId: 42)], + ), + ); + + final submission = detail.toSubmissionInput(defaultStatusId: 1); + + expect(submission.requesterId, 10); + expect(submission.statusId, 3); + expect(submission.templateId, 5); + expect(submission.metadata, isA>()); + final metadata = submission.metadata!; + expect(metadata.containsKey('_client_state'), isFalse); + expect(metadata['note'], 'memo'); + expect(submission.steps, hasLength(1)); + expect(submission.steps.first.approverId, 42); + }); + }); +} diff --git a/test/features/approvals/domain/usecases/approval_decision_use_cases_test.dart b/test/features/approvals/domain/usecases/approval_decision_use_cases_test.dart new file mode 100644 index 0000000..d487d24 --- /dev/null +++ b/test/features/approvals/domain/usecases/approval_decision_use_cases_test.dart @@ -0,0 +1,160 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import 'package:superport_v2/features/approvals/domain/entities/approval.dart'; +import 'package:superport_v2/features/approvals/domain/entities/approval_proceed_status.dart'; +import 'package:superport_v2/features/approvals/domain/repositories/approval_repository.dart'; +import 'package:superport_v2/features/approvals/domain/usecases/approve_approval_use_case.dart'; +import 'package:superport_v2/features/approvals/domain/usecases/reject_approval_use_case.dart'; + +class _MockApprovalRepository extends Mock implements ApprovalRepository {} + +void main() { + late ApprovalRepository repository; + late ApproveApprovalUseCase approveUseCase; + late RejectApprovalUseCase rejectUseCase; + + setUpAll(() { + registerFallbackValue(ApprovalDecisionInput(approvalId: 0, actorId: 0)); + }); + + setUp(() { + repository = _MockApprovalRepository(); + approveUseCase = ApproveApprovalUseCase(repository: repository); + rejectUseCase = RejectApprovalUseCase(repository: repository); + }); + + group('ApproveApprovalUseCase', () { + test('권한이 있으면 승인 요청을 위임한다', () async { + const approvalId = 10; + const actorId = 7; + final input = ApprovalDecisionInput( + approvalId: approvalId, + actorId: actorId, + ); + final approval = _buildApproval(id: approvalId); + + when(() => repository.canProceed(approvalId)).thenAnswer( + (_) async => + ApprovalProceedStatus(approvalId: approvalId, canProceed: true), + ); + when(() => repository.approve(any())).thenAnswer((_) async => approval); + + final flow = await approveUseCase(input); + + expect(flow.id, approvalId); + verify(() => repository.canProceed(approvalId)).called(1); + final captured = + verify(() => repository.approve(captureAny())).captured.single + as ApprovalDecisionInput; + expect(captured.approvalId, approvalId); + expect(captured.actorId, actorId); + }); + + test('권한이 없으면 StateError를 던지고 승인 요청을 하지 않는다', () async { + const approvalId = 99; + final input = ApprovalDecisionInput(approvalId: approvalId, actorId: 3); + when(() => repository.canProceed(approvalId)).thenAnswer( + (_) async => ApprovalProceedStatus( + approvalId: approvalId, + canProceed: false, + reason: '승인 권한이 없습니다.', + ), + ); + + expect( + () => approveUseCase(input), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('승인 권한이 없습니다.'), + ), + ), + ); + verify(() => repository.canProceed(approvalId)).called(1); + verifyNever(() => repository.approve(any())); + }); + }); + + group('RejectApprovalUseCase', () { + test('권한이 있으면 반려 요청을 위임한다', () async { + const approvalId = 70; + const actorId = 11; + final input = ApprovalDecisionInput( + approvalId: approvalId, + actorId: actorId, + ); + final approval = _buildApproval(id: approvalId); + + when(() => repository.canProceed(approvalId)).thenAnswer( + (_) async => + ApprovalProceedStatus(approvalId: approvalId, canProceed: true), + ); + when(() => repository.reject(any())).thenAnswer((_) async => approval); + + final flow = await rejectUseCase(input); + + expect(flow.id, approvalId); + verify(() => repository.canProceed(approvalId)).called(1); + final captured = + verify(() => repository.reject(captureAny())).captured.single + as ApprovalDecisionInput; + expect(captured.approvalId, approvalId); + expect(captured.actorId, actorId); + }); + + test('권한이 없으면 StateError를 던지고 반려 요청을 하지 않는다', () async { + const approvalId = 45; + final input = ApprovalDecisionInput(approvalId: approvalId, actorId: 9); + + when(() => repository.canProceed(approvalId)).thenAnswer( + (_) async => + ApprovalProceedStatus(approvalId: approvalId, canProceed: false), + ); + + expect( + () => rejectUseCase(input), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('결재를 진행할 권한이 없습니다.'), + ), + ), + ); + verify(() => repository.canProceed(approvalId)).called(1); + verifyNever(() => repository.reject(any())); + }); + }); +} + +Approval _buildApproval({required int id}) { + final status = ApprovalStatus(id: 1, name: '진행중'); + final approver = ApprovalApprover(id: 5, employeeNo: 'EMP-5', name: '김승인'); + return Approval( + id: id, + approvalNo: 'APP-$id', + transactionNo: 'TRX-$id', + status: status, + currentStep: ApprovalStep( + id: 10, + stepOrder: 1, + approver: approver, + status: status, + assignedAt: DateTime.utc(2025, 1, 1, 9), + ), + requester: ApprovalRequester(id: 2, employeeNo: 'EMP-2', name: '상신자'), + requestedAt: DateTime.utc(2025, 1, 1, 9), + steps: [ + ApprovalStep( + id: 10, + stepOrder: 1, + approver: approver, + status: status, + assignedAt: DateTime.utc(2025, 1, 1, 9), + ), + ], + histories: const [], + ); +} diff --git a/test/features/approvals/history/presentation/controllers/approval_history_controller_test.dart b/test/features/approvals/history/presentation/controllers/approval_history_controller_test.dart index 0fa8822..0fc7282 100644 --- a/test/features/approvals/history/presentation/controllers/approval_history_controller_test.dart +++ b/test/features/approvals/history/presentation/controllers/approval_history_controller_test.dart @@ -2,7 +2,12 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/core/network/failure.dart'; import 'package:superport_v2/features/approvals/domain/entities/approval.dart'; +import 'package:superport_v2/features/approvals/domain/entities/approval_flow.dart'; +import 'package:superport_v2/features/approvals/domain/repositories/approval_repository.dart'; +import 'package:superport_v2/features/approvals/domain/usecases/recall_approval_use_case.dart'; +import 'package:superport_v2/features/approvals/domain/usecases/resubmit_approval_use_case.dart'; import 'package:superport_v2/features/approvals/history/domain/entities/approval_history_record.dart'; import 'package:superport_v2/features/approvals/history/domain/repositories/approval_history_repository.dart'; import 'package:superport_v2/features/approvals/history/presentation/controllers/approval_history_controller.dart'; @@ -10,16 +15,27 @@ import 'package:superport_v2/features/approvals/history/presentation/controllers class _MockApprovalHistoryRepository extends Mock implements ApprovalHistoryRepository {} +class _MockApprovalRepository extends Mock implements ApprovalRepository {} + +class _MockRecallApprovalUseCase extends Mock + implements RecallApprovalUseCase {} + +class _MockResubmitApprovalUseCase extends Mock + implements ResubmitApprovalUseCase {} + void main() { late ApprovalHistoryController controller; late _MockApprovalHistoryRepository repository; + late _MockApprovalRepository approvalRepository; + late _MockRecallApprovalUseCase recallUseCase; + late _MockResubmitApprovalUseCase resubmitUseCase; final record = ApprovalHistoryRecord( id: 1, approvalId: 10, approvalNo: 'APP-2024-0001', stepOrder: 1, - action: ApprovalAction(id: 11, name: 'approve'), + action: ApprovalAction(id: 11, name: 'approve', code: 'approve'), fromStatus: ApprovalStatus(id: 1, name: '대기', color: null), toStatus: ApprovalStatus(id: 2, name: '승인', color: null), approver: ApprovalApprover(id: 21, employeeNo: 'E001', name: '최승인'), @@ -27,6 +43,16 @@ void main() { note: '승인 완료', ); + final auditEntry = ApprovalHistory( + id: 2, + action: ApprovalAction(id: 12, name: 'recall', code: 'recall'), + fromStatus: ApprovalStatus(id: 2, name: '승인'), + toStatus: ApprovalStatus(id: 3, name: '회수'), + approver: ApprovalApprover(id: 22, employeeNo: 'E002', name: '박회수'), + actionAt: DateTime(2024, 4, 2, 10), + note: '요청자 회수', + ); + PaginatedResult createResult( List items, ) { @@ -38,9 +64,49 @@ void main() { ); } + PaginatedResult createAuditResult( + List items, + ) { + return PaginatedResult( + items: items, + page: 1, + pageSize: 10, + total: items.length, + ); + } + + ApprovalFlow createFlow(int approvalId) { + final status = ApprovalStatus(id: 5, name: '대기'); + final requester = ApprovalRequester( + id: 88, + employeeNo: 'EMP088', + name: '상신자', + ); + final approval = Approval( + id: approvalId, + approvalNo: 'APP-$approvalId', + transactionId: approvalId * 1000, + transactionNo: 'TRX-$approvalId', + status: status, + requester: requester, + requestedAt: DateTime(2024, 4, 1), + transactionUpdatedAt: DateTime(2024, 4, 1, 12), + steps: const [], + ); + return ApprovalFlow(approval: approval); + } + setUp(() { repository = _MockApprovalHistoryRepository(); - controller = ApprovalHistoryController(repository: repository); + approvalRepository = _MockApprovalRepository(); + recallUseCase = _MockRecallApprovalUseCase(); + resubmitUseCase = _MockResubmitApprovalUseCase(); + controller = ApprovalHistoryController( + repository: repository, + approvalRepository: approvalRepository, + recallUseCase: recallUseCase, + resubmitUseCase: resubmitUseCase, + ); }); test('fetch 성공 시 결과를 갱신한다', () async { @@ -131,4 +197,186 @@ void main() { expect(controller.from, isNull); expect(controller.to, isNull); }); + + test('fetchAuditLogs는 ApprovalRepository를 사용한다', () async { + when( + () => approvalRepository.listHistory( + approvalId: any(named: 'approvalId'), + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + from: any(named: 'from'), + to: any(named: 'to'), + actorId: any(named: 'actorId'), + approvalActionId: any(named: 'approvalActionId'), + ), + ).thenAnswer((_) async => createAuditResult([auditEntry])); + + await controller.fetchAuditLogs(approvalId: 10); + + expect(controller.auditResult?.items, isNotEmpty); + expect(controller.selectedApprovalId, 10); + expect(controller.auditPageSize, 10); + }); + + test('updateActiveTab이 탭 상태를 변경한다', () { + expect(controller.activeTab, ApprovalHistoryTab.flow); + controller.updateActiveTab(ApprovalHistoryTab.audit); + expect(controller.activeTab, ApprovalHistoryTab.audit); + }); + + test('clearAuditSelection은 감사 로그 상태를 초기화한다', () async { + when( + () => approvalRepository.listHistory( + approvalId: any(named: 'approvalId'), + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + from: any(named: 'from'), + to: any(named: 'to'), + actorId: any(named: 'actorId'), + approvalActionId: any(named: 'approvalActionId'), + ), + ).thenAnswer((_) async => createAuditResult([auditEntry])); + + await controller.fetchAuditLogs(approvalId: 10); + controller.clearAuditSelection(); + + expect(controller.selectedApprovalId, isNull); + expect(controller.auditResult, isNull); + }); + + test('recallApproval은 유즈케이스를 호출하고 목록을 새로고침한다', () async { + final recallInput = ApprovalRecallInput(approvalId: 10, actorId: 77); + when( + () => recallUseCase.call(recallInput), + ).thenAnswer((_) async => createFlow(10)); + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + action: any(named: 'action'), + from: any(named: 'from'), + to: any(named: 'to'), + ), + ).thenAnswer((_) async => createResult([record])); + when( + () => approvalRepository.listHistory( + approvalId: any(named: 'approvalId'), + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + from: any(named: 'from'), + to: any(named: 'to'), + actorId: any(named: 'actorId'), + approvalActionId: any(named: 'approvalActionId'), + ), + ).thenAnswer((_) async => createAuditResult([auditEntry])); + + await controller.fetchAuditLogs(approvalId: 10); + final flow = await controller.recallApproval(recallInput); + + expect(flow, isNotNull); + expect(controller.isPerformingAction, isFalse); + verify(() => recallUseCase.call(recallInput)).called(1); + verify( + () => repository.list( + page: 1, + pageSize: 20, + query: null, + action: null, + from: null, + to: null, + ), + ).called(greaterThanOrEqualTo(1)); + verify( + () => approvalRepository.listHistory( + approvalId: 10, + page: 1, + pageSize: any(named: 'pageSize'), + from: any(named: 'from'), + to: any(named: 'to'), + actorId: null, + approvalActionId: null, + ), + ).called(greaterThanOrEqualTo(1)); + }); + + test('resubmitApproval은 유즈케이스를 호출한다', () async { + final submission = ApprovalSubmissionInput( + statusId: 3, + requesterId: 77, + steps: const [], + ); + final resubmitInput = ApprovalResubmissionInput( + approvalId: 11, + actorId: 77, + submission: submission, + ); + when( + () => resubmitUseCase.call(resubmitInput), + ).thenAnswer((_) async => createFlow(11)); + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + action: any(named: 'action'), + from: any(named: 'from'), + to: any(named: 'to'), + ), + ).thenAnswer((_) async => createResult([record])); + when( + () => approvalRepository.listHistory( + approvalId: any(named: 'approvalId'), + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + from: any(named: 'from'), + to: any(named: 'to'), + actorId: any(named: 'actorId'), + approvalActionId: any(named: 'approvalActionId'), + ), + ).thenAnswer((_) async => createAuditResult([auditEntry])); + + await controller.fetchAuditLogs(approvalId: 11); + final flow = await controller.resubmitApproval(resubmitInput); + + expect(flow, isNotNull); + expect(controller.isPerformingAction, isFalse); + verify(() => resubmitUseCase.call(resubmitInput)).called(1); + }); + + test('loadApprovalFlow는 403 응답 시 선택을 금지한다', () async { + when( + () => approvalRepository.fetchDetail( + any(), + includeSteps: any(named: 'includeSteps'), + includeHistories: any(named: 'includeHistories'), + ), + ).thenThrow(const Failure(message: '접근이 거부되었습니다.', statusCode: 403)); + + await controller.loadApprovalFlow(77); + + expect(controller.isSelectionForbidden, isTrue); + expect(controller.selectedFlow, isNull); + expect(controller.errorMessage, contains('접근이 거부')); + }); + + test('fetchAuditLogs는 403 응답 시 선택을 차단한다', () async { + when( + () => approvalRepository.listHistory( + approvalId: any(named: 'approvalId'), + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + from: any(named: 'from'), + to: any(named: 'to'), + actorId: any(named: 'actorId'), + approvalActionId: any(named: 'approvalActionId'), + ), + ).thenThrow(const Failure(message: '접근 불가', statusCode: 403)); + + await controller.fetchAuditLogs(approvalId: 12); + + expect(controller.isSelectionForbidden, isTrue); + expect(controller.auditResult, isNull); + expect(controller.errorMessage, contains('접근 불가')); + }); } diff --git a/test/features/approvals/history/presentation/pages/approval_history_page_test.dart b/test/features/approvals/history/presentation/pages/approval_history_page_test.dart index a18c534..fbf6588 100644 --- a/test/features/approvals/history/presentation/pages/approval_history_page_test.dart +++ b/test/features/approvals/history/presentation/pages/approval_history_page_test.dart @@ -7,13 +7,32 @@ import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; import 'package:superport_v2/features/approvals/domain/entities/approval.dart'; +import 'package:superport_v2/features/approvals/domain/entities/approval_flow.dart'; import 'package:superport_v2/features/approvals/history/domain/entities/approval_history_record.dart'; import 'package:superport_v2/features/approvals/history/domain/repositories/approval_history_repository.dart'; import 'package:superport_v2/features/approvals/history/presentation/pages/approval_history_page.dart'; +import 'package:superport_v2/features/approvals/domain/repositories/approval_repository.dart'; +import 'package:superport_v2/features/approvals/domain/usecases/recall_approval_use_case.dart'; +import 'package:superport_v2/features/approvals/domain/usecases/resubmit_approval_use_case.dart'; +import 'package:superport_v2/features/auth/application/auth_service.dart'; +import 'package:superport_v2/features/auth/domain/entities/auth_permission.dart'; +import 'package:superport_v2/features/auth/domain/entities/auth_session.dart'; +import 'package:superport_v2/features/auth/domain/entities/authenticated_user.dart'; +import 'package:superport_v2/widgets/components/superport_table.dart'; class _MockApprovalHistoryRepository extends Mock implements ApprovalHistoryRepository {} +class _MockApprovalRepository extends Mock implements ApprovalRepository {} + +class _MockRecallApprovalUseCase extends Mock + implements RecallApprovalUseCase {} + +class _MockResubmitApprovalUseCase extends Mock + implements ResubmitApprovalUseCase {} + +class _MockAuthService extends Mock implements AuthService {} + Widget _buildApp(Widget child) { return MaterialApp( home: ShadTheme( @@ -29,14 +48,33 @@ Widget _buildApp(Widget child) { void main() { TestWidgetsFlutterBinding.ensureInitialized(); - late _MockApprovalHistoryRepository repository; + late _MockApprovalHistoryRepository historyRepository; + late _MockApprovalRepository approvalRepository; + late _MockRecallApprovalUseCase recallUseCase; + late _MockResubmitApprovalUseCase resubmitUseCase; + late _MockAuthService authService; + + setUpAll(() { + registerFallbackValue(ApprovalRecallInput(approvalId: 0, actorId: 0)); + registerFallbackValue( + ApprovalResubmissionInput( + approvalId: 0, + actorId: 0, + submission: ApprovalSubmissionInput( + statusId: 0, + requesterId: 0, + steps: const [], + ), + ), + ); + }); final record = ApprovalHistoryRecord( id: 1, approvalId: 10, approvalNo: 'APP-2024-0001', stepOrder: 1, - action: ApprovalAction(id: 11, name: 'approve'), + action: ApprovalAction(id: 11, name: 'approve', code: 'approve'), fromStatus: ApprovalStatus(id: 1, name: '대기', color: null), toStatus: ApprovalStatus(id: 2, name: '승인', color: null), approver: ApprovalApprover(id: 21, employeeNo: 'E001', name: '최승인'), @@ -44,6 +82,160 @@ void main() { note: '승인 완료', ); + final secondRecord = ApprovalHistoryRecord( + id: 2, + approvalId: 11, + approvalNo: 'APP-2024-0002', + stepOrder: 2, + action: ApprovalAction(id: 12, name: 'submit', code: 'submit'), + fromStatus: ApprovalStatus(id: 1, name: '대기', color: null), + toStatus: ApprovalStatus(id: 3, name: '진행중', color: null), + approver: ApprovalApprover(id: 31, employeeNo: 'E031', name: '초기승인'), + actionAt: DateTime(2024, 4, 2, 9, 30), + note: '상신 완료', + ); + + ApprovalFlow stubFlow() { + final approval = Approval( + transactionId: 1, + approvalNo: 'APP-2024-0001', + transactionNo: 'TRX-001', + status: ApprovalStatus(id: 2, name: '승인'), + requester: ApprovalRequester(id: 99, employeeNo: 'E099', name: '테스터'), + requestedAt: DateTime(2024, 4, 1), + steps: const [], + histories: const [], + transactionUpdatedAt: DateTime(2024, 4, 1, 12), + ); + return ApprovalFlow(approval: approval); + } + + ApprovalFlow recallableFlow() { + final status = ApprovalStatus(id: 2, name: '진행중', isTerminal: false); + final approver = ApprovalApprover(id: 21, employeeNo: 'E001', name: '최승인'); + final steps = [ + ApprovalStep( + id: 100, + stepOrder: 1, + approver: approver, + status: status, + assignedAt: DateTime(2024, 4, 1, 9), + decidedAt: null, + ), + ApprovalStep( + id: 101, + stepOrder: 2, + approver: approver, + status: status, + assignedAt: DateTime(2024, 4, 1, 10), + decidedAt: DateTime(2024, 4, 1, 10, 30), + ), + ]; + final approval = Approval( + id: 10, + approvalNo: 'APP-2024-0001', + transactionId: 10, + transactionNo: 'TRX-001', + status: status, + requester: ApprovalRequester(id: 99, employeeNo: 'E099', name: '테스터'), + requestedAt: DateTime(2024, 4, 1), + steps: steps, + histories: const [], + updatedAt: DateTime(2024, 4, 1, 12), + transactionUpdatedAt: DateTime(2024, 4, 1, 11, 50), + ); + return ApprovalFlow(approval: approval); + } + + // ignore: unused_element + ApprovalFlow resubmittableFlow() { + final pendingStatus = ApprovalStatus(id: 2, name: '대기', isTerminal: false); + final rejectedStatus = ApprovalStatus(id: 5, name: '반려', isTerminal: true); + final firstApprover = ApprovalApprover( + id: 31, + employeeNo: 'E002', + name: '1차 승인자', + ); + final finalApprover = ApprovalApprover( + id: 32, + employeeNo: 'E003', + name: '최종 승인자', + ); + final steps = [ + ApprovalStep( + id: 120, + stepOrder: 1, + approver: firstApprover, + status: pendingStatus, + assignedAt: DateTime(2024, 3, 30, 9), + decidedAt: DateTime(2024, 3, 30, 10), + ), + ApprovalStep( + id: 121, + stepOrder: 2, + approver: finalApprover, + status: rejectedStatus, + assignedAt: DateTime(2024, 3, 30, 11), + decidedAt: DateTime(2024, 3, 30, 12), + note: '보완 필요', + ), + ]; + final approval = Approval( + id: 10, + approvalNo: 'APP-2024-0001', + transactionId: 11, + transactionNo: 'TRX-001', + status: rejectedStatus, + requester: ApprovalRequester(id: 99, employeeNo: 'E099', name: '테스터'), + requestedAt: DateTime(2024, 3, 30), + decidedAt: DateTime(2024, 3, 30, 12, 30), + note: '반려 사유 공유', + steps: steps, + histories: const [], + updatedAt: DateTime(2024, 3, 30, 12, 30), + transactionUpdatedAt: DateTime(2024, 3, 30, 12), + ); + return ApprovalFlow(approval: approval); + } + + setUp(() { + historyRepository = _MockApprovalHistoryRepository(); + approvalRepository = _MockApprovalRepository(); + recallUseCase = _MockRecallApprovalUseCase(); + resubmitUseCase = _MockResubmitApprovalUseCase(); + authService = _MockAuthService(); + + final sl = GetIt.I; + sl.registerLazySingleton( + () => historyRepository, + ); + sl.registerLazySingleton(() => approvalRepository); + sl.registerLazySingleton(() => recallUseCase); + sl.registerLazySingleton(() => resubmitUseCase); + sl.registerLazySingleton(() => authService); + + when( + () => approvalRepository.fetchDetail( + any(), + includeSteps: any(named: 'includeSteps'), + includeHistories: any(named: 'includeHistories'), + ), + ).thenAnswer((_) async => stubFlow().approval); + + when(() => authService.session).thenReturn( + AuthSession( + accessToken: 'token', + refreshToken: 'refresh', + expiresAt: DateTime.now().add(const Duration(hours: 1)), + user: const AuthenticatedUser(id: 99, name: '테스터', employeeNo: 'E099'), + permissions: const [], + ), + ); + + when(() => recallUseCase.call(any())).thenAnswer((_) async => stubFlow()); + when(() => resubmitUseCase.call(any())).thenAnswer((_) async => stubFlow()); + }); + tearDown(() async { await GetIt.I.reset(); dotenv.clean(); @@ -61,11 +253,53 @@ void main() { testWidgets('이력 목록을 렌더링하고 검색 필터를 적용한다', (tester) async { dotenv.testLoad(fileInput: 'FEATURE_APPROVALS_ENABLED=true\n'); - repository = _MockApprovalHistoryRepository(); - GetIt.I.registerLazySingleton(() => repository); when( - () => repository.list( + () => historyRepository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + action: any(named: 'action'), + from: any(named: 'from'), + to: any(named: 'to'), + ), + ).thenAnswer( + (_) async => PaginatedResult( + items: [record, secondRecord], + page: 1, + pageSize: 20, + total: 2, + ), + ); + + await tester.pumpWidget(_buildApp(const ApprovalHistoryPage())); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(find.textContaining('APP-2024-0001'), findsOneWidget); + expect(find.text('승인 완료'), findsOneWidget); + + await tester.enterText(find.byType(ShadInput).first, 'APP-2024'); + await tester.tap(find.text('검색 적용')); + await tester.pump(); + + verify( + () => historyRepository.list( + page: any(named: 'page'), + pageSize: 20, + query: 'APP-2024', + action: null, + from: null, + to: null, + ), + ).called(greaterThanOrEqualTo(1)); + }); + + testWidgets('회수 시 상세 재조회 실패 안내를 노출한다', (tester) async { + dotenv.testLoad(fileInput: 'FEATURE_APPROVALS_ENABLED=true\n'); + + when( + () => historyRepository.list( page: any(named: 'page'), pageSize: any(named: 'pageSize'), query: any(named: 'query'), @@ -82,26 +316,45 @@ void main() { ), ); + final recallable = recallableFlow(); + var fetchCount = 0; + when( + () => approvalRepository.fetchDetail( + any(), + includeSteps: any(named: 'includeSteps'), + includeHistories: any(named: 'includeHistories'), + ), + ).thenAnswer((_) async { + if (fetchCount == 0) { + fetchCount++; + return recallable.approval; + } + fetchCount++; + throw Exception('refresh failed'); + }); + await tester.pumpWidget(_buildApp(const ApprovalHistoryPage())); await tester.pump(); await tester.pumpAndSettle(); - expect(find.textContaining('APP-2024-0001'), findsOneWidget); - expect(find.text('승인 완료'), findsOneWidget); - - await tester.enterText(find.byType(ShadInput).first, 'APP-2024'); - await tester.tap(find.text('검색 적용')); + final table = tester.widget(find.byType(SuperportTable)); + table.onRowTap?.call(0); await tester.pump(); + await tester.pumpAndSettle(); - verify( - () => repository.list( - page: any(named: 'page'), - pageSize: 20, - query: 'APP-2024', - action: null, - from: null, - to: null, - ), - ).called(greaterThanOrEqualTo(1)); + final recallButton = find.widgetWithText(ShadButton, '회수').first; + await tester.ensureVisible(recallButton); + await tester.tap(recallButton); + await tester.pumpAndSettle(); + + expect(find.text('결재 회수'), findsOneWidget); + final confirmButton = find.widgetWithText(ShadButton, '회수').last; + await tester.ensureVisible(confirmButton); + await tester.tap(confirmButton); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(fetchCount, equals(2)); + expect(find.text('결재 상세를 새로고침하지 못했습니다. 다시 시도해 주세요.'), findsOneWidget); }); } diff --git a/test/features/approvals/history/presentation/widgets/approval_action_panel_test.dart b/test/features/approvals/history/presentation/widgets/approval_action_panel_test.dart new file mode 100644 index 0000000..7816af3 --- /dev/null +++ b/test/features/approvals/history/presentation/widgets/approval_action_panel_test.dart @@ -0,0 +1,371 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import 'package:superport_v2/features/approvals/domain/entities/approval.dart'; +import 'package:superport_v2/features/approvals/domain/entities/approval_flow.dart'; +import 'package:superport_v2/features/approvals/domain/entities/approval_proceed_status.dart'; +import 'package:superport_v2/features/approvals/domain/repositories/approval_repository.dart'; +import 'package:superport_v2/features/approvals/domain/usecases/recall_approval_use_case.dart'; +import 'package:superport_v2/features/approvals/domain/usecases/resubmit_approval_use_case.dart'; +import 'package:superport_v2/features/approvals/history/domain/repositories/approval_history_repository.dart'; +import 'package:superport_v2/features/approvals/history/presentation/controllers/approval_history_controller.dart'; + +class _MockApprovalHistoryRepository extends Mock + implements ApprovalHistoryRepository {} + +class _MockApprovalRepository extends Mock implements ApprovalRepository {} + +class _MockRecallApprovalUseCase extends Mock + implements RecallApprovalUseCase {} + +class _MockResubmitApprovalUseCase extends Mock + implements ResubmitApprovalUseCase {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late ApprovalHistoryRepository historyRepository; + late ApprovalRepository approvalRepository; + late RecallApprovalUseCase recallUseCase; + late ResubmitApprovalUseCase resubmitUseCase; + + setUpAll(() { + registerFallbackValue(ApprovalRecallInput(approvalId: 0, actorId: 0)); + registerFallbackValue( + ApprovalResubmissionInput( + approvalId: 0, + actorId: 0, + submission: ApprovalSubmissionInput(statusId: 0, requesterId: 0), + ), + ); + }); + + setUp(() { + historyRepository = _MockApprovalHistoryRepository(); + approvalRepository = _MockApprovalRepository(); + recallUseCase = _MockRecallApprovalUseCase(); + resubmitUseCase = _MockResubmitApprovalUseCase(); + }); + + ApprovalFlow buildRecallableFlow() { + final status = ApprovalStatus(id: 1, name: '진행중'); + final steps = [ + ApprovalStep( + id: 1, + stepOrder: 1, + approver: ApprovalApprover(id: 101, employeeNo: 'E101', name: '1차'), + status: status, + assignedAt: DateTime(2024, 4, 1, 9), + ), + ApprovalStep( + id: 2, + stepOrder: 2, + approver: ApprovalApprover(id: 102, employeeNo: 'E102', name: '2차'), + status: status, + assignedAt: DateTime(2024, 4, 1, 10), + decidedAt: DateTime(2024, 4, 1, 10, 30), + ), + ]; + final approval = Approval( + id: 500, + approvalNo: 'APP-500', + transactionId: 5000, + transactionNo: 'TRX-500', + status: status, + requester: ApprovalRequester(id: 90, employeeNo: 'E090', name: '상신자'), + requestedAt: DateTime(2024, 4, 1), + steps: steps, + histories: const [], + updatedAt: DateTime(2024, 4, 1, 12), + currentStep: steps.first, + transactionUpdatedAt: DateTime(2024, 4, 1, 11, 30), + ); + return ApprovalFlow(approval: approval); + } + + ApprovalFlow buildResubmittableFlow() { + final rejectedStatus = ApprovalStatus(id: 9, name: '반려', isTerminal: true); + final steps = [ + ApprovalStep( + id: 1, + stepOrder: 1, + approver: ApprovalApprover(id: 101, employeeNo: 'E101', name: '1차'), + status: rejectedStatus, + assignedAt: DateTime(2024, 3, 30, 9), + decidedAt: DateTime(2024, 3, 30, 9, 30), + ), + ApprovalStep( + id: 2, + stepOrder: 2, + approver: ApprovalApprover(id: 102, employeeNo: 'E102', name: '최종'), + status: rejectedStatus, + assignedAt: DateTime(2024, 3, 30, 10), + decidedAt: DateTime(2024, 3, 30, 10, 30), + note: '보완 필요', + ), + ]; + final approval = Approval( + id: 600, + approvalNo: 'APP-600', + transactionId: 6000, + transactionNo: 'TRX-600', + status: rejectedStatus, + requester: ApprovalRequester(id: 90, employeeNo: 'E090', name: '상신자'), + requestedAt: DateTime(2024, 3, 30), + decidedAt: DateTime(2024, 3, 30, 12), + note: '반려 메모', + steps: steps, + histories: const [], + updatedAt: DateTime(2024, 3, 30, 12), + currentStep: null, + transactionUpdatedAt: DateTime(2024, 3, 30, 11, 45), + ); + return ApprovalFlow(approval: approval); + } + + Future buildController(ApprovalFlow flow) async { + final controller = ApprovalHistoryController( + repository: historyRepository, + approvalRepository: approvalRepository, + recallUseCase: recallUseCase, + resubmitUseCase: resubmitUseCase, + ); + + when( + () => approvalRepository.fetchDetail( + flow.id!, + includeSteps: any(named: 'includeSteps'), + includeHistories: any(named: 'includeHistories'), + ), + ).thenAnswer((_) async => flow.approval); + when(() => approvalRepository.canProceed(flow.id!)).thenAnswer( + (_) async => ApprovalProceedStatus( + approvalId: flow.id!, + canProceed: flow.currentStep != null, + ), + ); + + await controller.loadApprovalFlow(flow.id!); + return controller; + } + + testWidgets('회수 버튼이 노트를 전달해 유즈케이스를 호출한다', (tester) async { + final flow = buildRecallableFlow(); + final controller = await buildController(flow); + + ApprovalRecallInput? captured; + when(() => recallUseCase.call(any())).thenAnswer((invocation) async { + captured = invocation.positionalArguments.first as ApprovalRecallInput; + return flow; + }); + + await tester.pumpWidget( + _ApprovalActionHarness(controller: controller, actorId: 99), + ); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const ValueKey('recall-note-field')), + ' 긴급 회수 ', + ); + await tester.tap(find.text('회수')); + await tester.pumpAndSettle(); + + expect(captured, isNotNull); + expect(captured?.approvalId, flow.id); + expect(captured?.actorId, 99); + expect(captured?.note, '긴급 회수'); + expect(captured?.transactionExpectedUpdatedAt, flow.transactionUpdatedAt); + }); + + testWidgets('재상신 버튼이 템플릿 단계를 포함해 유즈케이스를 호출한다', (tester) async { + final flow = buildResubmittableFlow(); + final controller = await buildController(flow); + + ApprovalResubmissionInput? captured; + when(() => resubmitUseCase.call(any())).thenAnswer((invocation) async { + captured = + invocation.positionalArguments.first as ApprovalResubmissionInput; + return flow; + }); + + await tester.pumpWidget( + _ApprovalActionHarness(controller: controller, actorId: 99), + ); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const ValueKey('resubmit-note-field')), + ' 재상신 메모 ', + ); + await tester.tap(find.text('재상신')); + await tester.pumpAndSettle(); + + expect(captured, isNotNull); + expect(captured?.actorId, 99); + expect(captured?.note, '재상신 메모'); + expect(captured?.submission.steps.length, flow.steps.length); + expect(captured?.submission.requesterId, flow.requester.id); + expect(captured?.submission.transactionId, flow.transactionId); + expect(captured?.transactionExpectedUpdatedAt, flow.transactionUpdatedAt); + }); +} + +class _ApprovalActionHarness extends StatefulWidget { + const _ApprovalActionHarness({ + required this.controller, + required this.actorId, + }); + + final ApprovalHistoryController controller; + final int actorId; + + @override + State<_ApprovalActionHarness> createState() => _ApprovalActionHarnessState(); +} + +class _ApprovalActionHarnessState extends State<_ApprovalActionHarness> { + final TextEditingController _recallNoteController = TextEditingController(); + final TextEditingController _resubmitNoteController = TextEditingController(); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: ShadTheme( + data: ShadThemeData( + colorScheme: const ShadSlateColorScheme.light(), + brightness: Brightness.light, + ), + child: Scaffold( + body: AnimatedBuilder( + animation: widget.controller, + builder: (context, _) { + final flow = widget.controller.selectedFlow; + if (flow == null) { + return const SizedBox.shrink(); + } + final canRecall = _canRecall(flow); + final canResubmit = _canResubmit(flow); + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShadInput( + key: const ValueKey('recall-note-field'), + controller: _recallNoteController, + placeholder: const Text('회수 사유'), + ), + const SizedBox(height: 12), + ShadButton( + onPressed: canRecall ? () => _handleRecall(flow) : null, + child: const Text('회수'), + ), + const SizedBox(height: 24), + ShadInput( + key: const ValueKey('resubmit-note-field'), + controller: _resubmitNoteController, + placeholder: const Text('재상신 메모'), + ), + const SizedBox(height: 12), + ShadButton.outline( + onPressed: canResubmit + ? () => _handleResubmit(flow) + : null, + child: const Text('재상신'), + ), + ], + ), + ); + }, + ), + ), + ), + ); + } + + bool _canRecall(ApprovalFlow flow) { + if (flow.status.isTerminal) { + return false; + } + if (flow.steps.isEmpty) { + return false; + } + return flow.steps.first.decidedAt == null; + } + + bool _canResubmit(ApprovalFlow flow) { + if (!flow.status.isTerminal) { + return false; + } + final statusName = flow.status.name.toLowerCase(); + return statusName.contains('반려') || statusName.contains('reject'); + } + + Future _handleRecall(ApprovalFlow flow) async { + final latestFlow = await widget.controller.refreshFlow(flow.id!) ?? flow; + final transactionUpdatedAt = latestFlow.transactionUpdatedAt; + if (transactionUpdatedAt == null) { + return; + } + final input = ApprovalRecallInput( + approvalId: latestFlow.id!, + actorId: widget.actorId, + note: _trimmedOrNull(_recallNoteController.text), + expectedUpdatedAt: latestFlow.approval.updatedAt, + transactionExpectedUpdatedAt: transactionUpdatedAt, + ); + await widget.controller.recallApproval(input); + } + + Future _handleResubmit(ApprovalFlow flow) async { + final latestFlow = await widget.controller.refreshFlow(flow.id!) ?? flow; + final transactionUpdatedAt = latestFlow.transactionUpdatedAt; + if (transactionUpdatedAt == null) { + return; + } + final steps = latestFlow.steps + .map( + (step) => ApprovalStepAssignmentItem( + stepOrder: step.stepOrder, + approverId: step.approver.id, + note: step.note, + ), + ) + .toList(growable: false); + final submission = ApprovalSubmissionInput( + transactionId: latestFlow.transactionId, + statusId: latestFlow.status.id, + requesterId: latestFlow.requester.id, + finalApproverId: latestFlow.finalApprover?.id, + note: latestFlow.note, + steps: steps, + ); + final input = ApprovalResubmissionInput( + approvalId: latestFlow.id!, + actorId: widget.actorId, + submission: submission, + note: _trimmedOrNull(_resubmitNoteController.text), + expectedUpdatedAt: latestFlow.approval.updatedAt, + transactionExpectedUpdatedAt: transactionUpdatedAt, + ); + await widget.controller.resubmitApproval(input); + } + + String? _trimmedOrNull(String value) { + final trimmed = value.trim(); + if (trimmed.isEmpty) { + return null; + } + return trimmed; + } + + @override + void dispose() { + _recallNoteController.dispose(); + _resubmitNoteController.dispose(); + super.dispose(); + } +} diff --git a/test/features/approvals/presentation/controllers/approval_controller_test.dart b/test/features/approvals/presentation/controllers/approval_controller_test.dart index 83d0ad6..d2fc9c6 100644 --- a/test/features/approvals/presentation/controllers/approval_controller_test.dart +++ b/test/features/approvals/presentation/controllers/approval_controller_test.dart @@ -1,16 +1,24 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/features/approvals/data/dtos/approval_dto.dart'; import 'package:superport_v2/features/approvals/domain/entities/approval.dart'; +import 'package:superport_v2/features/approvals/domain/entities/approval_draft.dart'; import 'package:superport_v2/features/approvals/domain/entities/approval_template.dart'; import 'package:superport_v2/features/approvals/domain/entities/approval_proceed_status.dart'; import 'package:superport_v2/features/approvals/domain/repositories/approval_repository.dart'; import 'package:superport_v2/features/approvals/domain/repositories/approval_template_repository.dart'; +import 'package:superport_v2/features/approvals/domain/usecases/get_approval_draft_use_case.dart'; +import 'package:superport_v2/features/approvals/domain/usecases/list_approval_drafts_use_case.dart'; +import 'package:superport_v2/features/approvals/domain/usecases/save_approval_draft_use_case.dart'; import 'package:superport_v2/features/approvals/presentation/controllers/approval_controller.dart'; import 'package:superport_v2/features/inventory/lookups/domain/entities/lookup_item.dart'; import 'package:superport_v2/features/inventory/lookups/domain/repositories/inventory_lookup_repository.dart'; +import '../../../../helpers/fixture_loader.dart'; + /// ApprovalRepository 모킹 클래스. class _MockApprovalRepository extends Mock implements ApprovalRepository {} @@ -34,31 +42,25 @@ class _FakeStepAssignmentInput extends Fake class _MockInventoryLookupRepository extends Mock implements InventoryLookupRepository {} +class _MockSaveApprovalDraftUseCase extends Mock + implements SaveApprovalDraftUseCase {} + +class _MockGetApprovalDraftUseCase extends Mock + implements GetApprovalDraftUseCase {} + +class _MockListApprovalDraftsUseCase extends Mock + implements ListApprovalDraftsUseCase {} + void main() { late ApprovalController controller; late _MockApprovalRepository repository; late _MockApprovalTemplateRepository templateRepository; - final sampleStep = ApprovalStep( - id: 11, - stepOrder: 1, - approver: ApprovalApprover(id: 21, employeeNo: 'E001', name: '최승인'), - status: ApprovalStatus(id: 1, name: '대기'), - assignedAt: DateTime(2024, 4, 1, 9), - ); + final sampleApproval = ApprovalDto.fromJson( + loadJsonFixture('approvals/approval_five_step_pending.json'), + ).toEntity(); - final sampleApproval = Approval( - id: 1, - approvalNo: 'AP-24001', - transactionNo: 'TRX-001', - status: ApprovalStatus(id: 1, name: '대기'), - currentStep: sampleStep, - requester: ApprovalRequester(id: 31, employeeNo: 'EMP001', name: '김상신'), - requestedAt: DateTime(2024, 4, 1, 9), - note: '긴급 결재', - steps: [sampleStep], - histories: const [], - ); + final sampleStep = sampleApproval.currentStep ?? sampleApproval.steps.first; /// 테스트용 페이징 응답을 생성하는 헬퍼. PaginatedResult createResult(List items) { @@ -75,6 +77,13 @@ void main() { registerFallbackValue(_FakeApprovalUpdateInput()); registerFallbackValue(_FakeStepActionInput()); registerFallbackValue(_FakeStepAssignmentInput()); + registerFallbackValue(const ApprovalDraftListFilter(requesterId: 0)); + registerFallbackValue( + ApprovalDraftSaveInput( + requesterId: 0, + steps: [ApprovalDraftStep(stepOrder: 0, approverId: 0)], + ), + ); }); setUp(() { @@ -90,6 +99,19 @@ void main() { canProceed: true, ), ); + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + transactionId: any(named: 'transactionId'), + approvalStatusId: any(named: 'approvalStatusId'), + requestedById: any(named: 'requestedById'), + statusCodes: any(named: 'statusCodes'), + includePending: any(named: 'includePending'), + includeHistories: any(named: 'includeHistories'), + includeSteps: any(named: 'includeSteps'), + ), + ).thenAnswer((_) async => createResult([sampleApproval])); }); // fetch 메서드 관련 시나리오 @@ -102,6 +124,8 @@ void main() { transactionId: any(named: 'transactionId'), approvalStatusId: any(named: 'approvalStatusId'), requestedById: any(named: 'requestedById'), + statusCodes: any(named: 'statusCodes'), + includePending: any(named: 'includePending'), includeHistories: any(named: 'includeHistories'), includeSteps: any(named: 'includeSteps'), ), @@ -116,6 +140,29 @@ void main() { expect(controller.errorMessage, isNull); }); + test('전체 상태 필터는 include_pending을 true로 전달한다', () async { + await controller.fetch(); + + final captured = verify( + () => repository.list( + page: 1, + pageSize: 20, + transactionId: null, + approvalStatusId: null, + requestedById: null, + statusCodes: captureAny(named: 'statusCodes'), + includePending: captureAny(named: 'includePending'), + includeHistories: false, + includeSteps: false, + ), + ).captured; + + final includePending = captured[0] as bool; + final statusCodes = captured[1] as List?; + expect(includePending, isTrue); + expect(statusCodes, isNull); + }); + // 검색어/상태/기간 필터가 Repository 호출에 반영되는지 확인한다. test('필터 전달을 검증한다', () async { controller.updateTransactionFilter(55); @@ -128,17 +175,52 @@ void main() { await controller.fetch(page: 3); - verify( + final captured = verify( () => repository.list( page: 3, pageSize: 20, transactionId: 55, approvalStatusId: null, requestedById: 77, + statusCodes: captureAny(named: 'statusCodes'), + includePending: captureAny(named: 'includePending'), includeHistories: false, includeSteps: false, ), - ).called(1); + ).captured; + + final includePending = captured[0] as bool; + final statusCodes = captured[1] as List?; + expect(includePending, isFalse); + expect(listEquals(statusCodes, const ['approved']), isTrue); + }); + + test('대기 상태 필터는 초안·상신 상태 코드를 전달한다', () async { + controller.updateStatusFilter(ApprovalStatusFilter.pending); + + await controller.fetch(); + + final captured = verify( + () => repository.list( + page: 1, + pageSize: 20, + transactionId: null, + approvalStatusId: null, + requestedById: null, + statusCodes: captureAny(named: 'statusCodes'), + includePending: captureAny(named: 'includePending'), + includeHistories: false, + includeSteps: false, + ), + ).captured; + + final includePending = captured[0] as bool; + final statusCodes = captured[1] as List?; + expect(includePending, isFalse); + expect( + listEquals(statusCodes, const ['draft', 'submitted', 'in_progress']), + isTrue, + ); }); // Repository 오류 발생 시 errorMessage가 설정된다. @@ -150,6 +232,8 @@ void main() { transactionId: any(named: 'transactionId'), approvalStatusId: any(named: 'approvalStatusId'), requestedById: any(named: 'requestedById'), + statusCodes: any(named: 'statusCodes'), + includePending: any(named: 'includePending'), includeHistories: any(named: 'includeHistories'), includeSteps: any(named: 'includeSteps'), ), @@ -187,17 +271,24 @@ void main() { ctrl.updateStatusFilter(ApprovalStatusFilter.approved); await ctrl.fetch(); - verify( + final captured = verify( () => repository.list( page: 1, pageSize: 20, transactionId: null, approvalStatusId: 5, requestedById: null, + statusCodes: captureAny(named: 'statusCodes'), + includePending: captureAny(named: 'includePending'), includeHistories: false, includeSteps: false, ), - ).called(1); + ).captured; + + final includePending = captured[0] as bool; + final statusCodes = captured[1] as List?; + expect(includePending, isFalse); + expect(listEquals(statusCodes, const ['approved']), isTrue); }); }); @@ -211,17 +302,17 @@ void main() { ), ).thenAnswer((_) async => sampleApproval); - await controller.selectApproval(1); + await controller.selectApproval(sampleApproval.id!); expect(controller.selected, isNotNull); verify( () => repository.fetchDetail( - 1, + sampleApproval.id!, includeSteps: true, includeHistories: true, ), ).called(1); - verify(() => repository.canProceed(1)).called(1); + verify(() => repository.canProceed(sampleApproval.id!)).called(1); expect(controller.canProceedSelected, isTrue); }); @@ -234,7 +325,7 @@ void main() { ), ).thenThrow(Exception('detail fail')); - await controller.selectApproval(1); + await controller.selectApproval(sampleApproval.id!); expect(controller.errorMessage, isNotNull); }); @@ -392,6 +483,7 @@ void main() { await controller.loadActionOptions(force: true); await controller.fetch(); + expect(controller.result, isNotNull); await controller.selectApproval(sampleApproval.id!); final success = await controller.performStepAction( @@ -400,8 +492,12 @@ void main() { ); expect(success, isTrue); - expect(controller.selected?.status.name, '승인'); - expect(controller.result?.items.first.status.name, '승인'); + expect(controller.selected?.status.id, updatedApproval.status.id); + expect(controller.result, isNotNull); + expect( + controller.result!.items.first.status.id, + updatedApproval.status.id, + ); expect(controller.isPerformingAction, isFalse); verify(() => repository.performStepAction(any())).called(1); }); @@ -616,6 +712,165 @@ void main() { }); }); + test('cacheSubmissionDraft와 consumeSubmissionDraft가 초안을 관리한다', () { + final draft = ApprovalSubmissionInput( + statusId: 1, + requesterId: 5, + steps: const [], + ); + + controller.cacheSubmissionDraft(draft); + + expect(controller.hasSubmissionDraft, isTrue); + expect(controller.submissionDraft, same(draft)); + + final restored = controller.consumeSubmissionDraft(); + + expect(restored, same(draft)); + expect(controller.hasSubmissionDraft, isFalse); + }); + + test('clearSubmissionDraft는 저장된 초안을 제거한다', () { + controller.cacheSubmissionDraft( + ApprovalSubmissionInput(statusId: 1, requesterId: 2, steps: const []), + ); + + controller.clearSubmissionDraft(); + + expect(controller.submissionDraft, isNull); + expect(controller.hasSubmissionDraft, isFalse); + }); + + test('cacheSubmissionDraft는 서버 초안을 저장한다', () async { + final saveUseCase = _MockSaveApprovalDraftUseCase(); + final detail = ApprovalDraftDetail( + id: 99, + requesterId: 5, + savedAt: DateTime.utc(2025, 1, 1), + payload: ApprovalDraftPayload(steps: const []), + ); + when(() => saveUseCase.call(any())).thenAnswer((_) async => detail); + + final controllerWithSave = ApprovalController( + approvalRepository: repository, + templateRepository: templateRepository, + saveDraftUseCase: saveUseCase, + ); + + final draft = ApprovalSubmissionInput( + statusId: 3, + requesterId: 5, + transactionId: 10, + steps: [ApprovalStepAssignmentItem(stepOrder: 1, approverId: 7)], + ); + + controllerWithSave.cacheSubmissionDraft(draft); + await Future.delayed(Duration.zero); + + final captured = verify(() => saveUseCase.call(captureAny())).captured; + expect(captured, isNotEmpty); + final input = captured.first as ApprovalDraftSaveInput; + expect(input.requesterId, 5); + expect(input.transactionId, 10); + expect(input.statusId, 3); + expect(input.sessionKey, 'approval_submission_5'); + }); + + test('restoreSubmissionDraft는 서버 초안을 불러온다', () async { + final listUseCase = _MockListApprovalDraftsUseCase(); + final getUseCase = _MockGetApprovalDraftUseCase(); + final summary = ApprovalDraftSummary( + id: 77, + requesterId: 5, + status: ApprovalDraftStatus.active, + savedAt: DateTime.utc(2025, 1, 3), + sessionKey: 'approval_submission_5', + stepCount: 1, + ); + when(() => listUseCase.call(any())).thenAnswer( + (_) async => PaginatedResult( + items: [summary], + page: 1, + pageSize: 10, + total: 1, + ), + ); + final detail = ApprovalDraftDetail( + id: summary.id, + requesterId: 5, + savedAt: DateTime.utc(2025, 1, 3), + payload: ApprovalDraftPayload( + templateId: 8, + metadata: const { + '_client_state': {'status_id': 6}, + }, + steps: [ApprovalDraftStep(stepOrder: 1, approverId: 9)], + ), + ); + when( + () => getUseCase.call(id: summary.id, requesterId: 5), + ).thenAnswer((_) async => detail); + + final controllerWithDrafts = ApprovalController( + approvalRepository: repository, + templateRepository: templateRepository, + listDraftsUseCase: listUseCase, + getDraftUseCase: getUseCase, + ); + + final restored = await controllerWithDrafts.restoreSubmissionDraft( + requesterId: 5, + ); + + expect(restored, isNotNull); + expect(restored!.statusId, 6); + expect(restored.steps, hasLength(1)); + expect(controllerWithDrafts.submissionDraft, isNotNull); + + verify(() => listUseCase.call(any())).called(1); + verify(() => getUseCase.call(id: summary.id, requesterId: 5)).called(1); + }); + + test('statusLabel은 draft 라벨을 반환한다', () { + final label = controller.statusLabel(ApprovalStatusFilter.draft); + + expect(label, '임시저장'); + }); + + test('목록에서 사라진 결재는 선택 상태를 해제한다', () async { + when( + () => repository.fetchDetail( + any(), + includeSteps: any(named: 'includeSteps'), + includeHistories: any(named: 'includeHistories'), + ), + ).thenAnswer((_) async => sampleApproval); + + await controller.fetch(); + await controller.selectApproval(sampleApproval.id!); + expect(controller.selected, isNotNull); + expect(controller.proceedStatus, isNotNull); + + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + transactionId: any(named: 'transactionId'), + approvalStatusId: any(named: 'approvalStatusId'), + requestedById: any(named: 'requestedById'), + statusCodes: any(named: 'statusCodes'), + includePending: any(named: 'includePending'), + includeHistories: any(named: 'includeHistories'), + includeSteps: any(named: 'includeSteps'), + ), + ).thenAnswer((_) async => createResult(const [])); + + await controller.fetch(); + + expect(controller.selected, isNull); + expect(controller.proceedStatus, isNull); + }); + test('필터 초기화', () { controller.updateTransactionFilter(42); controller.updateStatusFilter(ApprovalStatusFilter.rejected); diff --git a/test/features/approvals/request/presentation/controllers/approval_request_controller_test.dart b/test/features/approvals/request/presentation/controllers/approval_request_controller_test.dart new file mode 100644 index 0000000..76e67c7 --- /dev/null +++ b/test/features/approvals/request/presentation/controllers/approval_request_controller_test.dart @@ -0,0 +1,219 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:superport_v2/features/approvals/request/presentation/controllers/approval_request_controller.dart'; + +void main() { + ApprovalRequestParticipant buildParticipant(int id) { + return ApprovalRequestParticipant( + id: id, + name: '사용자$id', + employeeNo: 'EMP$id', + ); + } + + group('ApprovalRequestController', () { + late ApprovalRequestController controller; + + setUp(() { + controller = ApprovalRequestController(); + }); + + test('addStep 성공 시 최종 승인자로 바인딩된다', () { + final approver = buildParticipant(10); + + final added = controller.addStep(approver: approver, note: '검토 필요'); + + expect(added, isTrue); + expect(controller.steps, hasLength(1)); + expect(controller.steps.first.stepOrder, 1); + expect(controller.finalApprover?.id, approver.id); + expect(controller.errorMessage, isNull); + }); + + test('최대 단계 수를 초과하면 추가를 거부한다', () { + for (var i = 0; i < controller.maxSteps; i++) { + final success = controller.addStep(approver: buildParticipant(i + 1)); + expect(success, isTrue); + } + + final result = controller.addStep(approver: buildParticipant(999)); + + expect(result, isFalse); + expect(controller.errorMessage, isNotNull); + expect(controller.steps, hasLength(controller.maxSteps)); + }); + + test('동일한 승인자를 중복 추가할 수 없다', () { + final approver = buildParticipant(3); + controller.addStep(approver: approver); + + final duplicated = controller.addStep(approver: approver); + + expect(duplicated, isFalse); + expect(controller.errorMessage, isNotNull); + expect(controller.steps, hasLength(1)); + }); + + test('상신자는 승인자로 추가할 수 없다', () { + final requester = buildParticipant(7); + controller.setRequester(requester); + + final added = controller.addStep(approver: requester); + + expect(added, isFalse); + expect(controller.steps, isEmpty); + expect(controller.errorMessage, isNotNull); + }); + + test('updateStep에서 승인자 중복을 감지한다', () { + controller + ..addStep(approver: buildParticipant(1)) + ..addStep(approver: buildParticipant(2)); + + final updated = controller.updateStep(1, approver: buildParticipant(1)); + + expect(updated, isFalse); + expect(controller.steps[1].approver.id, 2); + expect(controller.errorMessage, isNotNull); + }); + + test('removeStepAt 호출 시 순번을 재정렬한다', () { + controller + ..addStep(approver: buildParticipant(1)) + ..addStep(approver: buildParticipant(2)) + ..addStep(approver: buildParticipant(3)); + + controller.removeStepAt(1); + + expect(controller.steps, hasLength(2)); + expect(controller.steps.first.stepOrder, 1); + expect(controller.steps.last.stepOrder, 2); + }); + + test('moveStep 호출 시 순서를 변경한다', () { + controller + ..addStep(approver: buildParticipant(1)) + ..addStep(approver: buildParticipant(2)) + ..addStep(approver: buildParticipant(3)); + + controller.moveStep(2, 0); + + expect(controller.steps.first.approver.id, 3); + expect(controller.steps.first.stepOrder, 1); + expect(controller.steps.last.approver.id, 2); + expect(controller.steps.last.stepOrder, 3); + }); + + test('setFinalApprover는 단계가 없을 때 새 단계를 추가한다', () { + final approver = buildParticipant(55); + + final result = controller.setFinalApprover(approver); + + expect(result, isTrue); + expect(controller.steps, hasLength(1)); + expect(controller.finalApprover?.id, approver.id); + }); + + test('buildTransactionApprovalInput은 필수 정보를 누락하면 예외를 던진다', () { + controller.addStep(approver: buildParticipant(1)); + + expect( + () => controller.buildTransactionApprovalInput(), + throwsStateError, + ); + }); + + test('buildTransactionApprovalInput은 요청 데이터를 변환한다', () { + final requester = buildParticipant(77); + controller.setRequester(requester); + controller + ..addStep(approver: buildParticipant(101), note: '검토') + ..addStep(approver: buildParticipant(102)); + + final approvalInput = controller.buildTransactionApprovalInput( + approvalStatusId: 9, + title: '입고 결재', + summary: '재고 입고 승인 요청', + note: '긴급 승인 필요', + metadata: const {'priority': 'high'}, + ); + + final payload = approvalInput.toJson(); + expect(payload['requested_by_id'], requester.id); + expect(payload['approval_status_id'], 9); + expect(payload['final_approver_id'], 102); + expect(payload['title'], '입고 결재'); + expect(payload['summary'], '재고 입고 승인 요청'); + expect(payload['note'], '긴급 승인 필요'); + final steps = payload['steps'] as List; + expect(steps, hasLength(2)); + expect(steps.first['step_order'], 1); + expect(steps.first['approver_id'], 101); + expect(steps.first['note'], '검토'); + expect(steps.last['step_order'], 2); + expect(steps.last['approver_id'], 102); + }); + + test('buildSubmissionInput 변환 시 finalApproverId를 채운다', () { + controller + ..setRequester(buildParticipant(2)) + ..addStep(approver: buildParticipant(10)) + ..addStep(approver: buildParticipant(11), note: '최종 검토'); + + final submission = controller.buildSubmissionInput(statusId: 4); + + expect(submission.requesterId, 2); + expect(submission.finalApproverId, 11); + expect(submission.steps, hasLength(2)); + }); + + test('applyTemplateSteps는 전달된 순서를 유지한다', () { + final steps = [ + ApprovalRequestStep(stepOrder: 3, approver: buildParticipant(90)), + ApprovalRequestStep(stepOrder: 9, approver: buildParticipant(91)), + ]; + + controller.applyTemplateSteps(steps); + + expect(controller.steps.first.stepOrder, 1); + expect(controller.steps.last.stepOrder, 2); + expect(controller.finalApproverId, 91); + }); + + test('중복 승인자가 있는 상태에서 빌드 시 예외가 발생한다', () { + controller.setRequester(buildParticipant(5)); + controller.applyTemplateSteps([ + ApprovalRequestStep(stepOrder: 1, approver: buildParticipant(8)), + ApprovalRequestStep(stepOrder: 2, approver: buildParticipant(8)), + ]); + + expect( + () => controller.buildSubmissionInput(statusId: 1), + throwsStateError, + ); + }); + + test('상신자와 동일한 승인자가 포함된 템플릿은 적용되지 않는다', () { + final requester = buildParticipant(20); + controller.setRequester(requester); + + controller.applyTemplateSteps([ + ApprovalRequestStep(stepOrder: 1, approver: requester), + ApprovalRequestStep(stepOrder: 2, approver: buildParticipant(21)), + ]); + + expect(controller.steps, isEmpty); + expect(controller.errorMessage, isNotNull); + }); + + test('상신자 변경 시 승인자와 겹치면 오류가 발생한다', () { + controller + ..addStep(approver: buildParticipant(40)) + ..addStep(approver: buildParticipant(41)); + + controller.setRequester(buildParticipant(41)); + + expect(controller.requester?.id, 41); + expect(controller.errorMessage, isNotNull); + }); + }); +} diff --git a/test/features/approvals/request/presentation/utils/approval_form_initializer_test.dart b/test/features/approvals/request/presentation/utils/approval_form_initializer_test.dart new file mode 100644 index 0000000..c2558ac --- /dev/null +++ b/test/features/approvals/request/presentation/utils/approval_form_initializer_test.dart @@ -0,0 +1,44 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:superport_v2/features/approvals/domain/entities/approval.dart'; +import 'package:superport_v2/features/approvals/request/presentation/controllers/approval_request_controller.dart'; +import 'package:superport_v2/features/approvals/request/presentation/utils/approval_form_initializer.dart'; +import 'package:superport_v2/features/inventory/transactions/domain/entities/stock_transaction_input.dart'; + +void main() { + group('ApprovalFormInitializer.populate', () { + test('초안 저장본이 있으면 상신자와 단계를 복구한다', () { + final controller = ApprovalRequestController(); + final draft = StockTransactionApprovalInput( + requestedById: 101, + steps: [ + ApprovalStepAssignmentItem(stepOrder: 1, approverId: 104), + ApprovalStepAssignmentItem(stepOrder: 2, approverId: 201), + ], + ); + + ApprovalFormInitializer.populate(controller: controller, draft: draft); + + expect(controller.requester?.id, equals(101)); + expect(controller.steps.length, equals(2)); + expect(controller.steps.first.approver.id, equals(104)); + expect(controller.steps.last.approver.id, equals(201)); + }); + + test('카탈로그에 없는 승인자는 복구 대상에서 제외한다', () { + final controller = ApprovalRequestController(); + final draft = StockTransactionApprovalInput( + requestedById: 101, + steps: [ + ApprovalStepAssignmentItem(stepOrder: 1, approverId: 104), + ApprovalStepAssignmentItem(stepOrder: 2, approverId: 9999), + ], + ); + + ApprovalFormInitializer.populate(controller: controller, draft: draft); + + expect(controller.steps.length, equals(1)); + expect(controller.steps.first.approver.id, equals(104)); + }); + }); +} diff --git a/test/features/approvals/request/presentation/widgets/approval_step_configurator_test.dart b/test/features/approvals/request/presentation/widgets/approval_step_configurator_test.dart new file mode 100644 index 0000000..6182af6 --- /dev/null +++ b/test/features/approvals/request/presentation/widgets/approval_step_configurator_test.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import 'package:superport_v2/features/approvals/request/presentation/controllers/approval_request_controller.dart'; +import 'package:superport_v2/features/approvals/request/presentation/widgets/approval_step_configurator.dart'; + +Widget _buildTestApp(Widget child) { + return MaterialApp( + home: ShadTheme( + data: ShadThemeData( + colorScheme: const ShadSlateColorScheme.light(), + brightness: Brightness.light, + ), + child: Scaffold(body: child), + ), + ); +} + +ApprovalRequestParticipant _participant(int id, String name) { + return ApprovalRequestParticipant(id: id, name: name, employeeNo: 'EMP$id'); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('요약 섹션에 상신자와 단계 정보가 표시된다', (tester) async { + final controller = ApprovalRequestController(); + controller.setRequester(_participant(1, '상신자')); + controller.addStep(approver: _participant(2, '1차 승인자')); + controller.addStep(approver: _participant(3, '최종 승인자')); + + await tester.pumpWidget( + _buildTestApp(ApprovalStepConfigurator(controller: controller)), + ); + await tester.pumpAndSettle(); + + expect(find.textContaining('상신자: 상신자'), findsOneWidget); + expect(find.textContaining('최종 승인자: 최종 승인자'), findsOneWidget); + expect(find.textContaining('총 단계: 2개'), findsOneWidget); + }); + + testWidgets('편집 버튼을 누르면 구성 모달이 열린다', (tester) async { + final controller = ApprovalRequestController(); + controller.setRequester(_participant(1, '사용자A')); + + await tester.pumpWidget( + _buildTestApp(ApprovalStepConfigurator(controller: controller)), + ); + await tester.pump(); + + await tester.tap(find.text('단계 구성 편집')); + await tester.pumpAndSettle(); + + expect(find.text('결재 단계 구성'), findsWidgets); + expect(find.text('결재 단계 목록'), findsOneWidget); + }); +} diff --git a/test/features/approvals/request/presentation/widgets/approval_template_picker_test.dart b/test/features/approvals/request/presentation/widgets/approval_template_picker_test.dart new file mode 100644 index 0000000..43a57d7 --- /dev/null +++ b/test/features/approvals/request/presentation/widgets/approval_template_picker_test.dart @@ -0,0 +1,136 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/features/approvals/domain/entities/approval_template.dart'; +import 'package:superport_v2/features/approvals/domain/repositories/approval_template_repository.dart'; +import 'package:superport_v2/features/approvals/request/presentation/controllers/approval_request_controller.dart'; +import 'package:superport_v2/features/approvals/request/presentation/widgets/approval_template_picker.dart'; + +class _MockApprovalTemplateRepository extends Mock + implements ApprovalTemplateRepository {} + +Widget _buildApp(Widget child) { + return MaterialApp( + home: ShadTheme( + data: ShadThemeData( + colorScheme: const ShadSlateColorScheme.light(), + brightness: Brightness.light, + ), + child: Scaffold(body: child), + ), + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late _MockApprovalTemplateRepository repository; + + setUp(() { + repository = _MockApprovalTemplateRepository(); + }); + + testWidgets('템플릿을 선택해 적용하면 단계가 컨트롤러에 반영된다', (tester) async { + final view = tester.view; + view.physicalSize = const Size(1280, 800); + view.devicePixelRatio = 1.0; + addTearDown(() { + view.resetPhysicalSize(); + view.resetDevicePixelRatio(); + }); + + final controller = ApprovalRequestController(); + final template = ApprovalTemplate( + id: 1, + code: 'AP-RENTAL', + name: '입고 결재 템플릿', + isActive: true, + steps: const [], + updatedAt: DateTime.utc(2025, 1, 1), + createdBy: ApprovalTemplateAuthor( + id: 7, + employeeNo: 'EMP-7', + name: '관리자', + ), + ); + final templateDetail = template.copyWith( + steps: [ + ApprovalTemplateStep( + id: 10, + stepOrder: 1, + approver: ApprovalTemplateApprover( + id: 101, + employeeNo: 'EMP-101', + name: '1차 승인자', + ), + note: '재고 확인', + ), + ApprovalTemplateStep( + id: 11, + stepOrder: 2, + approver: ApprovalTemplateApprover( + id: 102, + employeeNo: 'EMP-102', + name: '최종 승인자', + ), + note: '승인 처리', + ), + ], + ); + + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + isActive: any(named: 'isActive'), + ), + ).thenAnswer( + (_) async => PaginatedResult( + items: [template], + page: 1, + pageSize: 30, + total: 1, + ), + ); + when( + () => repository.fetchDetail( + template.id, + includeSteps: any(named: 'includeSteps'), + ), + ).thenAnswer((_) async => templateDetail); + + final applied = []; + controller.setTemplateSnapshot( + ApprovalTemplateSnapshot( + templateId: template.id, + updatedAt: template.updatedAt, + ), + ); + + await tester.pumpWidget( + _buildApp( + ApprovalTemplatePicker( + controller: controller, + repository: repository, + onTemplateApplied: applied.add, + ), + ), + ); + await tester.pump(); + await tester.pumpAndSettle(); + + final applyButton = find.widgetWithText(ShadButton, '템플릿 적용'); + expect(tester.widget(applyButton).onPressed, isNotNull); + + await tester.tap(applyButton); + await tester.pumpAndSettle(); + + expect(controller.steps, hasLength(2)); + expect(controller.steps.first.approver.name, '1차 승인자'); + expect(controller.steps.last.approver.name, '최종 승인자'); + expect(applied.single.id, templateDetail.id); + }); +} diff --git a/test/features/approvals/template/presentation/controllers/approval_template_controller_test.dart b/test/features/approvals/template/presentation/controllers/approval_template_controller_test.dart index f753f1b..ef7d5d6 100644 --- a/test/features/approvals/template/presentation/controllers/approval_template_controller_test.dart +++ b/test/features/approvals/template/presentation/controllers/approval_template_controller_test.dart @@ -2,13 +2,26 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/features/approvals/data/dtos/approval_template_dto.dart'; +import 'package:superport_v2/features/approvals/domain/entities/approval.dart'; +import 'package:superport_v2/features/approvals/domain/entities/approval_flow.dart'; import 'package:superport_v2/features/approvals/domain/entities/approval_template.dart'; import 'package:superport_v2/features/approvals/domain/repositories/approval_template_repository.dart'; +import 'package:superport_v2/features/approvals/domain/usecases/apply_approval_template_use_case.dart'; +import 'package:superport_v2/features/approvals/domain/usecases/save_approval_template_use_case.dart'; import 'package:superport_v2/features/approvals/template/presentation/controllers/approval_template_controller.dart'; +import '../../../../../helpers/fixture_loader.dart'; + class _MockApprovalTemplateRepository extends Mock implements ApprovalTemplateRepository {} +class _MockSaveApprovalTemplateUseCase extends Mock + implements SaveApprovalTemplateUseCase {} + +class _MockApplyApprovalTemplateUseCase extends Mock + implements ApplyApprovalTemplateUseCase {} + class _FakeTemplateInput extends Fake implements ApprovalTemplateInput {} class _FakeStepInput extends Fake implements ApprovalTemplateStepInput {} @@ -16,19 +29,12 @@ class _FakeStepInput extends Fake implements ApprovalTemplateStepInput {} void main() { late ApprovalTemplateController controller; late _MockApprovalTemplateRepository repository; + late _MockSaveApprovalTemplateUseCase saveUseCase; + late _MockApplyApprovalTemplateUseCase applyUseCase; - final sampleTemplate = ApprovalTemplate( - id: 1, - code: 'AP_INBOUND', - name: '입고 결재 기본', - description: '입고 2단계', - note: '기본 템플릿', - isActive: true, - createdBy: null, - createdAt: DateTime(2024, 4, 1, 9), - updatedAt: DateTime(2024, 4, 2, 9), - steps: const [], - ); + final sampleTemplate = ApprovalTemplateDto.fromJson( + loadJsonFixture('approvals/approval_template_sample.json'), + ).toEntity(); PaginatedResult createResult(List items) { return PaginatedResult( @@ -39,6 +45,36 @@ void main() { ); } + ApprovalFlow createFlow() { + final status = ApprovalStatus(id: 10, name: '대기'); + final requester = ApprovalRequester( + id: 99, + employeeNo: 'EMP099', + name: '상신자', + ); + final approver = ApprovalApprover( + id: 100, + employeeNo: 'EMP100', + name: '승인자', + ); + final step = ApprovalStep( + stepOrder: 1, + approver: approver, + status: status, + assignedAt: DateTime(2024, 4, 1), + ); + final approval = Approval( + id: 7, + approvalNo: 'APP-20240401-0001', + transactionNo: 'TRX-001', + status: status, + requester: requester, + requestedAt: DateTime(2024, 4, 1), + steps: [step], + ); + return ApprovalFlow(approval: approval); + } + setUpAll(() { registerFallbackValue(_FakeTemplateInput()); registerFallbackValue(_FakeStepInput()); @@ -47,7 +83,13 @@ void main() { setUp(() { repository = _MockApprovalTemplateRepository(); - controller = ApprovalTemplateController(repository: repository); + saveUseCase = _MockSaveApprovalTemplateUseCase(); + applyUseCase = _MockApplyApprovalTemplateUseCase(); + controller = ApprovalTemplateController( + repository: repository, + saveTemplateUseCase: saveUseCase, + applyTemplateUseCase: applyUseCase, + ); }); group('fetch', () { @@ -62,11 +104,12 @@ void main() { ).thenAnswer((_) async => createResult([sampleTemplate])); }); - test('목록을 조회한다', () async { + test('목록을 조회하고 버전을 캐시한다', () async { await controller.fetch(); expect(controller.result?.items, isNotEmpty); expect(controller.errorMessage, isNull); + expect(controller.versionOf(sampleTemplate.id), sampleTemplate.updatedAt); }); test('필터를 전달한다', () async { @@ -101,11 +144,14 @@ void main() { }); }); - test('create 성공 시 목록 갱신', () async { + test('create 호출 시 SaveUseCase를 사용하고 버전을 기록한다', () async { when( - () => repository.create(any(), steps: any(named: 'steps')), + () => saveUseCase.call( + templateId: any(named: 'templateId'), + input: any(named: 'input'), + steps: any(named: 'steps'), + ), ).thenAnswer((_) async => sampleTemplate); - when( () => repository.list( page: any(named: 'page'), @@ -121,16 +167,24 @@ void main() { ); expect(created, isNotNull); + expect(controller.versionOf(sampleTemplate.id), sampleTemplate.updatedAt); verify( - () => repository.create(any(), steps: any(named: 'steps')), + () => saveUseCase.call( + templateId: any(named: 'templateId'), + input: any(named: 'input'), + steps: any(named: 'steps'), + ), ).called(1); }); - test('update 성공 시 현재 페이지 갱신', () async { + test('save는 update 경로에서도 유즈케이스를 사용한다', () async { when( - () => repository.update(any(), any(), steps: any(named: 'steps')), + () => saveUseCase.call( + templateId: any(named: 'templateId'), + input: any(named: 'input'), + steps: any(named: 'steps'), + ), ).thenAnswer((_) async => sampleTemplate); - when( () => repository.list( page: any(named: 'page'), @@ -140,13 +194,44 @@ void main() { ), ).thenAnswer((_) async => createResult([sampleTemplate])); - controller.updateQuery('AP'); - await controller.update(1, ApprovalTemplateInput(name: '입고 결재 수정'), [ - ApprovalTemplateStepInput(stepOrder: 1, approverId: 33), - ]); + await controller.save( + templateId: 1, + input: ApprovalTemplateInput(name: '수정 템플릿'), + steps: const [], + ); verify( - () => repository.update(any(), any(), steps: any(named: 'steps')), + () => saveUseCase.call( + templateId: 1, + input: any(named: 'input'), + steps: any(named: 'steps'), + ), + ).called(1); + }); + + test('유즈케이스 미주입 시 저장소를 직접 호출한다', () async { + final fallbackController = ApprovalTemplateController( + repository: repository, + ); + when( + () => repository.create(any(), steps: any(named: 'steps')), + ).thenAnswer((_) async => sampleTemplate); + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + isActive: any(named: 'isActive'), + ), + ).thenAnswer((_) async => createResult([sampleTemplate])); + + await fallbackController.create( + ApprovalTemplateInput(code: 'AP', name: '템플릿'), + const [], + ); + + verify( + () => repository.create(any(), steps: any(named: 'steps')), ).called(1); }); @@ -167,7 +252,7 @@ void main() { verify(() => repository.delete(1)).called(1); }); - test('restore 성공 시 템플릿을 반환한다', () async { + test('restore 성공 시 템플릿을 반환하고 버전을 갱신한다', () async { when( () => repository.restore(any()), ).thenAnswer((_) async => sampleTemplate); @@ -183,6 +268,58 @@ void main() { final restored = await controller.restore(1); expect(restored, isNotNull); - verify(() => repository.restore(1)).called(1); + expect(controller.versionOf(sampleTemplate.id), sampleTemplate.updatedAt); + }); + + test('applyToApproval은 유즈케이스를 호출하고 버전을 갱신한다', () async { + when( + () => applyUseCase.call( + approvalId: any(named: 'approvalId'), + templateId: any(named: 'templateId'), + ), + ).thenAnswer((_) async => createFlow()); + when( + () => repository.fetchDetail( + any(), + includeSteps: any(named: 'includeSteps'), + ), + ).thenAnswer((_) async => sampleTemplate); + + final flow = await controller.applyToApproval( + approvalId: 10, + templateId: sampleTemplate.id, + ); + + expect(flow, isNotNull); + expect(controller.isApplyingTemplate, isFalse); + expect(controller.versionOf(sampleTemplate.id), sampleTemplate.updatedAt); + verify( + () => applyUseCase.call(approvalId: 10, templateId: sampleTemplate.id), + ).called(1); + }); + + test('isTemplateStale은 최신 버전을 판단한다', () async { + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + isActive: any(named: 'isActive'), + ), + ).thenAnswer((_) async => createResult([sampleTemplate])); + + await controller.fetch(); + + expect( + controller.isTemplateStale(sampleTemplate.id, sampleTemplate.updatedAt), + isFalse, + ); + expect( + controller.isTemplateStale( + sampleTemplate.id, + sampleTemplate.updatedAt!.add(const Duration(minutes: 10)), + ), + isTrue, + ); }); } diff --git a/test/features/approvals/template/presentation/pages/approval_template_page_test.dart b/test/features/approvals/template/presentation/pages/approval_template_page_test.dart index 315baa6..d52096a 100644 --- a/test/features/approvals/template/presentation/pages/approval_template_page_test.dart +++ b/test/features/approvals/template/presentation/pages/approval_template_page_test.dart @@ -7,12 +7,17 @@ import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; import 'package:superport_v2/features/approvals/domain/entities/approval_template.dart'; +import 'package:superport_v2/features/approvals/domain/repositories/approval_repository.dart'; import 'package:superport_v2/features/approvals/domain/repositories/approval_template_repository.dart'; +import 'package:superport_v2/features/approvals/domain/usecases/apply_approval_template_use_case.dart'; +import 'package:superport_v2/features/approvals/domain/usecases/save_approval_template_use_case.dart'; import 'package:superport_v2/features/approvals/template/presentation/pages/approval_template_page.dart'; class _MockApprovalTemplateRepository extends Mock implements ApprovalTemplateRepository {} +class _MockApprovalRepository extends Mock implements ApprovalRepository {} + class _FakeTemplateInput extends Fake implements ApprovalTemplateInput {} class _FakeTemplateStepInput extends Fake @@ -56,13 +61,27 @@ void main() { group('플래그 On', () { late _MockApprovalTemplateRepository repository; + late _MockApprovalRepository approvalRepository; setUp(() { dotenv.testLoad(fileInput: 'FEATURE_APPROVALS_ENABLED=true\n'); repository = _MockApprovalTemplateRepository(); + approvalRepository = _MockApprovalRepository(); GetIt.I.registerLazySingleton( () => repository, ); + GetIt.I.registerLazySingleton( + () => approvalRepository, + ); + GetIt.I.registerLazySingleton( + () => SaveApprovalTemplateUseCase(repository: repository), + ); + GetIt.I.registerLazySingleton( + () => ApplyApprovalTemplateUseCase( + templateRepository: repository, + approvalRepository: approvalRepository, + ), + ); }); ApprovalTemplate buildTemplate({bool isActive = true}) { @@ -114,6 +133,7 @@ void main() { expect(find.text('AP_INBOUND'), findsOneWidget); expect(find.text('입고 템플릿'), findsOneWidget); + expect(find.textContaining('1. 최승인'), findsOneWidget); verify( () => @@ -188,6 +208,54 @@ void main() { expect(find.text('템플릿 "신규 템플릿"을 생성했습니다.'), findsOneWidget); }); + testWidgets('보기 버튼을 눌러 템플릿 단계를 미리본다', (tester) async { + final template = buildTemplate(); + + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + isActive: any(named: 'isActive'), + ), + ).thenAnswer( + (_) async => PaginatedResult( + items: [template], + page: 1, + pageSize: 20, + total: 1, + ), + ); + + when( + () => repository.fetchDetail(template.id, includeSteps: true), + ).thenAnswer((_) async => template); + + await tester.pumpWidget(_buildApp(const ApprovalTemplatePage())); + await tester.pump(); + await tester.pumpAndSettle(); + + final previewFinder = find.text('보기', skipOffstage: false); + + await tester.dragUntilVisible( + previewFinder, + find.text(template.name), + const Offset(-200, 0), + ); + await tester.pumpAndSettle(); + + await tester.tap(previewFinder); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(find.text(template.name), findsWidgets); + expect(find.textContaining('사번 E001'), findsOneWidget); + + verify( + () => repository.fetchDetail(template.id, includeSteps: true), + ).called(1); + }); + testWidgets('수정 플로우에서 fetchDetail 후 update를 호출한다', (tester) async { final activeTemplate = buildTemplate(); diff --git a/test/features/inventory/inbound/presentation/controllers/inbound_controller_test.dart b/test/features/inventory/inbound/presentation/controllers/inbound_controller_test.dart index 5c2d3b1..8c1ac8a 100644 --- a/test/features/inventory/inbound/presentation/controllers/inbound_controller_test.dart +++ b/test/features/inventory/inbound/presentation/controllers/inbound_controller_test.dart @@ -4,6 +4,11 @@ import 'package:mocktail/mocktail.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; import 'package:superport_v2/core/network/api_error.dart'; import 'package:superport_v2/core/network/failure.dart'; +import 'package:superport_v2/features/approvals/domain/entities/approval.dart'; +import 'package:superport_v2/features/approvals/domain/entities/approval_draft.dart'; +import 'package:superport_v2/features/approvals/domain/usecases/get_approval_draft_use_case.dart'; +import 'package:superport_v2/features/approvals/domain/usecases/list_approval_drafts_use_case.dart'; +import 'package:superport_v2/features/approvals/domain/usecases/save_approval_draft_use_case.dart'; import 'package:superport_v2/features/inventory/inbound/presentation/controllers/inbound_controller.dart'; import 'package:superport_v2/features/inventory/transactions/domain/entities/stock_transaction.dart'; import 'package:superport_v2/features/inventory/transactions/domain/entities/stock_transaction_input.dart'; @@ -23,20 +28,14 @@ class _MockTransactionLineRepository extends Mock class _MockTransactionCustomerRepository extends Mock implements TransactionCustomerRepository {} -class _FakeStockTransactionCreateInput extends Fake - implements StockTransactionCreateInput {} +class _MockSaveApprovalDraftUseCase extends Mock + implements SaveApprovalDraftUseCase {} -class _FakeStockTransactionUpdateInput extends Fake - implements StockTransactionUpdateInput {} +class _MockGetApprovalDraftUseCase extends Mock + implements GetApprovalDraftUseCase {} -class _FakeStockTransactionListFilter extends Fake - implements StockTransactionListFilter {} - -class _FakeTransactionCustomerCreateInput extends Fake - implements TransactionCustomerCreateInput {} - -class _FakeTransactionCustomerUpdateInput extends Fake - implements TransactionCustomerUpdateInput {} +class _MockListApprovalDraftsUseCase extends Mock + implements ListApprovalDraftsUseCase {} void main() { group('InboundController', () { @@ -47,13 +46,31 @@ void main() { late InboundController controller; setUpAll(() { - registerFallbackValue(_FakeStockTransactionCreateInput()); - registerFallbackValue(_FakeStockTransactionUpdateInput()); - registerFallbackValue(_FakeStockTransactionListFilter()); - registerFallbackValue(_FakeTransactionCustomerCreateInput()); - registerFallbackValue(_FakeTransactionCustomerUpdateInput()); + registerFallbackValue( + StockTransactionCreateInput( + transactionTypeId: 0, + transactionStatusId: 0, + warehouseId: 0, + transactionDate: DateTime(2000, 1, 1), + createdById: 0, + approval: StockTransactionApprovalInput(requestedById: 0), + ), + ); + registerFallbackValue( + StockTransactionUpdateInput(transactionStatusId: 0), + ); + registerFallbackValue(StockTransactionListFilter()); + registerFallbackValue(TransactionCustomerCreateInput(customerId: 0)); + registerFallbackValue(TransactionCustomerUpdateInput(id: 0)); registerFallbackValue([]); registerFallbackValue([]); + registerFallbackValue(const ApprovalDraftListFilter(requesterId: 0)); + registerFallbackValue( + ApprovalDraftSaveInput( + requesterId: 0, + steps: [ApprovalDraftStep(stepOrder: 0, approverId: 0)], + ), + ); }); setUp(() { @@ -81,6 +98,7 @@ void main() { warehouseId: 3, transactionDate: DateTime(2024, 3, 1), createdById: 9, + approval: StockTransactionApprovalInput(requestedById: 9), ); final record = await controller.createTransaction( @@ -91,6 +109,7 @@ void main() { expect(record.id, equals(transaction.id)); expect(controller.records.length, equals(1)); expect(controller.records.first.id, equals(transaction.id)); + expect(controller.approvalDraft?.requestedById, equals(9)); verify(() => transactionRepository.create(any())).called(1); }); @@ -111,6 +130,7 @@ void main() { warehouseId: 3, transactionDate: DateTime(2024, 3, 1), createdById: 9, + approval: StockTransactionApprovalInput(requestedById: 9), ), refreshAfter: false, ); @@ -149,6 +169,7 @@ void main() { warehouseId: 3, transactionDate: DateTime(2024, 3, 1), createdById: 9, + approval: StockTransactionApprovalInput(requestedById: 9), ), refreshAfter: false, ); @@ -197,6 +218,107 @@ void main() { verify(() => customerRepository.addCustomers(42, any())).called(1); }); + test('updateApprovalDraft와 clearApprovalDraft가 초안을 관리한다', () { + final approval = StockTransactionApprovalInput( + requestedById: 55, + approvalStatusId: 7, + note: '메모', + ); + + controller.updateApprovalDraft(approval); + + expect(controller.approvalDraft?.requestedById, equals(55)); + expect(controller.approvalDraft?.approvalStatusId, equals(7)); + + controller.clearApprovalDraft(); + + expect(controller.approvalDraft, isNull); + }); + + test('updateApprovalDraft는 서버 초안을 동기화한다', () async { + final saveUseCase = _MockSaveApprovalDraftUseCase(); + when(() => saveUseCase.call(any())).thenAnswer( + (_) async => ApprovalDraftDetail( + id: 1, + requesterId: 3, + savedAt: DateTime.utc(2025, 1, 1), + payload: ApprovalDraftPayload(steps: const []), + ), + ); + final controllerWithSave = InboundController( + transactionRepository: transactionRepository, + lineRepository: lineRepository, + customerRepository: customerRepository, + lookupRepository: lookupRepository, + saveDraftUseCase: saveUseCase, + ); + + final approval = StockTransactionApprovalInput( + requestedById: 3, + approvalStatusId: 2, + steps: [ApprovalStepAssignmentItem(stepOrder: 1, approverId: 4)], + ); + + controllerWithSave.updateApprovalDraft(approval); + await Future.delayed(Duration.zero); + + verify(() => saveUseCase.call(captureAny())).called(1); + }); + + test('loadApprovalDraftFromServer는 원격 초안을 반영한다', () async { + final listUseCase = _MockListApprovalDraftsUseCase(); + final getUseCase = _MockGetApprovalDraftUseCase(); + final summary = ApprovalDraftSummary( + id: 5, + requesterId: 3, + status: ApprovalDraftStatus.active, + savedAt: DateTime.utc(2025, 1, 2), + sessionKey: 'inventory_inbound_3', + stepCount: 1, + ); + when(() => listUseCase.call(any())).thenAnswer( + (_) async => PaginatedResult( + items: [summary], + page: 1, + pageSize: 10, + total: 1, + ), + ); + final detail = ApprovalDraftDetail( + id: summary.id, + requesterId: 3, + savedAt: DateTime.utc(2025, 1, 2), + payload: ApprovalDraftPayload( + metadata: const { + '_client_state': {'status_id': 6}, + }, + steps: [ApprovalDraftStep(stepOrder: 1, approverId: 9)], + ), + ); + when( + () => getUseCase.call(id: summary.id, requesterId: 3), + ).thenAnswer((_) async => detail); + + final controllerWithDrafts = InboundController( + transactionRepository: transactionRepository, + lineRepository: lineRepository, + customerRepository: customerRepository, + lookupRepository: lookupRepository, + getDraftUseCase: getUseCase, + listDraftsUseCase: listUseCase, + ); + + await controllerWithDrafts.loadApprovalDraftFromServer(requesterId: 3); + + verify(() => listUseCase.call(any())).called(1); + verify(() => getUseCase.call(id: summary.id, requesterId: 3)).called(1); + final approval = controllerWithDrafts.approvalDraft; + expect(approval, isNotNull); + expect(approval!.requestedById, 3); + expect(approval.approvalStatusId, 6); + expect(approval.steps, hasLength(1)); + }); + test('submitTransaction은 refreshAfter가 true일 때 목록을 다시 불러온다', () async { final filter = StockTransactionListFilter(transactionTypeId: 1); final initial = _buildTransaction(); diff --git a/test/features/inventory/inbound_page_test.dart b/test/features/inventory/inbound_page_test.dart index a86c9f5..93d04b7 100644 --- a/test/features/inventory/inbound_page_test.dart +++ b/test/features/inventory/inbound_page_test.dart @@ -21,7 +21,9 @@ void main() { }); setUp(() { - registerInventoryTestStubs(); + registerInventoryTestStubs( + const InventoryTestStubConfig(registerProductRepository: true), + ); }); tearDown(() async { @@ -137,7 +139,9 @@ void main() { matching: find.byType(EditableText), ); await tester.enterText(firstProductInput, 'XR-5000'); - await tester.pump(); + await tester.pumpAndSettle(); + await tester.tap(find.text('XR-5000').last); + await tester.pumpAndSettle(); final addLineButton = find.widgetWithText(ShadButton, '품목 추가'); await tester.ensureVisible(addLineButton); @@ -152,12 +156,14 @@ void main() { matching: find.byType(EditableText), ); await tester.enterText(secondProductInput, 'XR-5000'); - await tester.pump(); + await tester.pumpAndSettle(); + await tester.tap(find.text('XR-5000').last); + await tester.pumpAndSettle(); final saveButton = find.widgetWithText(ShadButton, '저장'); await tester.ensureVisible(saveButton); await tester.tap(saveButton); - await tester.pump(); + await tester.pumpAndSettle(); expect(find.text('동일 제품이 중복되었습니다.'), findsOneWidget); }); diff --git a/test/features/inventory/outbound/presentation/controllers/outbound_controller_test.dart b/test/features/inventory/outbound/presentation/controllers/outbound_controller_test.dart index 927dd06..f3fa393 100644 --- a/test/features/inventory/outbound/presentation/controllers/outbound_controller_test.dart +++ b/test/features/inventory/outbound/presentation/controllers/outbound_controller_test.dart @@ -21,15 +21,6 @@ class _MockTransactionLineRepository extends Mock class _MockTransactionCustomerRepository extends Mock implements TransactionCustomerRepository {} -class _FakeStockTransactionCreateInput extends Fake - implements StockTransactionCreateInput {} - -class _FakeStockTransactionUpdateInput extends Fake - implements StockTransactionUpdateInput {} - -class _FakeStockTransactionListFilter extends Fake - implements StockTransactionListFilter {} - void main() { group('OutboundController', () { late StockTransactionRepository transactionRepository; @@ -39,9 +30,20 @@ void main() { late OutboundController controller; setUpAll(() { - registerFallbackValue(_FakeStockTransactionCreateInput()); - registerFallbackValue(_FakeStockTransactionUpdateInput()); - registerFallbackValue(_FakeStockTransactionListFilter()); + registerFallbackValue( + StockTransactionCreateInput( + transactionTypeId: 0, + transactionStatusId: 0, + warehouseId: 0, + transactionDate: DateTime(2000, 1, 1), + createdById: 0, + approval: StockTransactionApprovalInput(requestedById: 0), + ), + ); + registerFallbackValue( + StockTransactionUpdateInput(transactionStatusId: 0), + ); + registerFallbackValue(StockTransactionListFilter()); }); setUp(() { @@ -70,12 +72,31 @@ void main() { warehouseId: 3, transactionDate: DateTime(2024, 4, 1), createdById: 7, + approval: StockTransactionApprovalInput(requestedById: 7), ), refreshAfter: false, ); expect(record.id, equals(transaction.id)); expect(controller.records.first.id, equals(transaction.id)); + expect(controller.approvalDraft?.requestedById, equals(7)); + }); + + test('updateApprovalDraft와 clearApprovalDraft가 초안을 관리한다', () { + final draft = StockTransactionApprovalInput( + requestedById: 33, + approvalStatusId: 4, + note: '테스트', + ); + + controller.updateApprovalDraft(draft); + + expect(controller.approvalDraft?.requestedById, equals(33)); + expect(controller.approvalDraft?.approvalStatusId, equals(4)); + + controller.clearApprovalDraft(); + + expect(controller.approvalDraft, isNull); }); test('completeTransaction은 레코드를 갱신하고 처리 상태를 추적한다', () async { @@ -95,6 +116,7 @@ void main() { warehouseId: 3, transactionDate: DateTime(2024, 4, 1), createdById: 7, + approval: StockTransactionApprovalInput(requestedById: 7), ), refreshAfter: false, ); diff --git a/test/features/inventory/outbound_page_test.dart b/test/features/inventory/outbound_page_test.dart index 0207101..9aa4862 100644 --- a/test/features/inventory/outbound_page_test.dart +++ b/test/features/inventory/outbound_page_test.dart @@ -18,7 +18,9 @@ void main() { }); setUp(() { - registerInventoryTestStubs(); + registerInventoryTestStubs( + const InventoryTestStubConfig(registerProductRepository: true), + ); }); tearDown(() async { diff --git a/test/features/inventory/rental/presentation/controllers/rental_controller_test.dart b/test/features/inventory/rental/presentation/controllers/rental_controller_test.dart index 22f9572..d59d1be 100644 --- a/test/features/inventory/rental/presentation/controllers/rental_controller_test.dart +++ b/test/features/inventory/rental/presentation/controllers/rental_controller_test.dart @@ -22,15 +22,6 @@ class _MockTransactionLineRepository extends Mock class _MockTransactionCustomerRepository extends Mock implements TransactionCustomerRepository {} -class _FakeStockTransactionCreateInput extends Fake - implements StockTransactionCreateInput {} - -class _FakeStockTransactionUpdateInput extends Fake - implements StockTransactionUpdateInput {} - -class _FakeStockTransactionListFilter extends Fake - implements StockTransactionListFilter {} - void main() { group('RentalController', () { late StockTransactionRepository transactionRepository; @@ -40,9 +31,20 @@ void main() { late RentalController controller; setUpAll(() { - registerFallbackValue(_FakeStockTransactionCreateInput()); - registerFallbackValue(_FakeStockTransactionUpdateInput()); - registerFallbackValue(_FakeStockTransactionListFilter()); + registerFallbackValue( + StockTransactionCreateInput( + transactionTypeId: 0, + transactionStatusId: 0, + warehouseId: 0, + transactionDate: DateTime(2000, 1, 1), + createdById: 0, + approval: StockTransactionApprovalInput(requestedById: 0), + ), + ); + registerFallbackValue( + StockTransactionUpdateInput(transactionStatusId: 0), + ); + registerFallbackValue(StockTransactionListFilter()); }); setUp(() { @@ -71,12 +73,31 @@ void main() { warehouseId: 4, transactionDate: DateTime(2024, 5, 1), createdById: 5, + approval: StockTransactionApprovalInput(requestedById: 5), ), refreshAfter: false, ); expect(record.id, equals(transaction.id)); expect(controller.records.first.id, equals(transaction.id)); + expect(controller.approvalDraft?.requestedById, equals(5)); + }); + + test('updateApprovalDraft와 clearApprovalDraft가 초안을 관리한다', () { + final approval = StockTransactionApprovalInput( + requestedById: 44, + approvalStatusId: 9, + note: '대여 승인', + ); + + controller.updateApprovalDraft(approval); + + expect(controller.approvalDraft?.requestedById, equals(44)); + expect(controller.approvalDraft?.approvalStatusId, equals(9)); + + controller.clearApprovalDraft(); + + expect(controller.approvalDraft, isNull); }); test('deleteTransaction은 레코드를 제거하고 처리 상태를 초기화한다', () async { @@ -95,6 +116,7 @@ void main() { warehouseId: 4, transactionDate: DateTime(2024, 5, 1), createdById: 5, + approval: StockTransactionApprovalInput(requestedById: 5), ), refreshAfter: false, ); diff --git a/test/features/inventory/rental_page_test.dart b/test/features/inventory/rental_page_test.dart index 6e701f5..b29ebed 100644 --- a/test/features/inventory/rental_page_test.dart +++ b/test/features/inventory/rental_page_test.dart @@ -18,7 +18,9 @@ void main() { }); setUp(() { - registerInventoryTestStubs(); + registerInventoryTestStubs( + const InventoryTestStubConfig(registerProductRepository: true), + ); }); tearDown(() async { diff --git a/test/features/inventory/transactions/data/stock_transaction_dto_test.dart b/test/features/inventory/transactions/data/stock_transaction_dto_test.dart new file mode 100644 index 0000000..dafe8e7 --- /dev/null +++ b/test/features/inventory/transactions/data/stock_transaction_dto_test.dart @@ -0,0 +1,125 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:superport_v2/features/inventory/transactions/data/dtos/stock_transaction_dto.dart'; + +void main() { + group('StockTransactionDto', () { + test('결재 상세 정보를 포함해 파싱한다', () { + final dto = StockTransactionDto.fromJson({ + 'id': 9001, + 'transaction_no': 'TRX-202511100001', + 'transaction_date': '2025-09-18', + 'transaction_type': {'id': 1, 'name': '입고'}, + 'transaction_status': {'id': 1, 'name': '초안'}, + 'warehouse': { + 'id': 1, + 'warehouse_code': 'WH-001', + 'warehouse_name': '1센터', + }, + 'created_by': { + 'id': 7, + 'employee_no': 'E20250001', + 'employee_name': '김상신', + }, + 'lines': [ + { + 'id': 12001, + 'line_no': 1, + 'product': { + 'id': 101, + 'product_code': 'P100', + 'product_name': '샘플', + }, + 'quantity': 10, + 'unit_price': 1200, + }, + ], + 'customers': [], + 'approval': { + 'id': 5001, + 'approval_no': 'APP-202511100001', + 'status': { + 'id': 1, + 'name': '대기', + 'is_blocking_next': true, + 'is_terminal': false, + }, + 'current_step': { + 'id': 7001, + 'step_order': 1, + 'status': { + 'id': 2, + 'name': '진행중', + 'is_blocking_next': true, + 'is_terminal': false, + }, + 'approver': {'id': 21, 'employee_no': 'E2025002', 'name': '박검토'}, + 'assigned_at': '2025-09-18T06:05:00Z', + }, + 'requester': {'id': 7, 'employee_no': 'E20250001', 'name': '김상신'}, + 'requested_at': '2025-09-18T06:00:00Z', + 'note': '입고 결재', + 'template_name': '입고 결재 기본', + 'steps': [ + { + 'id': 7201, + 'step_order': 1, + 'status': { + 'id': 3, + 'name': '승인', + 'is_blocking_next': false, + 'is_terminal': false, + }, + 'approver': {'id': 21, 'employee_no': 'E2025002', 'name': '박검토'}, + 'assigned_at': '2025-09-18T06:05:00Z', + 'decided_at': '2025-09-18T06:10:00Z', + }, + { + 'id': 7202, + 'step_order': 2, + 'status': { + 'id': 1, + 'name': '대기', + 'is_blocking_next': true, + 'is_terminal': false, + }, + 'approver': {'id': 22, 'employee_no': 'E2025003', 'name': '이승인'}, + 'assigned_at': '2025-09-18T06:10:00Z', + }, + ], + 'histories': [ + { + 'id': 93001, + 'action': {'id': 1, 'name': '상신'}, + 'to_status': { + 'id': 1, + 'name': '대기', + 'is_blocking_next': true, + 'is_terminal': false, + }, + 'approver': {'id': 7, 'employee_no': 'E20250001', 'name': '김상신'}, + 'action_at': '2025-09-18T06:00:00Z', + }, + ], + 'created_at': '2025-09-18T06:00:00Z', + 'updated_at': '2025-09-18T06:05:00Z', + }, + }); + + final entity = dto.toEntity(); + + expect(entity.approval, isNotNull); + final approval = entity.approval!; + expect(approval.approvalNo, 'APP-202511100001'); + expect(approval.status.name, '대기'); + expect(approval.currentStep?.stepOrder, 1); + expect(approval.currentStep?.approver.name, '박검토'); + expect(approval.steps.length, 2); + expect(approval.histories.length, 1); + expect(approval.requester.name, '김상신'); + expect( + approval.requestedAt.toUtc().toIso8601String(), + '2025-09-18T06:00:00.000Z', + ); + }); + }); +} diff --git a/test/features/login/presentation/pages/login_page_test.dart b/test/features/login/presentation/pages/login_page_test.dart index 396c337..b4925ee 100644 --- a/test/features/login/presentation/pages/login_page_test.dart +++ b/test/features/login/presentation/pages/login_page_test.dart @@ -86,7 +86,9 @@ void main() { GetIt.I.registerSingleton(authService); - when(() => authRepository.login(any())).thenAnswer((_) async => sampleSession); + when( + () => authRepository.login(any()), + ).thenAnswer((_) async => sampleSession); when(() => authRepository.refresh(any())).thenThrow(UnimplementedError()); }); diff --git a/test/features/masters/customer/presentation/pages/customer_page_test.dart b/test/features/masters/customer/presentation/pages/customer_page_test.dart index 80cc31d..a5f8328 100644 --- a/test/features/masters/customer/presentation/pages/customer_page_test.dart +++ b/test/features/masters/customer/presentation/pages/customer_page_test.dart @@ -296,7 +296,7 @@ void main() { _buildApp( Center( child: SizedBox( - width: 260, + width: 320, child: CustomerPage(routeUri: Uri(path: '/masters/customers')), ), ), diff --git a/test/features/masters/product/presentation/pages/product_page_test.dart b/test/features/masters/product/presentation/pages/product_page_test.dart index 65339fc..76026cb 100644 --- a/test/features/masters/product/presentation/pages/product_page_test.dart +++ b/test/features/masters/product/presentation/pages/product_page_test.dart @@ -348,7 +348,7 @@ void main() { _buildApp( Center( child: SizedBox( - width: 260, + width: 320, child: ProductPage(routeUri: Uri(path: '/masters/products')), ), ), diff --git a/test/features/masters/product/presentation/widgets/vendor_autocomplete_field_test.dart b/test/features/masters/product/presentation/widgets/vendor_autocomplete_field_test.dart index 9f5119a..2a003a0 100644 --- a/test/features/masters/product/presentation/widgets/vendor_autocomplete_field_test.dart +++ b/test/features/masters/product/presentation/widgets/vendor_autocomplete_field_test.dart @@ -42,7 +42,9 @@ void main() { expect(selectedId, equals(1)); - final editableText = tester.widget(find.byType(EditableText)); + final editableText = tester.widget( + find.byType(EditableText), + ); expect(editableText.controller.text, '테스트 제조사 (V001)'); }); }); diff --git a/test/features/masters/vendor/presentation/pages/vendor_page_test.dart b/test/features/masters/vendor/presentation/pages/vendor_page_test.dart index bf0908d..a453fe2 100644 --- a/test/features/masters/vendor/presentation/pages/vendor_page_test.dart +++ b/test/features/masters/vendor/presentation/pages/vendor_page_test.dart @@ -242,7 +242,7 @@ void main() { _buildApp( Center( child: SizedBox( - width: 260, + width: 320, child: VendorPage(routeUri: Uri(path: '/masters/vendors')), ), ), diff --git a/test/features/masters/warehouse/presentation/pages/warehouse_page_test.dart b/test/features/masters/warehouse/presentation/pages/warehouse_page_test.dart index f1b81f8..a624569 100644 --- a/test/features/masters/warehouse/presentation/pages/warehouse_page_test.dart +++ b/test/features/masters/warehouse/presentation/pages/warehouse_page_test.dart @@ -277,7 +277,7 @@ void main() { _buildApp( Center( child: SizedBox( - width: 260, + width: 320, child: WarehousePage(routeUri: Uri(path: '/masters/warehouses')), ), ), diff --git a/test/features/reporting/reporting_page_test.dart b/test/features/reporting/reporting_page_test.dart index 9f42704..6e16bdc 100644 --- a/test/features/reporting/reporting_page_test.dart +++ b/test/features/reporting/reporting_page_test.dart @@ -48,10 +48,7 @@ void main() { await tester.pumpAndSettle(); expect(repo.attempts, 1); - expect( - find.text('창고 목록을 불러오지 못했습니다. 잠시 후 다시 시도하세요.'), - findsWidgets, - ); + expect(find.text('창고 목록을 불러오지 못했습니다. 잠시 후 다시 시도하세요.'), findsWidgets); await tester.tap(find.widgetWithText(ShadButton, '재시도')); await tester.pumpAndSettle(); diff --git a/test/fixtures/approvals/approval_five_step_pending.json b/test/fixtures/approvals/approval_five_step_pending.json new file mode 100644 index 0000000..1366d89 --- /dev/null +++ b/test/fixtures/approvals/approval_five_step_pending.json @@ -0,0 +1,237 @@ +{ + "id": 51001, + "approval_no": "APP-202511100201", + "transaction": { + "id": 91001, + "transaction_no": "IN-20251110-0001" + }, + "status": { + "id": 2, + "name": "진행중", + "color": "#3B82F6", + "is_blocking_next": true, + "is_terminal": false + }, + "current_step": { + "id": 72003, + "step_order": 3, + "status": { + "id": 2, + "name": "진행중", + "is_blocking_next": true, + "is_terminal": false + }, + "approver": { + "id": 104, + "employee_id": "E20250104", + "employee_no": "E20250104", + "name": "박팀장" + }, + "assigned_at": "2025-09-18T07:10:00Z", + "decided_at": null, + "note": null + }, + "requester": { + "id": 77, + "employee_id": "E20250077", + "employee_no": "E20250077", + "name": "김상신" + }, + "requested_at": "2025-09-18T06:55:00Z", + "decided_at": null, + "note": "입고 5단계 결재", + "is_active": true, + "is_deleted": false, + "steps": [ + { + "id": 72001, + "step_order": 1, + "status": { + "id": 3, + "name": "승인", + "is_blocking_next": false, + "is_terminal": false + }, + "approver": { + "id": 101, + "employee_id": "E20250101", + "employee_no": "E20250101", + "name": "이검토" + }, + "assigned_at": "2025-09-18T06:58:00Z", + "decided_at": "2025-09-18T06:59:30Z", + "note": "조건부 승인" + }, + { + "id": 72002, + "step_order": 2, + "status": { + "id": 3, + "name": "승인", + "is_blocking_next": false, + "is_terminal": false + }, + "approver": { + "id": 102, + "employee_id": "E20250102", + "employee_no": "E20250102", + "name": "최검수" + }, + "assigned_at": "2025-09-18T07:00:00Z", + "decided_at": "2025-09-18T07:04:10Z", + "note": null + }, + { + "id": 72003, + "step_order": 3, + "status": { + "id": 2, + "name": "진행중", + "is_blocking_next": true, + "is_terminal": false + }, + "approver": { + "id": 104, + "employee_id": "E20250104", + "employee_no": "E20250104", + "name": "박팀장" + }, + "assigned_at": "2025-09-18T07:10:00Z", + "decided_at": null, + "note": null + }, + { + "id": 72004, + "step_order": 4, + "status": { + "id": 1, + "name": "대기", + "is_blocking_next": true, + "is_terminal": false + }, + "approver": { + "id": 105, + "employee_id": "E20250105", + "employee_no": "E20250105", + "name": "정차장" + }, + "assigned_at": "2025-09-18T07:10:00Z", + "decided_at": null, + "note": null + }, + { + "id": 72005, + "step_order": 5, + "status": { + "id": 1, + "name": "대기", + "is_blocking_next": true, + "is_terminal": false + }, + "approver": { + "id": 201, + "employee_id": "E20250201", + "employee_no": "E20250201", + "name": "한임원" + }, + "assigned_at": "2025-09-18T07:10:00Z", + "decided_at": null, + "note": null + } + ], + "histories": [ + { + "id": 93001, + "action": { + "id": 1, + "name": "상신" + }, + "from_status": null, + "to_status": { + "id": 1, + "name": "대기", + "is_blocking_next": true, + "is_terminal": false + }, + "approver": { + "id": 77, + "employee_id": "E20250077", + "employee_no": "E20250077", + "name": "김상신" + }, + "action_at": "2025-09-18T06:55:00Z", + "note": null + }, + { + "id": 93002, + "action": { + "id": 2, + "name": "승인" + }, + "from_status": { + "id": 1, + "name": "대기", + "is_blocking_next": true, + "is_terminal": false + }, + "to_status": { + "id": 3, + "name": "승인", + "is_blocking_next": false, + "is_terminal": false + }, + "approver": { + "id": 101, + "employee_id": "E20250101", + "employee_no": "E20250101", + "name": "이검토" + }, + "action_at": "2025-09-18T06:59:30Z", + "note": "조건 수용" + }, + { + "id": 93003, + "action": { + "id": 2, + "name": "승인" + }, + "from_status": { + "id": 1, + "name": "대기", + "is_blocking_next": true, + "is_terminal": false + }, + "to_status": { + "id": 3, + "name": "승인", + "is_blocking_next": false, + "is_terminal": false + }, + "approver": { + "id": 102, + "employee_id": "E20250102", + "employee_no": "E20250102", + "name": "최검수" + }, + "action_at": "2025-09-18T07:04:10Z", + "note": null + } + ], + "created_at": "2025-09-18T06:55:00Z", + "updated_at": "2025-09-18T07:10:00Z", + "visibility": { + "allowed_roles": [ + "requester", + "completed_approver" + ], + "restricted_roles": [ + "pending_approver", + "external" + ] + }, + "permissions": { + "can_recall": true, + "can_resubmit": false, + "can_comment": true + } +} diff --git a/test/fixtures/approvals/approval_permissions.json b/test/fixtures/approvals/approval_permissions.json new file mode 100644 index 0000000..678adcf --- /dev/null +++ b/test/fixtures/approvals/approval_permissions.json @@ -0,0 +1,13 @@ +{ + "viewer": [ + "view" + ], + "approver": [ + "view", + "approve" + ], + "auditor": [ + "view", + "restore" + ] +} diff --git a/test/fixtures/approvals/approval_recalled.json b/test/fixtures/approvals/approval_recalled.json new file mode 100644 index 0000000..5857507 --- /dev/null +++ b/test/fixtures/approvals/approval_recalled.json @@ -0,0 +1,134 @@ +{ + "id": 52011, + "approval_no": "APP-202511120045", + "transaction": { + "id": 93010, + "transaction_no": "OUT-20251112-0003" + }, + "status": { + "id": 6, + "name": "회수", + "color": "#8B5CF6", + "is_blocking_next": true, + "is_terminal": false + }, + "current_step": null, + "requester": { + "id": 88, + "employee_id": "E20250088", + "employee_no": "E20250088", + "name": "오요청" + }, + "requested_at": "2025-09-20T01:10:00Z", + "decided_at": "2025-09-20T01:22:30Z", + "note": "출고 회수 테스트", + "is_active": true, + "is_deleted": false, + "steps": [ + { + "id": 73011, + "step_order": 1, + "status": { + "id": 1, + "name": "대기", + "is_blocking_next": true, + "is_terminal": false + }, + "approver": { + "id": 110, + "employee_id": "E20250110", + "employee_no": "E20250110", + "name": "문검토" + }, + "assigned_at": "2025-09-20T01:11:00Z", + "decided_at": null, + "note": null + }, + { + "id": 73012, + "step_order": 2, + "status": { + "id": 1, + "name": "대기", + "is_blocking_next": true, + "is_terminal": false + }, + "approver": { + "id": 210, + "employee_id": "E20250210", + "employee_no": "E20250210", + "name": "강팀장" + }, + "assigned_at": "2025-09-20T01:11:00Z", + "decided_at": null, + "note": null + } + ], + "histories": [ + { + "id": 94011, + "action": { + "id": 1, + "name": "상신" + }, + "from_status": null, + "to_status": { + "id": 1, + "name": "대기", + "is_blocking_next": true, + "is_terminal": false + }, + "approver": { + "id": 88, + "employee_id": "E20250088", + "employee_no": "E20250088", + "name": "오요청" + }, + "action_at": "2025-09-20T01:10:00Z", + "note": null + }, + { + "id": 94012, + "action": { + "id": 5, + "name": "회수" + }, + "from_status": { + "id": 2, + "name": "진행중", + "is_blocking_next": true, + "is_terminal": false + }, + "to_status": { + "id": 6, + "name": "회수", + "is_blocking_next": true, + "is_terminal": false + }, + "approver": { + "id": 88, + "employee_id": "E20250088", + "employee_no": "E20250088", + "name": "오요청" + }, + "action_at": "2025-09-20T01:22:30Z", + "note": "승인 지연으로 회수" + } + ], + "created_at": "2025-09-20T01:10:00Z", + "updated_at": "2025-09-20T01:22:30Z", + "visibility": { + "allowed_roles": [ + "requester" + ], + "restricted_roles": [ + "pending_approver", + "external" + ] + }, + "permissions": { + "can_recall": false, + "can_resubmit": true, + "can_comment": true + } +} diff --git a/test/fixtures/approvals/approval_rejected.json b/test/fixtures/approvals/approval_rejected.json new file mode 100644 index 0000000..c65ddb8 --- /dev/null +++ b/test/fixtures/approvals/approval_rejected.json @@ -0,0 +1,153 @@ +{ + "id": 53021, + "approval_no": "APP-202511150089", + "transaction": { + "id": 94090, + "transaction_no": "RENT-20251115-0002" + }, + "status": { + "id": 4, + "name": "반려", + "color": "#EF4444", + "is_blocking_next": true, + "is_terminal": true + }, + "current_step": { + "id": 74021, + "step_order": 1, + "status": { + "id": 4, + "name": "반려", + "is_blocking_next": true, + "is_terminal": true + }, + "approver": { + "id": 120, + "employee_id": "E20250120", + "employee_no": "E20250120", + "name": "신품질" + }, + "assigned_at": "2025-09-21T00:05:00Z", + "decided_at": "2025-09-21T00:16:40Z", + "note": "입력 수량 오류" + }, + "requester": { + "id": 91, + "employee_id": "E20250091", + "employee_no": "E20250091", + "name": "장요청" + }, + "requested_at": "2025-09-21T00:02:00Z", + "decided_at": "2025-09-21T00:16:40Z", + "note": "대여 반려 케이스", + "is_active": true, + "is_deleted": false, + "steps": [ + { + "id": 74021, + "step_order": 1, + "status": { + "id": 4, + "name": "반려", + "is_blocking_next": true, + "is_terminal": true + }, + "approver": { + "id": 120, + "employee_id": "E20250120", + "employee_no": "E20250120", + "name": "신품질" + }, + "assigned_at": "2025-09-21T00:05:00Z", + "decided_at": "2025-09-21T00:16:40Z", + "note": "입력 수량 오류" + }, + { + "id": 74022, + "step_order": 2, + "status": { + "id": 1, + "name": "대기", + "is_blocking_next": true, + "is_terminal": false + }, + "approver": { + "id": 221, + "employee_id": "E20250221", + "employee_no": "E20250221", + "name": "노부장" + }, + "assigned_at": "2025-09-21T00:05:00Z", + "decided_at": null, + "note": null + } + ], + "histories": [ + { + "id": 95021, + "action": { + "id": 1, + "name": "상신" + }, + "from_status": null, + "to_status": { + "id": 1, + "name": "대기", + "is_blocking_next": true, + "is_terminal": false + }, + "approver": { + "id": 91, + "employee_id": "E20250091", + "employee_no": "E20250091", + "name": "장요청" + }, + "action_at": "2025-09-21T00:02:00Z", + "note": null + }, + { + "id": 95022, + "action": { + "id": 3, + "name": "반려" + }, + "from_status": { + "id": 2, + "name": "진행중", + "is_blocking_next": true, + "is_terminal": false + }, + "to_status": { + "id": 4, + "name": "반려", + "is_blocking_next": true, + "is_terminal": true + }, + "approver": { + "id": 120, + "employee_id": "E20250120", + "employee_no": "E20250120", + "name": "신품질" + }, + "action_at": "2025-09-21T00:16:40Z", + "note": "수량 2배 입력" + } + ], + "created_at": "2025-09-21T00:02:00Z", + "updated_at": "2025-09-21T00:16:40Z", + "visibility": { + "allowed_roles": [ + "requester", + "completed_approver" + ], + "restricted_roles": [ + "pending_approver", + "external" + ] + }, + "permissions": { + "can_recall": false, + "can_resubmit": true, + "can_comment": false + } +} diff --git a/test/fixtures/approvals/approval_template_sample.json b/test/fixtures/approvals/approval_template_sample.json new file mode 100644 index 0000000..2e1998a --- /dev/null +++ b/test/fixtures/approvals/approval_template_sample.json @@ -0,0 +1,47 @@ +{ + "id": 901, + "template_code": "IN-STD", + "template_name": "입고 표준 결재", + "description": "팀장 → 센터장 → 본부장 순 결재", + "note": "입고 공통 사용", + "is_active": true, + "created_by": { + "id": 77, + "employee_no": "EMP077", + "name": "김상신" + }, + "created_at": "2025-09-15T00:10:00Z", + "updated_at": "2025-09-18T05:20:00Z", + "steps": [ + { + "id": 9101, + "step_order": 1, + "approver": { + "id": 101, + "employee_no": "EMP101", + "name": "이검토" + }, + "note": "입고 검수" + }, + { + "id": 9102, + "step_order": 2, + "approver": { + "id": 105, + "employee_no": "EMP105", + "name": "정차장" + }, + "note": "센터장 승인" + }, + { + "id": 9103, + "step_order": 3, + "approver": { + "id": 201, + "employee_no": "EMP201", + "name": "한임원" + }, + "note": "최종 승인" + } + ] +} diff --git a/test/helpers/fixture_loader.dart b/test/helpers/fixture_loader.dart new file mode 100644 index 0000000..7dff1ed --- /dev/null +++ b/test/helpers/fixture_loader.dart @@ -0,0 +1,15 @@ +import 'dart:convert'; +import 'dart:io'; + +/// test/fixtures 디렉터리의 JSON 파일을 읽어 Map으로 반환한다. +Map loadJsonFixture(String relativePath) { + final file = File('test/fixtures/$relativePath'); + final contents = file.readAsStringSync(); + return json.decode(contents) as Map; +} + +/// test/fixtures 디렉터리의 텍스트 파일을 그대로 읽어온다. +String readFixture(String relativePath) { + final file = File('test/fixtures/$relativePath'); + return file.readAsStringSync(); +} diff --git a/test/helpers/inventory_test_stubs.dart b/test/helpers/inventory_test_stubs.dart index 4430c47..8b1c26a 100644 --- a/test/helpers/inventory_test_stubs.dart +++ b/test/helpers/inventory_test_stubs.dart @@ -6,6 +6,8 @@ import 'package:superport_v2/features/inventory/lookups/domain/repositories/inve import 'package:superport_v2/features/inventory/transactions/domain/entities/stock_transaction.dart'; import 'package:superport_v2/features/inventory/transactions/domain/entities/stock_transaction_input.dart'; import 'package:superport_v2/features/inventory/transactions/domain/repositories/stock_transaction_repository.dart'; +import 'package:superport_v2/features/masters/product/domain/entities/product.dart'; +import 'package:superport_v2/features/masters/product/domain/repositories/product_repository.dart'; import 'package:superport_v2/features/masters/warehouse/domain/entities/warehouse.dart'; import 'package:superport_v2/features/masters/warehouse/domain/repositories/warehouse_repository.dart'; @@ -26,9 +28,13 @@ const int _statusRentalFinishedId = 33; StockTransactionListFilter? lastTransactionListFilter; class InventoryTestStubConfig { - const InventoryTestStubConfig({this.submitFailure}); + const InventoryTestStubConfig({ + this.submitFailure, + this.registerProductRepository = false, + }); final ApiException? submitFailure; + final bool registerProductRepository; } InventoryTestStubConfig _stubConfig = const InventoryTestStubConfig(); @@ -55,6 +61,10 @@ void registerInventoryTestStubs([ LookupItem(id: _statusRentalReturnWaitId, name: '반납대기'), LookupItem(id: _statusRentalFinishedId, name: '완료'), ], + approvalStatuses: [ + LookupItem(id: 401, name: '승인대기', isDefault: true), + LookupItem(id: 402, name: '승인완료'), + ], ); final transactions = _buildTransactions(); @@ -73,7 +83,6 @@ void registerInventoryTestStubs([ Warehouse(id: 3, warehouseCode: 'WH-003', warehouseName: '대전 물류'), ]; final warehouseRepository = _StubWarehouseRepository(warehouses: warehouses); - final getIt = GetIt.I; if (getIt.isRegistered()) { getIt.unregister(); @@ -95,17 +104,52 @@ void registerInventoryTestStubs([ getIt.registerSingleton(lineRepository); getIt.registerSingleton(customerRepository); getIt.registerSingleton(warehouseRepository); + if (config.registerProductRepository) { + final products = [ + Product( + id: 501, + productCode: 'XR-5000', + productName: 'XR-5000', + vendor: ProductVendor( + id: 11, + vendorCode: 'VN-11', + vendorName: 'X-Ray Co.', + ), + uom: ProductUom(id: 21, uomName: 'EA'), + ), + Product( + id: 502, + productCode: 'Eco-200', + productName: 'Eco-200', + vendor: ProductVendor( + id: 12, + vendorCode: 'VN-12', + vendorName: 'Eco Supplies', + ), + uom: ProductUom(id: 22, uomName: 'EA'), + ), + ]; + if (getIt.isRegistered()) { + getIt.unregister(); + } + getIt.registerSingleton( + _StubProductRepository(products: products), + ); + } } class _StubInventoryLookupRepository implements InventoryLookupRepository { _StubInventoryLookupRepository({ required List transactionTypes, required List statuses, + required List approvalStatuses, }) : _transactionTypes = transactionTypes, - _statuses = statuses; + _statuses = statuses, + _approvalStatuses = approvalStatuses; final List _transactionTypes; final List _statuses; + final List _approvalStatuses; @override Future> fetchTransactionTypes({ @@ -125,7 +169,7 @@ class _StubInventoryLookupRepository implements InventoryLookupRepository { Future> fetchApprovalStatuses({ bool activeOnly = true, }) async { - return const []; + return _approvalStatuses; } @override @@ -136,6 +180,60 @@ class _StubInventoryLookupRepository implements InventoryLookupRepository { } } +class _StubProductRepository implements ProductRepository { + _StubProductRepository({required List products}) + : _products = products; + + final List _products; + + @override + Future> list({ + int page = 1, + int pageSize = 20, + String? query, + int? vendorId, + int? uomId, + bool? isActive, + }) async { + Iterable filtered = _products; + if (query != null && query.trim().isNotEmpty) { + final normalized = query.trim().toLowerCase(); + filtered = filtered.where( + (product) => + product.productCode.toLowerCase().contains(normalized) || + product.productName.toLowerCase().contains(normalized), + ); + } + final items = filtered.toList(growable: false); + return PaginatedResult( + items: items, + page: page, + pageSize: pageSize, + total: items.length, + ); + } + + @override + Future create(ProductInput input) { + throw UnimplementedError(); + } + + @override + Future delete(int id) { + throw UnimplementedError(); + } + + @override + Future restore(int id) { + throw UnimplementedError(); + } + + @override + Future update(int id, ProductInput input) { + throw UnimplementedError(); + } +} + class _StubStockTransactionRepository implements StockTransactionRepository { _StubStockTransactionRepository({ required List transactions, diff --git a/test/navigation/navigation_flow_test.dart b/test/navigation/navigation_flow_test.dart index c036301..dc59885 100644 --- a/test/navigation/navigation_flow_test.dart +++ b/test/navigation/navigation_flow_test.dart @@ -273,7 +273,9 @@ void _registerAuthService( _FakeTokenStorage storage, ) { final service = AuthService(repository: repository, tokenStorage: storage); - when(() => repository.login(any())).thenAnswer((_) async => _buildSampleSession()); + when( + () => repository.login(any()), + ).thenAnswer((_) async => _buildSampleSession()); when(() => repository.refresh(any())).thenThrow(UnimplementedError()); GetIt.I.registerSingleton(service); } diff --git a/test/widgets/app_shell_test.dart b/test/widgets/app_shell_test.dart index 187677c..6b382b6 100644 --- a/test/widgets/app_shell_test.dart +++ b/test/widgets/app_shell_test.dart @@ -78,17 +78,19 @@ void main() { final session = _buildSession(); final authService = _createAuthService(session); final captured = []; - final repository = _StubUserRepository(onUpdateMe: (input) async { - captured.add(input); - return UserAccount( - id: session.user.id, - employeeNo: session.user.employeeNo ?? '', - employeeName: session.user.name, - email: input.email, - mobileNo: input.phone, - group: UserGroup(id: 1, groupName: '물류팀'), - ); - }); + final repository = _StubUserRepository( + onUpdateMe: (input) async { + captured.add(input); + return UserAccount( + id: session.user.id, + employeeNo: session.user.employeeNo ?? '', + employeeName: session.user.name, + email: input.email, + mobileNo: input.phone, + group: UserGroup(id: 1, groupName: '물류팀'), + ); + }, + ); GetIt.I.registerSingleton(authService); GetIt.I.registerSingleton(repository); @@ -134,17 +136,19 @@ void main() { final session = _buildSession(); final authService = _createAuthService(session); UserProfileUpdateInput? passwordInput; - final repository = _StubUserRepository(onUpdateMe: (input) async { - passwordInput = input; - return UserAccount( - id: session.user.id, - employeeNo: session.user.employeeNo ?? '', - employeeName: session.user.name, - email: input.email ?? session.user.email, - mobileNo: input.phone ?? session.user.phone, - group: UserGroup(id: 1, groupName: '물류팀'), - ); - }); + final repository = _StubUserRepository( + onUpdateMe: (input) async { + passwordInput = input; + return UserAccount( + id: session.user.id, + employeeNo: session.user.employeeNo ?? '', + employeeName: session.user.name, + email: input.email ?? session.user.email, + mobileNo: input.phone ?? session.user.phone, + group: UserGroup(id: 1, groupName: '물류팀'), + ); + }, + ); GetIt.I.registerSingleton(authService); GetIt.I.registerSingleton(repository);