From f4dc83d441d8f3a2c5e175aad12125a8161f332e Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Wed, 22 Oct 2025 01:05:47 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B3=84=EC=A0=95=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EB=8B=A4=EC=9D=B4=EC=96=BC=EB=A1=9C=EA=B7=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=EC=A0=84=EC=B2=B4=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=20=ED=8E=98=EC=B9=98=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- doc/backup/backend_change_requests.md | 150 ++++++----- doc/stock_approval_system_api_v4.md | 23 +- doc/stock_approval_system_spec_v4.md | 1 + lib/core/common/utils/pagination_utils.dart | 65 +++++ .../approval_history_controller.dart | 13 +- .../pages/approval_history_page.dart | 9 +- .../controllers/approval_controller.dart | 29 +- .../presentation/pages/approval_page.dart | 46 +++- .../controllers/approval_step_controller.dart | 13 +- .../pages/approval_step_page.dart | 85 ++++-- .../approval_template_controller.dart | 13 +- .../pages/approval_template_page.dart | 45 ++-- .../auth/domain/entities/auth_permission.dart | 29 +- .../presentation/pages/dashboard_page.dart | 13 +- .../presentation/pages/inbound_page.dart | 28 +- .../presentation/pages/outbound_page.dart | 36 ++- .../presentation/pages/rental_page.dart | 28 +- .../widgets/warehouse_select_field.dart | 69 ++--- .../controllers/customer_controller.dart | 13 +- .../presentation/pages/customer_page.dart | 62 ++++- .../controllers/group_controller.dart | 13 +- .../group/presentation/pages/group_page.dart | 58 +++- .../group_permission_controller.dart | 35 ++- .../pages/group_permission_page.dart | 54 +++- .../controllers/menu_controller.dart | 28 +- .../menu/presentation/pages/menu_page.dart | 58 +++- .../controllers/product_controller.dart | 30 ++- .../presentation/pages/product_page.dart | 62 ++++- .../widgets/uom_autocomplete_field.dart | 33 ++- .../widgets/vendor_autocomplete_field.dart | 36 +-- .../controllers/user_controller.dart | 23 +- .../user/presentation/pages/user_page.dart | 54 +++- .../controllers/vendor_controller.dart | 13 +- .../presentation/pages/vendor_page.dart | 77 ++++-- .../controllers/warehouse_controller.dart | 13 +- .../presentation/pages/warehouse_page.dart | 62 ++++- .../presentation/pages/reporting_page.dart | 14 +- lib/widgets/app_shell.dart | 254 +++++++++++++++++- lib/widgets/components/feedback.dart | 5 +- lib/widgets/components/superport_table.dart | 25 +- .../domain/entities/auth_permission_test.dart | 34 +++ .../controllers/menu_controller_test.dart | 2 +- .../vendor_autocomplete_field_test.dart | 49 ++++ test/widgets/app_shell_test.dart | 196 ++++++++++++++ 44 files changed, 1636 insertions(+), 362 deletions(-) create mode 100644 lib/core/common/utils/pagination_utils.dart create mode 100644 test/features/auth/domain/entities/auth_permission_test.dart create mode 100644 test/features/masters/product/presentation/widgets/vendor_autocomplete_field_test.dart create mode 100644 test/widgets/app_shell_test.dart diff --git a/doc/backup/backend_change_requests.md b/doc/backup/backend_change_requests.md index 8052777..8ebbdd3 100644 --- a/doc/backup/backend_change_requests.md +++ b/doc/backup/backend_change_requests.md @@ -1,79 +1,97 @@ -# 백엔드 수정 요청서 (2025-10-16 갱신) +# 백엔드 수정 요청서 (2025-10-20 갱신) ## 1. 배경 -- Flutter 프런트엔드(`superport_v2`)와 최신 백엔드(`superport_api_v2`) 사이 계약을 점검한 결과, 다수의 엔드포인트가 미구현이거나 응답 스키마가 상이해 실사용 플로우를 마무리할 수 없다. -- 프런트는 Clean Architecture 구조 및 DTO를 백엔드 스펙(v4)에 맞춰 구현한 상태이며, 실연동 전까지 계약 정합성을 확보해야 한다. -- 본 문서는 백엔드 측 추가 개발/수정을 요청하기 위한 정리 문서이다. +- 프런트엔드는 `.env.development`에서 `API_BASE_URL=http://43.201.34.104:8080`을 사용해 실서버 로그인 API를 호출한다. +- 동일 API를 사용하는 운영 환경에서는 CORS 문제가 없지만, 로컬 웹 개발 시 브라우저가 `http://localhost:` 오리진으로 사전 요청(preflight)을 보내면서 403 대신 CORS 차단이 발생한다. +- 프런트(`superport_v2`)와 백엔드(`superport_api_v2`) 양쪽 구현을 재검토한 결과, 로그인 계약은 일치하나 실서버에서 CORS 응답 헤더가 전혀 내려오지 않는 것으로 확인되었다. +- 로컬 개발 및 QA가 모두 실서버를 바라보고 있어, 백엔드에서 CORS 허용 정책을 명시적으로 정비해야 한다. -## 2. 주요 이슈 요약 -- 로그인 및 대시보드 핵심 엔드포인트(`/api/v1/auth/**`, `/api/v1/dashboard/summary`)가 존재하지 않아 애플리케이션 초기 진입이 불가능하다. -- 보고서 다운로드 화면이 호출하는 `/api/v1/reports/**` 엔드포인트가 미구현 상태다. -- 결재·재고 API 응답 키가 프런트 DTO와 불일치하여 승인 상태, 요청자, 제품/벤더 정보 등이 전부 기본값으로 표시되며, 단계/상태 전환 이후 최신 데이터를 확보할 수 없다. -- 결재 단계(`approval-steps`) API가 단계 CRUD/액션 수행 후 적절한 본문을 반환하지 않고, 목록 필터(승인자·상태·검색)도 지원하지 않는다. -- 그룹-메뉴 권한 API가 라우팅 정보를 제공하지 않고, 삭제 항목 조회 파라미터가 프런트와 불일치해 권한 동기화가 깨진다. +## 2. 현상 및 재현 절차 +- 브라우저 콘솔 오류: + ``` + Access to XMLHttpRequest at 'http://43.201.34.104:8080/api/v1/auth/login' from origin 'http://localhost:50408' + has been blocked by CORS policy: Response to preflight request doesn't pass access control check: + No 'Access-Control-Allow-Origin' header is present on the requested resource. + ``` +- curl 재현(사전 요청): -## 3. 상세 요청 + ```bash + curl -i -X OPTIONS \ + http://43.201.34.104:8080/api/v1/auth/login \ + -H 'Origin: http://localhost:50408' \ + -H 'Access-Control-Request-Method: POST' + ``` -### 3.1 로그인/세션 및 대시보드 API 구현 -- 엔드포인트 - - `POST /api/v1/auth/login`: `identifier`, `password`, `remember_me`(bool) 입력을 받아 `{ "data": { "access_token", "refresh_token", "expires_at", "user", "permissions" } }` 구조를 반환해야 한다. `user` 객체는 `{ id, name, employee_no, email, primary_group { id, name } }` 필드를 포함하고, `permissions`는 `resource`와 `actions[]`(소문자 문자열)로 구성된다. - - `POST /api/v1/auth/refresh`: `refresh_token`으로 세션을 갱신하며 응답 스키마는 로그인과 동일하다. - - `GET /api/v1/dashboard/summary`: `{ "data": { "generated_at", "kpis": [], "recent_transactions": [], "pending_approvals": [] } }` 형태로 내려 KPI 카드, 최근 전표, 결재 대기 목록을 채울 수 있어야 한다. -- 요구 사항 - - `kpis[]` 항목은 `{ key, label, value, trend_label, delta }` 필드를 제공해 프런트 차트 증감률을 계산할 수 있도록 한다. - - `recent_transactions[]`는 `{ transaction_no, transaction_date, transaction_type, status_name, created_by }` 문자열 필드로 구성한다. - - `pending_approvals[]`는 `{ approval_no, title, step_summary, requested_at }`을 포함하며 `requested_at`은 ISO8601 UTC 문자열로 반환한다. - - 로그인 실패 시 `invalid credentials`, 비활성 계정 접근 시 `account is inactive`, 갱신 토큰 만료는 `token expired`, 재사용·서명 오류는 `invalid token` 메시지를 반환해 프런트 알림 문구와 동일하게 맞춘다. - - 인증 실패(401), 세션 만료·권한 거부(403) 시 `{ "error": { "code": , "message": "...", "details": [...] } }` 규격을 사용하고, 만료/재사용 토큰별 메시지를 문서화한다. + 실제 응답: `HTTP/1.1 404 Not Found` + 헤더 없음 → CORS 미적용. -### 3.2 보고서 Export API 구현 -- 엔드포인트 - - `GET /api/v1/reports/transactions/export` - - `GET /api/v1/reports/approvals/export` -- 요구 사항 - - 공통 쿼리: `from`, `to`, `format(xlsx|pdf)`, `transaction_status_id`, `approval_status_id`, `requested_by_id`. - - 트랜잭션 보고서 `from`·`to` 값은 `yyyy-MM-dd` 형식, 결재 보고서는 ISO8601 UTC 타임스탬프를 지원한다. - - 응답은 기본적으로 파일 스트림(`Content-Type`은 선택한 포맷의 MIME, `Content-Disposition: attachment; filename=""`)이며, 스토리지 연계 시 `{ "data": { "download_url", "filename", "mime_type", "expires_at" } }` 메타데이터로 대체할 수 있다. - - `format=pdf` 요청도 정상 처리하고, 지원 불가 시 명확한 4xx 코드·메시지를 반환하도록 문서화한다. - - 모든 다운로드 요청에 대해 접근 권한·감사 로그 정책을 명시한다. +- 실제 요청도 동일하게 헤더가 비어 있음: -### 3.3 결재/재고 응답 스키마 정합성 -- 결재 목록·단건 응답은 프런트 도메인 모델과 동일한 키를 사용한다. - - 단건 응답은 `{ "data": { ... } }` 혹은 `{ "data": { "approval": { ... } } }` 구조를 유지하고, `approval_no`, `transaction_no`, `status { id, name, color }`, `requester { id, employee_no, name }`, `current_step { id, step_order, status { id, name, is_blocking_next, is_terminal }, approver { id, employee_no, name }, assigned_at, decided_at, note }`, `steps[]`, `histories[]`, `created_at`, `updated_at`을 포함한다. - - 모든 단계·이력 항목은 `status` 키로 정규화하고(`step_status` 금지), `histories[]`에는 `action { id, name }`, `from_status`, `to_status`, `approver`, `action_at`, `note`를 내려준다. - - `approval` 객체에는 필요 시 `transaction { id, transaction_no }`, `template_name` 등을 함께 포함해 단계 목록에서도 동일 데이터를 재사용할 수 있도록 한다. -- 재고 트랜잭션 응답은 중첩 객체 구조를 보장한다. - - 헤더: `transaction_type { id, name }`, `transaction_status { id, name }`, `warehouse { id, warehouse_code, warehouse_name, zipcode { ... } }`, `created_by { id, employee_no, employee_name }`, `expected_return_date`. - - 라인: `lines[].product { id, product_code, product_name, vendor { id, vendor_name }, uom { id, uom_name } }`, `quantity`, `unit_price`, `note`. - - 고객: `customers[].customer { id, customer_code, customer_name }`, `note`. - - 결재 요약: `approval { id, approval_no, status { id, name, is_blocking_next } }`. - - `quantity`, `unit_price`는 BigDecimal 직렬화 그대로 전달하되 `null`은 키를 생략하지 말 것(프런트 DTO가 숫자·문자열 모두를 파싱한다). - - `warehouse.zipcode`는 최소 `zipcode`, `road_name`을 포함하고 추가 주소 필드가 있으면 그대로 노출한다. -- 상태 전환(Submit/Approve/Reject/Cancel/Complete) 응답은 최신 `data.transaction` 전체 또는 최소한 `data.transaction_status`, `data.updated_at`, `data.approval`을 포함해 UI가 즉시 갱신되도록 한다. + ```bash + curl -i -X POST \ + http://43.201.34.104:8080/api/v1/auth/login \ + -H 'Origin: http://localhost:50408' \ + -H 'Content-Type: application/json' \ + -d '{"identifier":"test","password":"test"}' + ``` -### 3.4 결재 단계/행위 API 정합성 -- `GET /api/v1/approval-steps`는 `approver_id`, `approval_id`, `status_id`(또는 `step_status_id`), `q`(결재번호·승인자 키워드)를 지원하고, 항상 `include=approval,approver,status` 형태의 확장을 처리한다. 응답 항목에는 `approval { id, approval_no, transaction_no, template_name }`이 포함되어야 한다. -- 단계 생성·수정·복구 응답은 `{ "data": { ... } }` 형태로 단계 요약을 반환하고, 단계 행위·일괄 배정 응답은 최신 결재 데이터를 `data.approval` 또는 `data.approval.steps`/`data.histories`에 담아 돌려준다. -- 모든 단계·행위 응답에서 단계 상태 키는 `status`로 통일하고, `step_status_id`는 요청/응답에서 보조 필드로만 유지한다. + 응답: `401 Unauthorized` 본문은 내려오지만 `Access-Control-Allow-Origin` 헤더가 없음. -### 3.5 그룹-메뉴 권한 응답 확장 -- `GET /api/v1/group-menu-permissions` 및 단건 응답의 `menu` 객체에 `route_path`(가능하면 `path` 보조 필드 포함)를 항상 채운다. -- `deleted=true`(또는 `include_deleted=true`) 파라미터를 허용해 소프트 삭제 항목을 조회할 수 있게 하고, 응답 항목에 `is_deleted`를 노출한다. -- `include=group,menu` 확장을 공식화해 그룹/메뉴 요약을 한 번에 받을 수 있도록 한다. +## 3. 프런트/백엔드 로그인 계약 점검 +- **프런트 요청 구조** + - 경로: `POST ${ApiRoutes.apiV1}/auth/login` → `/api/v1/auth/login` + - 페이로드: `{ identifier, password, remember_me }` (`lib/features/auth/data/repositories/auth_repository_remote.dart:18`) + - 응답 파싱: `{ data: { access_token, refresh_token, expires_at, user, permissions[] } }` + (`lib/features/auth/data/dtos/auth_session_dto.dart`) +- **백엔드 구현** + - 라우트: `#[post("/login")]` (`backend/src/api/v1/auth.rs:17`) → `web::scope("/api/v1")` + - 요청 모델: `LoginRequest { identifier: String, password: String, remember_me: bool }` + (`backend/src/domain/auth.rs:9`) + - 응답 모델: `AuthSessionResponse { data: AuthSessionData { ... } }` +- **계약 비교 결론** + - 필드 명/자료형 모두 일치, remember_me 기본값 및 데이터 매핑도 호환. + - 로그인 자체는 401/403 흐름이 정상이나, 브라우저 오리진이 차단되어 요청이 전달되지 못함. -### 3.6 결재 생성/수정 응답 보강 -- `POST /api/v1/approvals`/`PATCH /api/v1/approvals/{id}` 응답은 `{ "data": { "approval": { ... } } }` 형태로 최신 결재 요약과 `steps[]`, 필요 시 `histories[]`를 포함해야 한다. -- `approval_status_id`가 생략되면 자동으로 기본 대기 상태를 설정하는 규칙을 명시하고, `approval_no` 포맷·중복 검증 실패 시 상세 에러 메시지를 반환한다. +## 4. 근본 원인 분석 +- 백엔드 `App::new()` 정의는 `build_cors(&config.cors)` 미들웨어를 `wrap`하고 + `default_service`에서 `OPTIONS` 가드를 204로 처리하도록 구현되어 있음 (`backend/src/app/mod.rs:36-132`). +- `config/default.toml`의 `[cors]` 설정은 `allowed_origins = []`로 전체 허용이 기본이지만, + 실제 운영 환경에서는 `APP_ENV`에 대응하는 설정 또는 환경 변수로 제한된 오리진만 등록한 것으로 추정된다. +- 하지만 허용 목록에서 로컬 호스트가 빠진 경우라면 CORS 미들웨어가 `403`을 반환해야 하는데, + 현재는 단순 404/401 응답으로 보아 **미들웨어가 동작하지 않거나, 리버스 프록시/로드밸런서 구간에서 CORS 헤더가 삭제**되고 있다. +- 결과적으로 브라우저는 `Access-Control-Allow-Origin`을 받지 못하고 사전 요청 단계에서 차단된다. -### 3.7 응답/에러 문서화 및 테스트 -- `stock_approval_system_api_v4.md`에 변경된 요청/응답 예시를 모두 반영하고, 인증/대시보드/결재 단계/보고서 섹션을 최신 상태로 유지한다. -- 회귀 테스트(`cargo test`, 통합 시나리오 스크립트)에 신규 계약을 검증하는 케이스를 추가한다. +## 5. 요청 사항 +1. **백엔드 Actix CORS 설정 재점검** + - `build_cors`가 실제 배포 바이너리에도 적용되는지 확인하고, 필요한 경우 `Cors::default()` 대신 + `Cors::permissive()` 또는 `allowed_origin_fn` 로깅을 추가해 런타임에서 허용 여부를 추적한다. + - `supports_credentials()`를 유지하면서도 최소 `http://localhost` 기반 개발 포트 전체를 허용하도록 + `allowed_origin_fn`에서 와일드카드 검사를 추가하거나, 설정 파일에 와일드카드 표기를 허용하도록 개선한다. + ```rust + .allowed_origin_fn(move |origin, _| { + if allow_all { + return true; + } + if origin.as_bytes().starts_with(b"http://localhost") { + return true; + } + allowed_list.iter().any(|allowed| allowed == origin) + }) + ``` + - 운영 배포용 설정(`APP_ENV=production`)에도 `cors.allowed_origins`에 + `https://{prod-domain}` + `http://localhost` (또는 사내 VPN 도메인)을 명시한다. +2. **리버스 프록시/로드밸런서 검증** + - Nginx/ALB 등 중간 계층이 `OPTIONS` 메서드를 백엔드로 전달하는지 확인하고, 차단 시 `proxy_set_header Access-Control-Allow-Origin` 등을 설정한다. + - 모든 사전 요청이 최소 `204` 혹은 `200`과 함께 `Access-Control-Allow-Origin`, `Access-Control-Allow-Methods`, `Access-Control-Allow-Headers`를 반환하도록 보장한다. +3. **로그인 핸들러 응답 헤더 확인** + - 인증 성공/실패 여부와 관계없이 `Access-Control-Allow-Origin`이 반드시 포함되도록 통합 테스트를 추가한다. + - 예시: `cargo test cors_allows_login_origin` 형태의 통합 테스트에서 `Origin: http://localhost:50408` 헤더를 넣고 응답 헤더를 검증. -## 4. 수용 기준 -- 상기 엔드포인트 및 스키마 변경이 구현되고, 요청/응답이 문서와 일치해야 한다. -- 기존 204 응답은 JSON 응답으로 교체되고, 키(`data.approval`, `data.transaction` 등)가 프런트 기대와 동일해야 한다. -- `cargo fmt`, `cargo check`, `cargo test` 및 CI 파이프라인이 통과한다. +## 6. 검증 및 수용 기준 +- `curl -X OPTIONS` 및 `curl -X POST` 재현 시 `Access-Control-Allow-Origin: http://localhost:50408` 헤더가 내려오고 브라우저 CORS 에러가 사라질 것. +- `flutter run -d chrome --web-port 50408`에서 로그인 성공/실패 흐름이 정상 동작. +- 백엔드 `cargo fmt`, `cargo test` 모두 통과. +- `stock_approval_system_api_v4.md` 또는 운영 문서에 허용 오리진 정책 및 설정 방법을 명시. -## 5. 후속 조치 -- 백엔드 담당자가 개발 일정·우선순위를 산출해 프런트 팀과 공유. -- 구현 완료 후 샌드박스 환경에서 계약 검증 → 프런트엔드 실연동 검증 착수. +## 7. 후속 조치 +- 백엔드 담당자가 실제 배포 서버의 환경 변수/리버스 프록시 설정을 확인 후 조치 내용을 공유. +- 수정 배포 이후 프런트 팀이 실서버 연결 테스트를 수행하고, 필요한 경우 추가 허용 오리진 목록을 요청. diff --git a/doc/stock_approval_system_api_v4.md b/doc/stock_approval_system_api_v4.md index 55c0f0e..3cf4f14 100644 --- a/doc/stock_approval_system_api_v4.md +++ b/doc/stock_approval_system_api_v4.md @@ -9,7 +9,7 @@ ## 0. 구현 현황 요약 (2025-09-18 기준) - 마스터 데이터: `/vendors`, `/uoms`, `/transaction-types`, `/transaction-statuses`, `/approval-statuses`, `/approval-actions`, `/warehouses`, `/customers`, `/products`, `/employees`, `/groups`, `/menus`, `/group-menu-permissions`, `/zipcodes` - 각 자원은 `/api/v1/` 패턴을 따르며, 목록 필터·페이지네이션·`include` 확장을 지원한다. -- 그룹 권한은 `/api/v1/group-menu-permissions`와 `/api/v1/groups/{id}/permissions` 일괄 갱신 엔드포인트로 관리한다. `group-menu-permissions` 응답의 `menu` 객체에는 `route_path`가 포함되며, `include=group` 쿼리로 그룹 요약을 함께 받을 수 있다. +- 그룹 권한은 `/api/v1/group-menu-permissions`와 `/api/v1/groups/{id}/permissions` 일괄 갱신 엔드포인트로 관리한다. `group-menu-permissions` 응답의 `menu` 객체에는 `route_path`와 동일 값을 가진 `path`가 포함되며 각 항목은 `is_deleted`를 노출한다. `include=group,menu` 확장과 `include_deleted=true` 파라미터로 삭제 권한을 함께 조회할 수 있다. - 우편번호 검색 `/api/v1/zipcodes`는 부분 일치 검색(`q`, `zipcode`, `road_name`)과 단건 조회를 제공한다. --- @@ -17,6 +17,7 @@ ## 1. 공통 규칙 - **URI 규칙:** 복수형 리소스 명 사용. 기본 경로 예) `/api/v1/vendors`. - **표준 응답 구조:** 목록은 `{ items: [], page, page_size, total }`, 단건은 `{ data: { ... } }`. +- **헬스 체크:** `GET /health`는 `{ status, build_version, error? }` 구조를 반환하며 `build_version` 값은 `config/default.toml`의 `[app].build_version`에서 로딩된다. 원격 배포 시 `script/deploy_remote.sh`가 배포 아카이브 파일명에서 버전을 추출해 해당 값을 갱신한다. - **시간대:** 모든 날짜·시간은 ISO8601 UTC 문자열. - **소프트 삭제:** `DELETE /{res}/{id}` 호출 시 서버는 `is_deleted=true`, `is_active=false`로 처리하고 응답 바디는 `{ data: { id, deleted_at } }` 형식을 사용. - **복구:** `POST /{res}/{id}/restore`. @@ -34,6 +35,7 @@ - `409 CONFLICT` — 유니크 제약, 결재 단계 상태 충돌. - `422 UNPROCESSABLE_ENTITY` — 비즈니스 규칙 위반(출고 고객 누락, blocking 상태 전이 등). - 에러 응답 예: `{ "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`가 응답된다. --- @@ -60,6 +62,7 @@ "total": 1 } ``` +- `delta` 값은 전일 대비 증감률(비율)로 반환되며 `1.0`은 100% 증가, `-0.5`는 50% 감소를 의미한다. 값이 계산되지 않는 KPI는 `delta`를 생략한다. ### 2.2 단건 조회 `GET /{type}/{id}` @@ -129,6 +132,8 @@ ## 3. 마스터 데이터 API 리소스: `/vendors`, `/warehouses`, `/customers`, `/employees`, `/products`, `/menus`, `/groups`, `/zipcodes` +> 기본 정렬: 별도 `sort` 파라미터가 없으면 항상 `id` 오름차순으로 응답을 정렬한다. (`order` 기본값도 `asc`) + ### 3.1 목록 조회 `GET /vendors?page=1&q=한빛` ```json @@ -1120,6 +1125,7 @@ "approval_id": 5001, "step_order": 1, "approver_id": 21, + "status_id": 1, "step_status_id": 1, "status": { "id": 1, @@ -1137,6 +1143,7 @@ "approval_id": 5001, "step_order": 2, "approver_id": 34, + "status_id": 1, "step_status_id": 1, "status": { "id": 1, @@ -1152,6 +1159,7 @@ ], "approval": { "id": 5001, + "transaction_no": "TXN-2025-0001", "status": { "id": 1, "name": "대기", @@ -1199,6 +1207,7 @@ "approval_id": 5001, "step_order": 1, "approver_id": 21, + "status_id": 1, "step_status_id": 1, "status": { "id": 1, @@ -1216,6 +1225,7 @@ "approval_id": 5001, "step_order": 2, "approver_id": 35, + "status_id": 1, "step_status_id": 1, "status": { "id": 1, @@ -1231,6 +1241,7 @@ ], "approval": { "id": 5001, + "transaction_no": "TXN-2025-0001", "status": { "id": 1, "name": "대기", @@ -1263,6 +1274,7 @@ "data": { "approval": { "id": 5001, + "transaction_no": "TXN-2025-0001", "status": { "id": 2, "name": "진행중", @@ -1317,6 +1329,7 @@ "approval_id": 5001, "step_order": 1, "approver_id": 21, + "status_id": 2, "step_status_id": 2, "status": { "id": 2, @@ -1485,17 +1498,17 @@ ### 5.10 단계 개별 CRUD - `GET /approval-steps?approval_id=5001&include=approval,approver,status` → `{ items: [], page, page_size, total }` 형태로 반환하며, 각 항목은 `approval`, `approver`, `status` 서브 오브젝트를 선택적으로 포함한다. - `GET /approval-steps/7001?include=approval,approver,status` → `{ data: { ... } }`. -- `POST /approval-steps` → 단일 단계를 생성하고 `{ data: { ... } }` 형태로 생성된 요약을 반환한다. `step_status_id`를 생략하면 자동으로 `대기` 상태가 지정된다. +- `POST /approval-steps` → 단일 단계를 생성하고 `{ data: { ... } }` 형태로 생성된 요약을 반환한다. `status_id`(구 버전 호환용 `step_status_id`)를 생략하면 자동으로 `대기` 상태가 지정된다. - `PATCH /approval-steps/{id}` → 갱신된 단계 요약을 반환한다. - `DELETE /approval-steps/{id}` → `{ data: { id, deleted_at } }`. - `POST /approval-steps/{id}/restore` → `{ data: { id, restored_at } }`. 주요 필터 및 확장 파라미터: -- `approval_id`, `approval_step_id`, `approver_id`, `approval_action_id` -- `action_from`, `action_to` (ISO8601) +- `approval_id`, `approval_step_id`, `approver_id`, `approval_action_id`, `status_id` +- `q`(결재번호·승인자 검색), `action_from`, `action_to` (ISO8601) - `sort=action_at|created_at|updated_at`, `order=asc|desc` -- `include` 기본값은 `approver,approval_action,from_status,to_status`; `approval`, `step` 토큰으로 확장 +- `include` 기본값은 `approver,approval_action,from_status,to_status`; `approval`, `step`, `status` 토큰으로 확장 `GET /approval-histories/91001?include=approval,step` ```json diff --git a/doc/stock_approval_system_spec_v4.md b/doc/stock_approval_system_spec_v4.md index 410d72d..4b3947d 100644 --- a/doc/stock_approval_system_spec_v4.md +++ b/doc/stock_approval_system_spec_v4.md @@ -683,3 +683,4 @@ zipcodes ||--o{ customers : addressed - `updated_at` 자동 갱신 트리거, 소프트 삭제 처리 트리거 권장. - 낙관적 잠금(선택): `version`(int) + ETag. - 병렬 결재 확장(선택): `approval_steps`에 `group_no`, `approval_mode(all|any)` 도입. +- `/health` 응답의 `build_version`은 `config/default.toml`의 `[app].build_version`을 사용하며, `script/deploy_remote.sh`가 배포 아카이브 파일명에서 버전을 추출해 값을 주입한다. diff --git a/lib/core/common/utils/pagination_utils.dart b/lib/core/common/utils/pagination_utils.dart new file mode 100644 index 0000000..55a9ffd --- /dev/null +++ b/lib/core/common/utils/pagination_utils.dart @@ -0,0 +1,65 @@ +import '../models/paginated_result.dart'; + +/// 페이지네이션 API에서 모든 항목을 수집하는 도우미. +/// +/// - 백엔드가 기본적으로 페이지 크기 제한을 두는 경우, 반복 호출로 전체 목록을 확보한다. +/// - `maxPages`는 안전장치로 사용하며, 비정상 응답으로 무한 루프에 빠지는 것을 막는다. +typedef PaginatedRequest = + Future> Function(int page, int pageSize); + +/// 페이지 단위로 제공되는 데이터를 모두 불러온다. +/// +/// - [request]는 페이지 번호와 페이지 크기를 받아 `PaginatedResult`를 반환해야 한다. +/// - [pageSize]는 첫 호출에 사용할 기본 페이지 크기이며, 서버가 다른 값을 돌려주면 +/// 이후 호출에서는 응답 값을 따른다. +/// - [maxPages]는 허용할 최대 페이지 수로, 비정상 응답을 대비한 제한값이다. +Future> fetchAllPaginatedItems({ + required PaginatedRequest request, + int pageSize = 100, + int maxPages = 50, +}) async { + final results = []; + var currentPage = 1; + var pagesFetched = 0; + var effectivePageSize = pageSize > 0 ? pageSize : 100; + + while (pagesFetched < maxPages) { + final previousLength = results.length; + final response = await request(currentPage, effectivePageSize); + pagesFetched += 1; + + final items = response.items; + if (items.isEmpty) { + break; + } + + results.addAll(items); + final added = results.length - previousLength; + + if (added <= 0) { + // 새 항목을 받지 못했다면 더 이상 진행하지 않는다. + break; + } + + final receivedPageSize = () { + if (response.pageSize > 0) { + return response.pageSize; + } + if (items.isNotEmpty) { + return items.length; + } + return effectivePageSize; + }(); + + if (items.length < receivedPageSize) { + // 마지막 페이지에 도달한 경우 즉시 종료한다. + break; + } + + effectivePageSize = receivedPageSize; + final nextPage = response.page > 0 ? response.page + 1 : currentPage + 1; + currentPage = nextPage > currentPage ? nextPage : currentPage + 1; + } + + return results; +} 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 ad3cd94..c3368db 100644 --- a/lib/features/approvals/history/presentation/controllers/approval_history_controller.dart +++ b/lib/features/approvals/history/presentation/controllers/approval_history_controller.dart @@ -43,6 +43,17 @@ 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 action = switch (_actionFilter) { ApprovalHistoryActionFilter.all => null, ApprovalHistoryActionFilter.approve => 'approve', @@ -51,7 +62,7 @@ class ApprovalHistoryController extends ChangeNotifier { }; final response = await _repository.list( - page: page, + page: resolvedPage, pageSize: _pageSize, query: _query.trim().isEmpty ? null : _query.trim(), action: action, 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 fa2a413..246b107 100644 --- a/lib/features/approvals/history/presentation/pages/approval_history_page.dart +++ b/lib/features/approvals/history/presentation/pages/approval_history_page.dart @@ -87,9 +87,10 @@ class _ApprovalHistoryEnabledPageState final error = _controller.errorMessage; if (error != null && error != _lastError && mounted) { _lastError = error; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(error))); + final messenger = ScaffoldMessenger.maybeOf(context); + if (messenger != null) { + messenger.showSnackBar(SnackBar(content: Text(error))); + } _controller.clearError(); } } @@ -115,7 +116,7 @@ class _ApprovalHistoryEnabledPageState final currentPage = result?.page ?? 1; final totalPages = result == null || result.pageSize == 0 ? 1 - : (result.total / result.pageSize).ceil().clamp(1, 9999); + : (result.total / result.pageSize).ceil().clamp(1, 9999) as int; final sortedHistories = _applySorting(histories); return AppLayout( diff --git a/lib/features/approvals/presentation/controllers/approval_controller.dart b/lib/features/approvals/presentation/controllers/approval_controller.dart index 5bb0be5..8e26e3a 100644 --- a/lib/features/approvals/presentation/controllers/approval_controller.dart +++ b/lib/features/approvals/presentation/controllers/approval_controller.dart @@ -1,6 +1,7 @@ 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/core/common/models/paginated_result.dart'; +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'; @@ -150,9 +151,20 @@ class ApprovalController 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 statusId = _statusIdFor(_statusFilter); final response = await _repository.list( - page: page, + page: resolvedPage, pageSize: _result?.pageSize ?? 20, transactionId: _transactionIdFilter, approvalStatusId: statusId, @@ -294,12 +306,15 @@ class ApprovalController extends ChangeNotifier { _errorMessage = null; notifyListeners(); try { - final result = await _templateRepository.list( - page: 1, - pageSize: 100, - isActive: true, + final templates = await fetchAllPaginatedItems( + pageSize: 200, + request: (page, pageSize) => _templateRepository.list( + page: page, + pageSize: pageSize, + isActive: true, + ), ); - _templates = result.items; + _templates = templates; } catch (error) { final failure = Failure.from(error); _errorMessage = failure.describe(); diff --git a/lib/features/approvals/presentation/pages/approval_page.dart b/lib/features/approvals/presentation/pages/approval_page.dart index 5c0624a..a60f8bd 100644 --- a/lib/features/approvals/presentation/pages/approval_page.dart +++ b/lib/features/approvals/presentation/pages/approval_page.dart @@ -135,7 +135,7 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> { final currentPage = result?.page ?? 1; final totalPages = result == null || result.pageSize == 0 ? 1 - : (result.total / result.pageSize).ceil().clamp(1, 9999); + : (result.total / result.pageSize).ceil().clamp(1, 9999) as int; final hasNext = result == null ? false : (result.page * result.pageSize) < result.total; @@ -275,6 +275,15 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> { ), Row( children: [ + ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: + _controller.isLoadingList || currentPage <= 1 + ? null + : () => _controller.fetch(page: 1), + child: const Text('처음'), + ), + const SizedBox(width: 8), ShadButton.outline( size: ShadButtonSize.sm, onPressed: @@ -291,6 +300,15 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> { : () => _controller.fetch(page: currentPage + 1), child: const Text('다음'), ), + const SizedBox(width: 8), + ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: + _controller.isLoadingList || currentPage >= totalPages + ? null + : () => _controller.fetch(page: totalPages), + child: const Text('마지막'), + ), ], ), ], @@ -393,7 +411,10 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> { ShadButton.ghost( onPressed: isSubmitting ? null - : () => Navigator.of(context).pop(false), + : () => Navigator.of( + context, + rootNavigator: true, + ).pop(false), child: const Text('취소'), ), ShadButton( @@ -449,7 +470,10 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> { return; } if (result != null) { - Navigator.of(context).pop(true); + Navigator.of( + context, + rootNavigator: true, + ).pop(true); } }, child: isSubmitting @@ -788,7 +812,10 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> { constraints: const BoxConstraints(maxWidth: 420), actions: [ ShadButton.ghost( - onPressed: () => Navigator.of(dialogContext).pop(), + onPressed: () => Navigator.of( + dialogContext, + rootNavigator: true, + ).pop(), child: const Text('취소'), ), ShadButton( @@ -798,7 +825,10 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> { setState(() => errorText = '비고를 입력하세요.'); return; } - Navigator.of(dialogContext).pop( + Navigator.of( + dialogContext, + rootNavigator: true, + ).pop( _StepActionDialogResult(note: note.isEmpty ? null : note), ); }, @@ -877,11 +907,13 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> { title: '템플릿 적용 확인', actions: [ ShadButton.ghost( - onPressed: () => Navigator.of(context).pop(false), + onPressed: () => + Navigator.of(context, rootNavigator: true).pop(false), child: const Text('취소'), ), ShadButton( - onPressed: () => Navigator.of(context).pop(true), + onPressed: () => + Navigator.of(context, rootNavigator: true).pop(true), child: const Text('적용'), ), ], diff --git a/lib/features/approvals/step/presentation/controllers/approval_step_controller.dart b/lib/features/approvals/step/presentation/controllers/approval_step_controller.dart index df3034a..7b8f100 100644 --- a/lib/features/approvals/step/presentation/controllers/approval_step_controller.dart +++ b/lib/features/approvals/step/presentation/controllers/approval_step_controller.dart @@ -41,9 +41,20 @@ class ApprovalStepController 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 sanitizedQuery = _query.trim(); final response = await _repository.list( - page: page, + page: resolvedPage, pageSize: _result?.pageSize ?? 20, query: sanitizedQuery.isEmpty ? null : sanitizedQuery, statusId: _statusId, diff --git a/lib/features/approvals/step/presentation/pages/approval_step_page.dart b/lib/features/approvals/step/presentation/pages/approval_step_page.dart index 4d59d24..cbccc56 100644 --- a/lib/features/approvals/step/presentation/pages/approval_step_page.dart +++ b/lib/features/approvals/step/presentation/pages/approval_step_page.dart @@ -87,9 +87,10 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> { final error = _controller.errorMessage; if (error != null && error != _lastError && mounted) { _lastError = error; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(error))); + final messenger = ScaffoldMessenger.maybeOf(context); + if (messenger != null) { + messenger.showSnackBar(SnackBar(content: Text(error))); + } _controller.clearError(); } } @@ -117,7 +118,7 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> { final pageSize = result?.pageSize ?? records.length; final totalPages = pageSize == 0 ? 1 - : (totalCount / pageSize).ceil().clamp(1, 9999); + : (totalCount / pageSize).ceil().clamp(1, 9999) as int; final hasNext = result == null ? false : (result.page * result.pageSize) < result.total; @@ -410,6 +411,17 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> { Text('총 $totalCount건', style: theme.textTheme.small), Row( children: [ + ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: + _controller.isLoading || + isSaving || + currentPage <= 1 + ? null + : () => _controller.fetch(page: 1), + child: const Text('처음'), + ), + const SizedBox(width: 8), ShadButton.outline( size: ShadButtonSize.sm, onPressed: @@ -435,6 +447,19 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> { ), child: const Text('다음'), ), + const SizedBox(width: 8), + ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: + _controller.isLoading || + isSaving || + currentPage >= totalPages + ? null + : () => _controller.fetch( + page: totalPages, + ), + child: const Text('마지막'), + ), const SizedBox(width: 12), Text( '페이지 $currentPage / $totalPages', @@ -507,7 +532,8 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> { return; } - ScaffoldMessenger.of(context).showSnackBar( + final messenger = ScaffoldMessenger.maybeOf(context); + messenger?.showSnackBar( SnackBar(content: Text('결재번호 ${created.approvalNo} 단계가 추가되었습니다.')), ); } @@ -515,9 +541,10 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> { Future _openEditStepForm(ApprovalStepRecord record) async { final stepId = record.step.id; if (stepId == null) { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('저장되지 않은 단계는 수정할 수 없습니다.'))); + final messenger = ScaffoldMessenger.maybeOf(context); + messenger?.showSnackBar( + const SnackBar(content: Text('저장되지 않은 단계는 수정할 수 없습니다.')), + ); return; } @@ -542,7 +569,8 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> { return; } - ScaffoldMessenger.of(context).showSnackBar( + final messenger = ScaffoldMessenger.maybeOf(context); + messenger?.showSnackBar( SnackBar(content: Text('결재번호 ${updated.approvalNo} 단계 정보를 수정했습니다.')), ); } @@ -550,7 +578,8 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> { Future _openDetail(ApprovalStepRecord record) async { final stepId = record.step.id; if (stepId == null) { - ScaffoldMessenger.of(context).showSnackBar( + final messenger = ScaffoldMessenger.maybeOf(context); + messenger?.showSnackBar( const SnackBar(content: Text('단계 식별자가 없어 상세 정보를 볼 수 없습니다.')), ); return; @@ -614,9 +643,10 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> { Future _confirmDeleteStep(ApprovalStepRecord record) async { final stepId = record.step.id; if (stepId == null) { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('저장되지 않은 단계는 삭제할 수 없습니다.'))); + final messenger = ScaffoldMessenger.maybeOf(context); + messenger?.showSnackBar( + const SnackBar(content: Text('저장되지 않은 단계는 삭제할 수 없습니다.')), + ); return; } @@ -628,11 +658,13 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> { '결재번호 ${record.approvalNo}의 ${record.step.stepOrder}단계를 삭제하시겠습니까? 삭제 후 복구할 수 있습니다.', actions: [ ShadButton.ghost( - onPressed: () => Navigator.of(context).pop(false), + onPressed: () => + Navigator.of(context, rootNavigator: true).pop(false), child: const Text('취소'), ), ShadButton.destructive( - onPressed: () => Navigator.of(context).pop(true), + onPressed: () => + Navigator.of(context, rootNavigator: true).pop(true), child: const Text('삭제'), ), ], @@ -648,7 +680,8 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> { return; } if (success) { - ScaffoldMessenger.of(context).showSnackBar( + final messenger = ScaffoldMessenger.maybeOf(context); + messenger?.showSnackBar( SnackBar(content: Text('결재번호 ${record.approvalNo} 단계가 삭제되었습니다.')), ); } @@ -657,9 +690,10 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> { Future _confirmRestoreStep(ApprovalStepRecord record) async { final stepId = record.step.id; if (stepId == null) { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('단계 식별자가 없어 복구할 수 없습니다.'))); + final messenger = ScaffoldMessenger.maybeOf(context); + messenger?.showSnackBar( + const SnackBar(content: Text('단계 식별자가 없어 복구할 수 없습니다.')), + ); return; } @@ -671,11 +705,13 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> { '결재번호 ${record.approvalNo}의 ${record.step.stepOrder}단계를 복구하시겠습니까?', actions: [ ShadButton.ghost( - onPressed: () => Navigator.of(context).pop(false), + onPressed: () => + Navigator.of(context, rootNavigator: true).pop(false), child: const Text('취소'), ), ShadButton( - onPressed: () => Navigator.of(context).pop(true), + onPressed: () => + Navigator.of(context, rootNavigator: true).pop(true), child: const Text('복구'), ), ], @@ -691,7 +727,8 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> { return; } if (restored != null) { - ScaffoldMessenger.of(context).showSnackBar( + final messenger = ScaffoldMessenger.maybeOf(context); + messenger?.showSnackBar( SnackBar(content: Text('결재번호 ${restored.approvalNo} 단계가 복구되었습니다.')), ); } @@ -832,7 +869,7 @@ class _StepFormDialogState extends State<_StepFormDialog> { child: Text(widget.submitLabel), ), secondaryAction: ShadButton.ghost( - onPressed: () => Navigator.of(context).pop(), + onPressed: () => Navigator.of(context, rootNavigator: true).pop(), child: const Text('취소'), ), contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), @@ -951,7 +988,7 @@ class _StepFormDialogState extends State<_StepFormDialog> { statusId: widget.initialRecord?.step.status.id, ); - Navigator.of(context).pop(input); + Navigator.of(context, rootNavigator: true).pop(input); } void _clearError(String field) { 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 f0d9012..d109bdd 100644 --- a/lib/features/approvals/template/presentation/controllers/approval_template_controller.dart +++ b/lib/features/approvals/template/presentation/controllers/approval_template_controller.dart @@ -41,6 +41,17 @@ class ApprovalTemplateController 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 sanitizedQuery = _query.trim(); final isActive = switch (_statusFilter) { ApprovalTemplateStatusFilter.all => null, @@ -48,7 +59,7 @@ class ApprovalTemplateController extends ChangeNotifier { ApprovalTemplateStatusFilter.inactiveOnly => false, }; final response = await _repository.list( - page: page, + page: resolvedPage, pageSize: _pageSize, query: sanitizedQuery.isEmpty ? null : sanitizedQuery, isActive: isActive, 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 fae3afe..9a0b866 100644 --- a/lib/features/approvals/template/presentation/pages/approval_template_page.dart +++ b/lib/features/approvals/template/presentation/pages/approval_template_page.dart @@ -86,9 +86,10 @@ class _ApprovalTemplateEnabledPageState final error = _controller.errorMessage; if (error != null && error != _lastError && mounted) { _lastError = error; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(error))); + final messenger = ScaffoldMessenger.maybeOf(context); + if (messenger != null) { + messenger.showSnackBar(SnackBar(content: Text(error))); + } _controller.clearError(); } } @@ -115,7 +116,7 @@ class _ApprovalTemplateEnabledPageState final currentPage = result?.page ?? 1; final totalPages = result == null || result.pageSize == 0 ? 1 - : (result.total / result.pageSize).ceil().clamp(1, 9999); + : (result.total / result.pageSize).ceil().clamp(1, 9999) as int; final showReset = _searchController.text.trim().isNotEmpty || _controller.statusFilter != ApprovalTemplateStatusFilter.all; @@ -340,9 +341,10 @@ class _ApprovalTemplateEnabledPageState } final success = await _openTemplateForm(template: detail); if (!mounted || success != true) return; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('템플릿 "${detail.name}"을(를) 수정했습니다.'))); + final messenger = ScaffoldMessenger.maybeOf(context); + messenger?.showSnackBar( + SnackBar(content: Text('템플릿 "${detail.name}"을(를) 수정했습니다.')), + ); } Future _confirmDelete(ApprovalTemplate template) async { @@ -354,11 +356,13 @@ class _ApprovalTemplateEnabledPageState '"${template.name}" 템플릿을 삭제하시겠습니까?\n삭제 시 템플릿은 미사용 상태로 전환됩니다.', actions: [ ShadButton.ghost( - onPressed: () => Navigator.of(context).pop(false), + onPressed: () => + Navigator.of(context, rootNavigator: true).pop(false), child: const Text('취소'), ), ShadButton( - onPressed: () => Navigator.of(context).pop(true), + onPressed: () => + Navigator.of(context, rootNavigator: true).pop(true), child: const Text('삭제'), ), ], @@ -367,7 +371,8 @@ class _ApprovalTemplateEnabledPageState if (confirmed != true) return; final ok = await _controller.delete(template.id); if (!mounted || !ok) return; - ScaffoldMessenger.of(context).showSnackBar( + final messenger = ScaffoldMessenger.maybeOf(context); + messenger?.showSnackBar( SnackBar(content: Text('템플릿 "${template.name}"을(를) 삭제했습니다.')), ); } @@ -380,11 +385,13 @@ class _ApprovalTemplateEnabledPageState description: '"${template.name}" 템플릿을 복구하시겠습니까?', actions: [ ShadButton.ghost( - onPressed: () => Navigator.of(context).pop(false), + onPressed: () => + Navigator.of(context, rootNavigator: true).pop(false), child: const Text('취소'), ), ShadButton( - onPressed: () => Navigator.of(context).pop(true), + onPressed: () => + Navigator.of(context, rootNavigator: true).pop(true), child: const Text('복구'), ), ], @@ -393,7 +400,8 @@ class _ApprovalTemplateEnabledPageState if (confirmed != true) return; final restored = await _controller.restore(template.id); if (!mounted || restored == null) return; - ScaffoldMessenger.of(context).showSnackBar( + final messenger = ScaffoldMessenger.maybeOf(context); + messenger?.showSnackBar( SnackBar(content: Text('템플릿 "${restored.name}"을(를) 복구했습니다.')), ); } @@ -466,7 +474,7 @@ class _ApprovalTemplateEnabledPageState ? await _controller.update(existingTemplate.id, input, stepInputs) : await _controller.create(input, stepInputs); if (success != null && mounted) { - Navigator.of(context).pop(true); + Navigator.of(context, rootNavigator: true).pop(true); } else { modalSetState?.call(() => isSaving = false); } @@ -609,7 +617,7 @@ class _ApprovalTemplateEnabledPageState ShadButton.ghost( onPressed: () { if (isSaving) return; - Navigator.of(context).pop(false); + Navigator.of(context, rootNavigator: true).pop(false); }, child: const Text('취소'), ), @@ -632,9 +640,10 @@ class _ApprovalTemplateEnabledPageState statusNotifier.dispose(); if (result == true && mounted && template == null) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('템플릿 "$createdName"을 생성했습니다.'))); + final messenger = ScaffoldMessenger.maybeOf(context); + messenger?.showSnackBar( + SnackBar(content: Text('템플릿 "$createdName"을 생성했습니다.')), + ); } return result; } diff --git a/lib/features/auth/domain/entities/auth_permission.dart b/lib/features/auth/domain/entities/auth_permission.dart index b218cc1..581b50b 100644 --- a/lib/features/auth/domain/entities/auth_permission.dart +++ b/lib/features/auth/domain/entities/auth_permission.dart @@ -16,17 +16,36 @@ class AuthPermission { final normalized = PermissionResources.normalize(resource); final actionSet = {}; for (final raw in actions) { - final matched = PermissionAction.values.where( - (action) => action.name == raw.trim().toLowerCase(), - ); - if (matched.isEmpty) { + final parsed = _parseAction(raw); + if (parsed == null) { continue; } - actionSet.addAll(matched); + actionSet.add(parsed); } if (actionSet.isEmpty) { return >{}; } return {normalized: actionSet}; } + + /// 백엔드 권한 문자열을 [PermissionAction]으로 변환한다. + /// + /// - 백엔드 스펙(`create`, `read`, `update`, `delete`, `restore`, `approve`)과 + /// 프런트 내부 별칭(`view`, `edit`)을 모두 인식한다. + /// - 인식할 수 없는 문자열은 무시해 잘못된 권한이 섞여도 앱이 중단되지 않도록 한다. + PermissionAction? _parseAction(String raw) { + final key = raw.trim().toLowerCase(); + return _actionMap[key]; + } + + static const Map _actionMap = { + 'view': PermissionAction.view, + 'read': PermissionAction.view, + 'create': PermissionAction.create, + 'edit': PermissionAction.edit, + 'update': PermissionAction.edit, + 'delete': PermissionAction.delete, + 'restore': PermissionAction.restore, + 'approve': PermissionAction.approve, + }; } diff --git a/lib/features/dashboard/presentation/pages/dashboard_page.dart b/lib/features/dashboard/presentation/pages/dashboard_page.dart index 15c8361..24e435e 100644 --- a/lib/features/dashboard/presentation/pages/dashboard_page.dart +++ b/lib/features/dashboard/presentation/pages/dashboard_page.dart @@ -127,7 +127,9 @@ class _DashboardPageState extends State { Align( alignment: Alignment.centerRight, child: ShadButton( - onPressed: _controller.refresh, + onPressed: _controller.isLoading + ? null + : _controller.refresh, child: const Text('다시 시도'), ), ), @@ -138,12 +140,15 @@ class _DashboardPageState extends State { ); } - final kpiMap = {for (final item in summary.kpis) item.key: item}; + final kpiMap = summary.kpis.fold>({}, (map, kpi) { + map[kpi.key] = kpi; + return map; + }); return SingleChildScrollView( - padding: const EdgeInsets.only(right: 12, bottom: 24), + padding: const EdgeInsets.only(bottom: 48), child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + crossAxisAlignment: CrossAxisAlignment.start, children: [ if (_controller.errorMessage != null) Padding( diff --git a/lib/features/inventory/inbound/presentation/pages/inbound_page.dart b/lib/features/inventory/inbound/presentation/pages/inbound_page.dart index f09b800..3b57562 100644 --- a/lib/features/inventory/inbound/presentation/pages/inbound_page.dart +++ b/lib/features/inventory/inbound/presentation/pages/inbound_page.dart @@ -520,6 +520,17 @@ class _InboundPageState extends State { style: theme.textTheme.small, ), const SizedBox(width: 12), + ShadButton.ghost( + size: ShadButtonSize.sm, + onPressed: currentPage <= 1 + ? null + : () => _goToPage(1), + child: const Icon( + lucide.LucideIcons.chevronsLeft, + size: 16, + ), + ), + const SizedBox(width: 8), ShadButton.ghost( size: ShadButtonSize.sm, onPressed: currentPage <= 1 @@ -541,6 +552,17 @@ class _InboundPageState extends State { size: 16, ), ), + const SizedBox(width: 8), + ShadButton.ghost( + size: ShadButtonSize.sm, + onPressed: currentPage >= totalPages + ? null + : () => _goToPage(totalPages), + child: const Icon( + lucide.LucideIcons.chevronsRight, + size: 16, + ), + ), ], ), ], @@ -1060,7 +1082,11 @@ class _InboundPageState extends State { } void _goToPage(int page) { - final target = page < 1 ? 1 : page; + final totalItems = _result?.total ?? _filteredRecords.length; + final totalPages = _calculateTotalPages(totalItems); + final int target = page < 1 + ? 1 + : (page > totalPages ? totalPages : page); if (target == _currentPage) { return; } diff --git a/lib/features/inventory/outbound/presentation/pages/outbound_page.dart b/lib/features/inventory/outbound/presentation/pages/outbound_page.dart index 143a3fb..e0fca9e 100644 --- a/lib/features/inventory/outbound/presentation/pages/outbound_page.dart +++ b/lib/features/inventory/outbound/presentation/pages/outbound_page.dart @@ -18,6 +18,7 @@ import 'package:superport_v2/features/inventory/shared/widgets/customer_multi_se import 'package:superport_v2/features/inventory/shared/widgets/warehouse_select_field.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'; import 'package:superport_v2/core/permissions/permission_manager.dart'; import 'package:superport_v2/core/permissions/permission_resources.dart'; import 'package:superport_v2/features/inventory/outbound/presentation/controllers/outbound_controller.dart'; @@ -163,17 +164,16 @@ class _OutboundPageState extends State { try { final repository = getIt(); - final result = await repository.list( - page: 1, - pageSize: 100, - isActive: true, + final customers = await fetchAllPaginatedItems( + request: (page, pageSize) => + repository.list(page: page, pageSize: pageSize, isActive: true), ); if (!mounted) { return; } final seen = {CustomerFilterOption.all.cacheKey}; final options = [CustomerFilterOption.all]; - for (final customer in result.items) { + for (final customer in customers) { final option = CustomerFilterOption.fromCustomer(customer); if (seen.add(option.cacheKey)) { options.add(option); @@ -620,6 +620,17 @@ class _OutboundPageState extends State { style: theme.textTheme.small, ), const SizedBox(width: 12), + ShadButton.ghost( + size: ShadButtonSize.sm, + onPressed: currentPage <= 1 + ? null + : () => _goToPage(1), + child: const Icon( + lucide.LucideIcons.chevronsLeft, + size: 16, + ), + ), + const SizedBox(width: 8), ShadButton.ghost( size: ShadButtonSize.sm, onPressed: currentPage <= 1 @@ -641,6 +652,17 @@ class _OutboundPageState extends State { size: 16, ), ), + const SizedBox(width: 8), + ShadButton.ghost( + size: ShadButtonSize.sm, + onPressed: currentPage >= totalPages + ? null + : () => _goToPage(totalPages), + child: const Icon( + lucide.LucideIcons.chevronsRight, + size: 16, + ), + ), ], ), ], @@ -1172,7 +1194,9 @@ class _OutboundPageState extends State { } void _goToPage(int page) { - final target = page < 1 ? 1 : page; + final totalItems = _result?.total ?? _filteredRecords.length; + final totalPages = _calculateTotalPages(totalItems); + final int target = page < 1 ? 1 : (page > totalPages ? totalPages : page); if (target == _currentPage) { return; } diff --git a/lib/features/inventory/rental/presentation/pages/rental_page.dart b/lib/features/inventory/rental/presentation/pages/rental_page.dart index f657cdf..9accc14 100644 --- a/lib/features/inventory/rental/presentation/pages/rental_page.dart +++ b/lib/features/inventory/rental/presentation/pages/rental_page.dart @@ -567,6 +567,17 @@ class _RentalPageState extends State { style: theme.textTheme.small, ), const SizedBox(width: 12), + ShadButton.ghost( + size: ShadButtonSize.sm, + onPressed: currentPage <= 1 + ? null + : () => _goToPage(1), + child: const Icon( + lucide.LucideIcons.chevronsLeft, + size: 16, + ), + ), + const SizedBox(width: 8), ShadButton.ghost( size: ShadButtonSize.sm, onPressed: currentPage <= 1 @@ -588,6 +599,17 @@ class _RentalPageState extends State { size: 16, ), ), + const SizedBox(width: 8), + ShadButton.ghost( + size: ShadButtonSize.sm, + onPressed: currentPage >= totalPages + ? null + : () => _goToPage(totalPages), + child: const Icon( + lucide.LucideIcons.chevronsRight, + size: 16, + ), + ), ], ), ], @@ -1106,7 +1128,11 @@ class _RentalPageState extends State { } void _goToPage(int page) { - final target = page < 1 ? 1 : page; + final totalItems = _result?.total ?? _filteredRecords.length; + final totalPages = _calculateTotalPages(totalItems); + final int target = page < 1 + ? 1 + : (page > totalPages ? totalPages : page); if (target == _currentPage) { return; } diff --git a/lib/features/inventory/shared/widgets/warehouse_select_field.dart b/lib/features/inventory/shared/widgets/warehouse_select_field.dart index d506c48..bf78f1d 100644 --- a/lib/features/inventory/shared/widgets/warehouse_select_field.dart +++ b/lib/features/inventory/shared/widgets/warehouse_select_field.dart @@ -5,6 +5,7 @@ import 'package:get_it/get_it.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:superport_v2/core/network/failure.dart'; +import 'package:superport_v2/core/common/utils/pagination_utils.dart'; import 'package:superport_v2/features/masters/warehouse/domain/entities/warehouse.dart'; import 'package:superport_v2/features/masters/warehouse/domain/repositories/warehouse_repository.dart'; @@ -120,13 +121,15 @@ class _InventoryWarehouseSelectFieldState _error = null; }); try { - final result = await repository.list( - page: 1, - pageSize: 100, - isActive: true, - includeZipcode: false, + final warehouses = await fetchAllPaginatedItems( + request: (page, pageSize) => repository.list( + page: page, + pageSize: pageSize, + isActive: true, + includeZipcode: false, + ), ); - final options = result.items + final options = warehouses .map(InventoryWarehouseOption.fromWarehouse) .toList(growable: false); setState(() { @@ -296,35 +299,29 @@ class _InventoryWarehouseSelectFieldState } void _setSelection(InventoryWarehouseOption? option, {bool notify = true}) { - if (option != null && - option.id != -1 && - !_initialOptions.any((item) => item.id == option.id)) { - _initialOptions.add(option); + setState(() { + if (option != null && + option.id != -1 && + !_initialOptions.any((item) => item.id == option.id)) { + _initialOptions.add(option); + } + _selected = option; + if (option == null) { + _applyControllerText(''); + } else if (option.id == -1) { + _applyControllerText(widget.allLabel); + } else { + _applyControllerText(_displayLabel(option)); + } + }); + if (!notify) { + return; } - _selected = option; - _isApplyingText = true; - if (option == null) { - _controller.clear(); - if (notify) { - widget.onChanged(null); - } - } else if (option.id == -1) { - _controller - ..text = widget.allLabel - ..selection = TextSelection.collapsed(offset: widget.allLabel.length); - if (notify) { - widget.onChanged(null); - } + if (option == null || option.id == -1) { + widget.onChanged(null); } else { - final label = _displayLabel(option); - _controller - ..text = label - ..selection = TextSelection.collapsed(offset: label.length); - if (notify) { - widget.onChanged(option); - } + widget.onChanged(option); } - _isApplyingText = false; } String _displayLabel(InventoryWarehouseOption option) { @@ -387,6 +384,7 @@ class _InventoryWarehouseSelectFieldState } }, fieldViewBuilder: (context, textController, focusNode, onFieldSubmitted) { + assert(identical(textController, _controller)); final placeholder = widget.placeholder ?? const Text('창고 선택'); return Stack( alignment: Alignment.centerRight, @@ -454,4 +452,11 @@ class _InventoryWarehouseSelectFieldState }, ); } + + void _applyControllerText(String value) { + _isApplyingText = true; + _controller.text = value; + _controller.selection = TextSelection.collapsed(offset: value.length); + _isApplyingText = false; + } } diff --git a/lib/features/masters/customer/presentation/controllers/customer_controller.dart b/lib/features/masters/customer/presentation/controllers/customer_controller.dart index a783416..ecdb060 100644 --- a/lib/features/masters/customer/presentation/controllers/customer_controller.dart +++ b/lib/features/masters/customer/presentation/controllers/customer_controller.dart @@ -44,6 +44,17 @@ class CustomerController 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; + } bool? isPartner; bool? isGeneral; switch (_typeFilter) { @@ -68,7 +79,7 @@ class CustomerController extends ChangeNotifier { }; final response = await _repository.list( - page: page, + page: resolvedPage, pageSize: _pageSize, query: _query.isEmpty ? null : _query, isPartner: isPartner, diff --git a/lib/features/masters/customer/presentation/pages/customer_page.dart b/lib/features/masters/customer/presentation/pages/customer_page.dart index 30590c8..a9e59c9 100644 --- a/lib/features/masters/customer/presentation/pages/customer_page.dart +++ b/lib/features/masters/customer/presentation/pages/customer_page.dart @@ -125,9 +125,10 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> { final error = _controller.errorMessage; if (error != null && error != _lastError && mounted) { _lastError = error; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(error))); + final messenger = ScaffoldMessenger.maybeOf(context); + if (messenger != null) { + messenger.showSnackBar(SnackBar(content: Text(error))); + } _controller.clearError(); } } @@ -174,7 +175,7 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> { final currentPage = result?.page ?? 1; final totalPages = result == null || result.pageSize == 0 ? 1 - : (result.total / result.pageSize).ceil().clamp(1, 9999); + : (result.total / result.pageSize).ceil().clamp(1, 9999) as int; final hasNext = result == null ? false : (result.page * result.pageSize) < result.total; @@ -301,6 +302,14 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> { ), Row( children: [ + ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: _controller.isLoading || currentPage <= 1 + ? null + : () => _goToPage(1), + child: const Text('처음'), + ), + const SizedBox(width: 8), ShadButton.outline( size: ShadButtonSize.sm, onPressed: _controller.isLoading || currentPage <= 1 @@ -316,6 +325,15 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> { : () => _goToPage(currentPage + 1), child: const Text('다음'), ), + const SizedBox(width: 8), + ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: + _controller.isLoading || currentPage >= totalPages + ? null + : () => _goToPage(totalPages), + child: const Text('마지막'), + ), ], ), ], @@ -357,8 +375,18 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> { } void _goToPage(int page) { + final result = _controller.result; + final int totalPages; + if (result == null || result.pageSize == 0) { + totalPages = 1; + } else { + final calculated = (result.total / result.pageSize).ceil(); + totalPages = calculated < 1 ? 1 : calculated; + } if (page < 1) { page = 1; + } else if (page > totalPages) { + page = totalPages; } _updateRoute(page: page); } @@ -647,7 +675,10 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> { isActive: isActiveNotifier.value, note: note.isEmpty ? null : note, ); - final navigator = Navigator.of(context); + final navigator = Navigator.of( + context, + rootNavigator: true, + ); final response = isEdit ? await _controller.update(customerId!, input) : await _controller.create(input); @@ -672,7 +703,8 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> { return ShadButton.ghost( onPressed: isSaving ? null - : () => Navigator.of(context).pop(false), + : () => + Navigator.of(context, rootNavigator: true).pop(false), child: const Text('취소'), ); }, @@ -984,11 +1016,13 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> { description: '"${customer.customerName}" 고객사를 삭제하시겠습니까?', actions: [ ShadButton.ghost( - onPressed: () => Navigator.of(context).pop(false), + onPressed: () => + Navigator.of(context, rootNavigator: true).pop(false), child: const Text('취소'), ), ShadButton( - onPressed: () => Navigator.of(context).pop(true), + onPressed: () => + Navigator.of(context, rootNavigator: true).pop(true), child: const Text('삭제'), ), ], @@ -1012,10 +1046,14 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> { } void _showSnack(String message) { - if (!mounted) return; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(message))); + if (!mounted) { + return; + } + final messenger = ScaffoldMessenger.maybeOf(context); + if (messenger == null) { + return; + } + messenger.showSnackBar(SnackBar(content: Text(message))); } String _formatDateTime(DateTime? value) { diff --git a/lib/features/masters/group/presentation/controllers/group_controller.dart b/lib/features/masters/group/presentation/controllers/group_controller.dart index 5c0988f..9460fec 100644 --- a/lib/features/masters/group/presentation/controllers/group_controller.dart +++ b/lib/features/masters/group/presentation/controllers/group_controller.dart @@ -54,6 +54,17 @@ class GroupController 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 isDefault = switch (_defaultFilter) { GroupDefaultFilter.all => null, GroupDefaultFilter.defaultOnly => true, @@ -65,7 +76,7 @@ class GroupController extends ChangeNotifier { GroupStatusFilter.inactiveOnly => false, }; final response = await _repository.list( - page: page, + page: resolvedPage, pageSize: _result?.pageSize ?? 20, query: _query.isEmpty ? null : _query, isDefault: isDefault, diff --git a/lib/features/masters/group/presentation/pages/group_page.dart b/lib/features/masters/group/presentation/pages/group_page.dart index 51f13de..ea6d741 100644 --- a/lib/features/masters/group/presentation/pages/group_page.dart +++ b/lib/features/masters/group/presentation/pages/group_page.dart @@ -100,9 +100,10 @@ class _GroupEnabledPageState extends State<_GroupEnabledPage> { final error = _controller.errorMessage; if (error != null && error != _lastError && mounted) { _lastError = error; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(error))); + final messenger = ScaffoldMessenger.maybeOf(context); + if (messenger != null) { + messenger.showSnackBar(SnackBar(content: Text(error))); + } _controller.clearError(); } } @@ -129,7 +130,7 @@ class _GroupEnabledPageState extends State<_GroupEnabledPage> { final currentPage = result?.page ?? 1; final totalPages = result == null || result.pageSize == 0 ? 1 - : (result.total / result.pageSize).ceil().clamp(1, 9999); + : (result.total / result.pageSize).ceil().clamp(1, 9999) as int; final hasNext = result == null ? false : (result.page * result.pageSize) < result.total; @@ -253,6 +254,14 @@ class _GroupEnabledPageState extends State<_GroupEnabledPage> { ), Row( children: [ + ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: _controller.isLoading || currentPage <= 1 + ? null + : () => _controller.fetch(page: 1), + child: const Text('처음'), + ), + const SizedBox(width: 8), ShadButton.outline( size: ShadButtonSize.sm, onPressed: _controller.isLoading || currentPage <= 1 @@ -268,6 +277,15 @@ class _GroupEnabledPageState extends State<_GroupEnabledPage> { : () => _controller.fetch(page: currentPage + 1), child: const Text('다음'), ), + const SizedBox(width: 8), + ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: + _controller.isLoading || currentPage >= totalPages + ? null + : () => _controller.fetch(page: totalPages), + child: const Text('마지막'), + ), ], ), ], @@ -367,7 +385,10 @@ class _GroupEnabledPageState extends State<_GroupEnabledPage> { return ShadButton.ghost( onPressed: isSaving ? null - : () => Navigator.of(dialogContext).pop(), + : () => Navigator.of( + dialogContext, + rootNavigator: true, + ).pop(), child: const Text('취소'), ); }, @@ -397,7 +418,10 @@ class _GroupEnabledPageState extends State<_GroupEnabledPage> { isActive: isActiveNotifier.value, note: note.isEmpty ? null : note, ); - final navigator = Navigator.of(dialogContext); + final navigator = Navigator.of( + dialogContext, + rootNavigator: true, + ); final response = isEdit ? await _controller.update(groupId!, input) : await _controller.create(input); @@ -553,11 +577,17 @@ class _GroupEnabledPageState extends State<_GroupEnabledPage> { content: Text('"${group.groupName}" 그룹을 삭제하시겠습니까?'), actions: [ TextButton( - onPressed: () => Navigator.of(dialogContext).pop(false), + onPressed: () => Navigator.of( + dialogContext, + rootNavigator: true, + ).pop(false), child: const Text('취소'), ), TextButton( - onPressed: () => Navigator.of(dialogContext).pop(true), + onPressed: () => Navigator.of( + dialogContext, + rootNavigator: true, + ).pop(true), child: const Text('삭제'), ), ], @@ -582,10 +612,14 @@ class _GroupEnabledPageState extends State<_GroupEnabledPage> { } void _showSnack(String message) { - if (!mounted) return; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(message))); + if (!mounted) { + return; + } + final messenger = ScaffoldMessenger.maybeOf(context); + if (messenger == null) { + return; + } + messenger.showSnackBar(SnackBar(content: Text(message))); } String _formatDateTime(DateTime? value) { diff --git a/lib/features/masters/group_permission/presentation/controllers/group_permission_controller.dart b/lib/features/masters/group_permission/presentation/controllers/group_permission_controller.dart index 36c9555..59617fa 100644 --- a/lib/features/masters/group_permission/presentation/controllers/group_permission_controller.dart +++ b/lib/features/masters/group_permission/presentation/controllers/group_permission_controller.dart @@ -1,6 +1,7 @@ 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/core/common/models/paginated_result.dart'; +import 'package:superport_v2/core/common/utils/pagination_utils.dart'; import '../../../../../core/permissions/permission_manager.dart'; import '../../application/permission_synchronizer.dart'; @@ -65,10 +66,13 @@ class GroupPermissionController extends ChangeNotifier { _isLoadingGroups = true; notifyListeners(); try { - final response = await _groupRepository.list(page: 1, pageSize: 200); + final groups = await fetchAllPaginatedItems( + request: (page, pageSize) => + _groupRepository.list(page: page, pageSize: pageSize), + ); _groups ..clear() - ..addAll(response.items); + ..addAll(groups); } catch (error) { final failure = Failure.from(error); _errorMessage = failure.describe(); @@ -83,14 +87,16 @@ class GroupPermissionController extends ChangeNotifier { _isLoadingMenus = true; notifyListeners(); try { - final response = await _menuRepository.list( - page: 1, - pageSize: 200, - includeDeleted: false, + final menus = await fetchAllPaginatedItems( + request: (page, pageSize) => _menuRepository.list( + page: page, + pageSize: pageSize, + includeDeleted: false, + ), ); _menus ..clear() - ..addAll(response.items); + ..addAll(menus); } catch (error) { final failure = Failure.from(error); _errorMessage = failure.describe(); @@ -106,13 +112,24 @@ class GroupPermissionController 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 isActive = switch (_statusFilter) { GroupPermissionStatusFilter.all => null, GroupPermissionStatusFilter.activeOnly => true, GroupPermissionStatusFilter.inactiveOnly => false, }; final response = await _permissionRepository.list( - page: page, + page: resolvedPage, pageSize: _result?.pageSize ?? 20, groupId: _groupFilter, menuId: _menuFilter, diff --git a/lib/features/masters/group_permission/presentation/pages/group_permission_page.dart b/lib/features/masters/group_permission/presentation/pages/group_permission_page.dart index 8b23319..8069785 100644 --- a/lib/features/masters/group_permission/presentation/pages/group_permission_page.dart +++ b/lib/features/masters/group_permission/presentation/pages/group_permission_page.dart @@ -151,9 +151,10 @@ class _GroupPermissionEnabledPageState final error = _controller.errorMessage; if (error != null && error != _lastError && mounted) { _lastError = error; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(error))); + final messenger = ScaffoldMessenger.maybeOf(context); + if (messenger != null) { + messenger.showSnackBar(SnackBar(content: Text(error))); + } _controller.clearError(); } } @@ -180,7 +181,7 @@ class _GroupPermissionEnabledPageState final currentPage = result?.page ?? 1; final totalPages = result == null || result.pageSize == 0 ? 1 - : (result.total / result.pageSize).ceil().clamp(1, 9999); + : (result.total / result.pageSize).ceil().clamp(1, 9999) as int; final hasNext = result == null ? false : (result.page * result.pageSize) < result.total; @@ -366,6 +367,14 @@ class _GroupPermissionEnabledPageState ), Row( children: [ + ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: _controller.isLoading || currentPage <= 1 + ? null + : () => _controller.fetch(page: 1), + child: const Text('처음'), + ), + const SizedBox(width: 8), ShadButton.outline( size: ShadButtonSize.sm, onPressed: _controller.isLoading || currentPage <= 1 @@ -381,6 +390,15 @@ class _GroupPermissionEnabledPageState : () => _controller.fetch(page: currentPage + 1), child: const Text('다음'), ), + const SizedBox(width: 8), + ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: + _controller.isLoading || currentPage >= totalPages + ? null + : () => _controller.fetch(page: totalPages), + child: const Text('마지막'), + ), ], ), ], @@ -481,7 +499,10 @@ class _GroupPermissionEnabledPageState return ShadButton.ghost( onPressed: isSaving ? null - : () => Navigator.of(dialogContext).pop(false), + : () => Navigator.of( + dialogContext, + rootNavigator: true, + ).pop(false), child: const Text('취소'), ); }, @@ -513,7 +534,10 @@ class _GroupPermissionEnabledPageState isActive: activeNotifier.value, note: trimmedNote.isEmpty ? null : trimmedNote, ); - final navigator = Navigator.of(dialogContext); + final navigator = Navigator.of( + dialogContext, + rootNavigator: true, + ); final response = isEdit ? await _controller.update(permissionId!, input) : await _controller.create(input); @@ -758,7 +782,8 @@ class _GroupPermissionEnabledPageState secondaryAction: Builder( builder: (dialogContext) { return ShadButton.ghost( - onPressed: () => Navigator.of(dialogContext).pop(false), + onPressed: () => + Navigator.of(dialogContext, rootNavigator: true).pop(false), child: const Text('취소'), ); }, @@ -766,7 +791,8 @@ class _GroupPermissionEnabledPageState primaryAction: Builder( builder: (dialogContext) { return ShadButton.destructive( - onPressed: () => Navigator.of(dialogContext).pop(true), + onPressed: () => + Navigator.of(dialogContext, rootNavigator: true).pop(true), child: const Text('삭제'), ); }, @@ -791,10 +817,14 @@ class _GroupPermissionEnabledPageState } void _showSnack(String message) { - if (!mounted) return; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(message))); + if (!mounted) { + return; + } + final messenger = ScaffoldMessenger.maybeOf(context); + if (messenger == null) { + return; + } + messenger.showSnackBar(SnackBar(content: Text(message))); } String _formatDateTime(DateTime? value) { diff --git a/lib/features/masters/menu/presentation/controllers/menu_controller.dart b/lib/features/masters/menu/presentation/controllers/menu_controller.dart index 7f211ac..a7958a1 100644 --- a/lib/features/masters/menu/presentation/controllers/menu_controller.dart +++ b/lib/features/masters/menu/presentation/controllers/menu_controller.dart @@ -1,6 +1,7 @@ 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/core/common/models/paginated_result.dart'; +import 'package:superport_v2/core/common/utils/pagination_utils.dart'; import '../../domain/entities/menu.dart'; import '../../domain/repositories/menu_repository.dart'; @@ -45,12 +46,14 @@ class MenuController extends ChangeNotifier { _isLoadingParents = true; notifyListeners(); try { - final response = await _repository.list( - page: 1, - pageSize: 200, - includeDeleted: false, + final parents = await fetchAllPaginatedItems( + request: (page, pageSize) => _repository.list( + page: page, + pageSize: pageSize, + includeDeleted: false, + ), ); - _parents = response.items; + _parents = parents; } catch (error) { final failure = Failure.from(error); _errorMessage = failure.describe(); @@ -66,13 +69,24 @@ class MenuController 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 isActive = switch (_statusFilter) { MenuStatusFilter.all => null, MenuStatusFilter.activeOnly => true, MenuStatusFilter.inactiveOnly => false, }; final response = await _repository.list( - page: page, + page: resolvedPage, pageSize: _result?.pageSize ?? 20, query: _query.isEmpty ? null : _query, parentId: _parentFilter, diff --git a/lib/features/masters/menu/presentation/pages/menu_page.dart b/lib/features/masters/menu/presentation/pages/menu_page.dart index a162738..66060ba 100644 --- a/lib/features/masters/menu/presentation/pages/menu_page.dart +++ b/lib/features/masters/menu/presentation/pages/menu_page.dart @@ -121,9 +121,10 @@ class _MenuEnabledPageState extends State<_MenuEnabledPage> { final error = _controller.errorMessage; if (error != null && error != _lastError && mounted) { _lastError = error; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(error))); + final messenger = ScaffoldMessenger.maybeOf(context); + if (messenger != null) { + messenger.showSnackBar(SnackBar(content: Text(error))); + } _controller.clearError(); } } @@ -150,7 +151,7 @@ class _MenuEnabledPageState extends State<_MenuEnabledPage> { final currentPage = result?.page ?? 1; final totalPages = result == null || result.pageSize == 0 ? 1 - : (result.total / result.pageSize).ceil().clamp(1, 9999); + : (result.total / result.pageSize).ceil().clamp(1, 9999) as int; final hasNext = result == null ? false : (result.page * result.pageSize) < result.total; @@ -309,6 +310,14 @@ class _MenuEnabledPageState extends State<_MenuEnabledPage> { ), Row( children: [ + ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: _controller.isLoading || currentPage <= 1 + ? null + : () => _controller.fetch(page: 1), + child: const Text('처음'), + ), + const SizedBox(width: 8), ShadButton.outline( size: ShadButtonSize.sm, onPressed: _controller.isLoading || currentPage <= 1 @@ -324,6 +333,15 @@ class _MenuEnabledPageState extends State<_MenuEnabledPage> { : () => _controller.fetch(page: currentPage + 1), child: const Text('다음'), ), + const SizedBox(width: 8), + ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: + _controller.isLoading || currentPage >= totalPages + ? null + : () => _controller.fetch(page: totalPages), + child: const Text('마지막'), + ), ], ), ], @@ -417,7 +435,10 @@ class _MenuEnabledPageState extends State<_MenuEnabledPage> { return ShadButton.ghost( onPressed: isSaving ? null - : () => Navigator.of(dialogContext).pop(false), + : () => Navigator.of( + dialogContext, + rootNavigator: true, + ).pop(false), child: const Text('취소'), ); }, @@ -464,7 +485,10 @@ class _MenuEnabledPageState extends State<_MenuEnabledPage> { isActive: isActiveNotifier.value, note: note.isEmpty ? null : note, ); - final navigator = Navigator.of(dialogContext); + final navigator = Navigator.of( + dialogContext, + rootNavigator: true, + ); final response = isEdit ? await _controller.update(menuId!, input) : await _controller.create(input); @@ -706,7 +730,10 @@ class _MenuEnabledPageState extends State<_MenuEnabledPage> { secondaryAction: Builder( builder: (dialogContext) { return ShadButton.ghost( - onPressed: () => Navigator.of(dialogContext).pop(false), + onPressed: () => Navigator.of( + dialogContext, + rootNavigator: true, + ).pop(false), child: const Text('취소'), ); }, @@ -714,7 +741,10 @@ class _MenuEnabledPageState extends State<_MenuEnabledPage> { primaryAction: Builder( builder: (dialogContext) { return ShadButton.destructive( - onPressed: () => Navigator.of(dialogContext).pop(true), + onPressed: () => Navigator.of( + dialogContext, + rootNavigator: true, + ).pop(true), child: const Text('삭제'), ); }, @@ -739,10 +769,14 @@ class _MenuEnabledPageState extends State<_MenuEnabledPage> { } void _showSnack(String message) { - if (!mounted) return; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(message))); + if (!mounted) { + return; + } + final messenger = ScaffoldMessenger.maybeOf(context); + if (messenger == null) { + return; + } + messenger.showSnackBar(SnackBar(content: Text(message))); } String _formatDateTime(DateTime? value) { diff --git a/lib/features/masters/product/presentation/controllers/product_controller.dart b/lib/features/masters/product/presentation/controllers/product_controller.dart index 25ea343..65b52b4 100644 --- a/lib/features/masters/product/presentation/controllers/product_controller.dart +++ b/lib/features/masters/product/presentation/controllers/product_controller.dart @@ -1,6 +1,7 @@ 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/core/common/models/paginated_result.dart'; +import 'package:superport_v2/core/common/utils/pagination_utils.dart'; import '../../../vendor/domain/entities/vendor.dart'; import '../../../vendor/domain/repositories/vendor_repository.dart'; @@ -61,13 +62,24 @@ class ProductController 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 isActive = switch (_statusFilter) { ProductStatusFilter.all => null, ProductStatusFilter.activeOnly => true, ProductStatusFilter.inactiveOnly => false, }; final response = await _productRepository.list( - page: page, + page: resolvedPage, pageSize: _pageSize, query: _query.isEmpty ? null : _query, vendorId: _vendorFilter, @@ -92,10 +104,16 @@ class ProductController extends ChangeNotifier { _isLoadingLookups = true; notifyListeners(); try { - final vendorResult = await _vendorRepository.list(page: 1, pageSize: 100); - final uomResult = await _uomRepository.list(page: 1, pageSize: 100); - _vendorOptions = vendorResult.items; - _uomOptions = uomResult.items; + final vendors = await fetchAllPaginatedItems( + request: (page, pageSize) => + _vendorRepository.list(page: page, pageSize: pageSize), + ); + final uoms = await fetchAllPaginatedItems( + request: (page, pageSize) => + _uomRepository.list(page: page, pageSize: pageSize), + ); + _vendorOptions = vendors; + _uomOptions = uoms; } catch (error) { final failure = Failure.from(error); _errorMessage = failure.describe(); diff --git a/lib/features/masters/product/presentation/pages/product_page.dart b/lib/features/masters/product/presentation/pages/product_page.dart index 51074b2..3335bf9 100644 --- a/lib/features/masters/product/presentation/pages/product_page.dart +++ b/lib/features/masters/product/presentation/pages/product_page.dart @@ -125,9 +125,10 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> { final error = _controller.errorMessage; if (error != null && error != _lastError && mounted) { _lastError = error; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(error))); + final messenger = ScaffoldMessenger.maybeOf(context); + if (messenger != null) { + messenger.showSnackBar(SnackBar(content: Text(error))); + } _controller.clearError(); } } @@ -154,7 +155,7 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> { final currentPage = result?.page ?? 1; final totalPages = result == null || result.pageSize == 0 ? 1 - : (result.total / result.pageSize).ceil().clamp(1, 9999); + : (result.total / result.pageSize).ceil().clamp(1, 9999) as int; final hasNext = result == null ? false : (result.page * result.pageSize) < result.total; @@ -316,6 +317,14 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> { ), Row( children: [ + ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: _controller.isLoading || currentPage <= 1 + ? null + : () => _goToPage(1), + child: const Text('처음'), + ), + const SizedBox(width: 8), ShadButton.outline( size: ShadButtonSize.sm, onPressed: _controller.isLoading || currentPage <= 1 @@ -331,6 +340,15 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> { : () => _goToPage(currentPage + 1), child: const Text('다음'), ), + const SizedBox(width: 8), + ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: + _controller.isLoading || currentPage >= totalPages + ? null + : () => _goToPage(totalPages), + child: const Text('마지막'), + ), ], ), ], @@ -428,8 +446,18 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> { } void _goToPage(int page) { + final result = _controller.result; + final int totalPages; + if (result == null || result.pageSize == 0) { + totalPages = 1; + } else { + final calculated = (result.total / result.pageSize).ceil(); + totalPages = calculated < 1 ? 1 : calculated; + } if (page < 1) { page = 1; + } else if (page > totalPages) { + page = totalPages; } _updateRoute(page: page); } @@ -579,7 +607,10 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> { isActive: isActiveNotifier.value, note: note.isEmpty ? null : note, ); - final navigator = Navigator.of(context); + final navigator = Navigator.of( + context, + rootNavigator: true, + ); final response = isEdit ? await _controller.update(productId!, input) : await _controller.create(input); @@ -602,7 +633,8 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> { return ShadButton.ghost( onPressed: isSaving ? null - : () => Navigator.of(context).pop(false), + : () => + Navigator.of(context, rootNavigator: true).pop(false), child: const Text('취소'), ); }, @@ -835,11 +867,13 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> { title: '제품 삭제', description: '"${product.productName}" 제품을 삭제하시겠습니까?', primaryAction: ShadButton.destructive( - onPressed: () => Navigator.of(context).pop(true), + onPressed: () => + Navigator.of(context, rootNavigator: true).pop(true), child: const Text('삭제'), ), secondaryAction: ShadButton.ghost( - onPressed: () => Navigator.of(context).pop(false), + onPressed: () => + Navigator.of(context, rootNavigator: true).pop(false), child: const Text('취소'), ), ), @@ -862,10 +896,14 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> { } void _showSnack(String message) { - if (!mounted) return; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(message))); + if (!mounted) { + return; + } + final messenger = ScaffoldMessenger.maybeOf(context); + if (messenger == null) { + return; + } + messenger.showSnackBar(SnackBar(content: Text(message))); } String _formatDateTime(DateTime? value) { diff --git a/lib/features/masters/product/presentation/widgets/uom_autocomplete_field.dart b/lib/features/masters/product/presentation/widgets/uom_autocomplete_field.dart index a4beb2a..b80fb55 100644 --- a/lib/features/masters/product/presentation/widgets/uom_autocomplete_field.dart +++ b/lib/features/masters/product/presentation/widgets/uom_autocomplete_field.dart @@ -201,25 +201,16 @@ class _UomAutocompleteFieldState extends State { } void _setSelection(Uom? uom, {bool notify = true}) { - _selected = uom; - if (uom != null && !_baseOptions.any((item) => item.id == uom.id)) { - _baseOptions.add(uom); - } - _isApplyingText = true; - if (uom == null) { - _controller.clear(); - if (notify) { - widget.onSelected(null); - } - } else { - _controller - ..text = uom.uomName - ..selection = TextSelection.collapsed(offset: uom.uomName.length); - if (notify) { - widget.onSelected(uom.id); + setState(() { + _selected = uom; + if (uom != null && !_baseOptions.any((item) => item.id == uom.id)) { + _baseOptions.add(uom); } + _applyControllerText(uom?.uomName ?? ''); + }); + if (notify) { + widget.onSelected(uom?.id); } - _isApplyingText = false; } @override @@ -247,6 +238,7 @@ class _UomAutocompleteFieldState extends State { displayStringForOption: (option) => option.uomName, onSelected: (uom) => _setSelection(uom), fieldViewBuilder: (context, textController, focusNode, onFieldSubmitted) { + assert(identical(textController, _controller)); final placeholder = widget.placeholder ?? const Text('단위를 선택하세요'); return Stack( alignment: Alignment.centerRight, @@ -312,4 +304,11 @@ class _UomAutocompleteFieldState extends State { }, ); } + + void _applyControllerText(String value) { + _isApplyingText = true; + _controller.text = value; + _controller.selection = TextSelection.collapsed(offset: value.length); + _isApplyingText = false; + } } diff --git a/lib/features/masters/product/presentation/widgets/vendor_autocomplete_field.dart b/lib/features/masters/product/presentation/widgets/vendor_autocomplete_field.dart index 987b5da..f4e8db4 100644 --- a/lib/features/masters/product/presentation/widgets/vendor_autocomplete_field.dart +++ b/lib/features/masters/product/presentation/widgets/vendor_autocomplete_field.dart @@ -209,26 +209,20 @@ class _VendorAutocompleteFieldState extends State { } void _setSelection(Vendor? vendor, {bool notify = true}) { - _selected = vendor; - if (vendor != null && !_baseOptions.any((item) => item.id == vendor.id)) { - _baseOptions.add(vendor); - } - _isApplyingText = true; - if (vendor == null) { - _controller.clear(); - if (notify) { - widget.onSelected(null); + setState(() { + _selected = vendor; + if (vendor != null && !_baseOptions.any((item) => item.id == vendor.id)) { + _baseOptions.add(vendor); } - } else { - final label = _displayLabel(vendor); - _controller - ..text = label - ..selection = TextSelection.collapsed(offset: label.length); - if (notify) { - widget.onSelected(vendor.id); + if (vendor == null) { + _applyControllerText(''); + } else { + _applyControllerText(_displayLabel(vendor)); } + }); + if (notify) { + widget.onSelected(vendor?.id); } - _isApplyingText = false; } String _displayLabel(Vendor vendor) { @@ -261,6 +255,7 @@ class _VendorAutocompleteFieldState extends State { displayStringForOption: _displayLabel, onSelected: (vendor) => _setSelection(vendor), fieldViewBuilder: (context, textController, focusNode, onFieldSubmitted) { + assert(identical(textController, _controller)); final placeholder = widget.placeholder ?? const Text('제조사를 선택하세요'); return Stack( alignment: Alignment.centerRight, @@ -326,4 +321,11 @@ class _VendorAutocompleteFieldState extends State { }, ); } + + void _applyControllerText(String value) { + _isApplyingText = true; + _controller.text = value; + _controller.selection = TextSelection.collapsed(offset: value.length); + _isApplyingText = false; + } } diff --git a/lib/features/masters/user/presentation/controllers/user_controller.dart b/lib/features/masters/user/presentation/controllers/user_controller.dart index 4f7e362..bdf70d3 100644 --- a/lib/features/masters/user/presentation/controllers/user_controller.dart +++ b/lib/features/masters/user/presentation/controllers/user_controller.dart @@ -1,6 +1,7 @@ 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/core/common/models/paginated_result.dart'; +import 'package:superport_v2/core/common/utils/pagination_utils.dart'; import '../../../../../core/permissions/permission_manager.dart'; import '../../../group/domain/entities/group.dart'; @@ -55,8 +56,11 @@ class UserController extends ChangeNotifier { _isLoadingGroups = true; notifyListeners(); try { - final response = await _groupRepository.list(page: 1, pageSize: 100); - _groups = response.items; + final groups = await fetchAllPaginatedItems( + request: (page, pageSize) => + _groupRepository.list(page: page, pageSize: pageSize), + ); + _groups = groups; } catch (error) { final failure = Failure.from(error); _errorMessage = failure.describe(); @@ -72,13 +76,24 @@ class UserController 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 isActive = switch (_statusFilter) { UserStatusFilter.all => null, UserStatusFilter.activeOnly => true, UserStatusFilter.inactiveOnly => false, }; final response = await _userRepository.list( - page: page, + page: resolvedPage, pageSize: _result?.pageSize ?? 20, query: _query.isEmpty ? null : _query, groupId: _groupFilter, diff --git a/lib/features/masters/user/presentation/pages/user_page.dart b/lib/features/masters/user/presentation/pages/user_page.dart index 00e14d8..6abe7f8 100644 --- a/lib/features/masters/user/presentation/pages/user_page.dart +++ b/lib/features/masters/user/presentation/pages/user_page.dart @@ -133,9 +133,10 @@ class _UserEnabledPageState extends State<_UserEnabledPage> { final error = _controller.errorMessage; if (error != null && error != _lastError && mounted) { _lastError = error; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(error))); + final messenger = ScaffoldMessenger.maybeOf(context); + if (messenger != null) { + messenger.showSnackBar(SnackBar(content: Text(error))); + } _controller.clearError(); } } @@ -162,7 +163,7 @@ class _UserEnabledPageState extends State<_UserEnabledPage> { final currentPage = result?.page ?? 1; final totalPages = result == null || result.pageSize == 0 ? 1 - : (result.total / result.pageSize).ceil().clamp(1, 9999); + : (result.total / result.pageSize).ceil().clamp(1, 9999) as int; final hasNext = result == null ? false : (result.page * result.pageSize) < result.total; @@ -293,6 +294,14 @@ class _UserEnabledPageState extends State<_UserEnabledPage> { ), Row( children: [ + ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: _controller.isLoading || currentPage <= 1 + ? null + : () => _controller.fetch(page: 1), + child: const Text('처음'), + ), + const SizedBox(width: 8), ShadButton.outline( size: ShadButtonSize.sm, onPressed: _controller.isLoading || currentPage <= 1 @@ -308,6 +317,15 @@ class _UserEnabledPageState extends State<_UserEnabledPage> { : () => _controller.fetch(page: currentPage + 1), child: const Text('다음'), ), + const SizedBox(width: 8), + ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: + _controller.isLoading || currentPage >= totalPages + ? null + : () => _controller.fetch(page: totalPages), + child: const Text('마지막'), + ), ], ), ], @@ -423,7 +441,10 @@ class _UserEnabledPageState extends State<_UserEnabledPage> { } saving.value = true; - final navigator = Navigator.of(context); + final navigator = Navigator.of( + context, + rootNavigator: true, + ); final input = UserInput( employeeNo: code, employeeName: name, @@ -454,7 +475,10 @@ class _UserEnabledPageState extends State<_UserEnabledPage> { secondaryAction: ValueListenableBuilder( valueListenable: saving, builder: (context, isSaving, _) { - final navigator = Navigator.of(context); + final navigator = Navigator.of( + context, + rootNavigator: true, + ); return ShadButton.ghost( onPressed: isSaving ? null : () => navigator.pop(false), child: const Text('취소'), @@ -666,11 +690,13 @@ class _UserEnabledPageState extends State<_UserEnabledPage> { description: '"${user.employeeName}" 사용자를 삭제하시겠습니까?', actions: [ ShadButton.ghost( - onPressed: () => Navigator.of(context).pop(false), + onPressed: () => + Navigator.of(context, rootNavigator: true).pop(false), child: const Text('취소'), ), ShadButton( - onPressed: () => Navigator.of(context).pop(true), + onPressed: () => + Navigator.of(context, rootNavigator: true).pop(true), child: const Text('삭제'), ), ], @@ -694,10 +720,14 @@ class _UserEnabledPageState extends State<_UserEnabledPage> { } void _showSnack(String message) { - if (!mounted) return; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(message))); + if (!mounted) { + return; + } + final messenger = ScaffoldMessenger.maybeOf(context); + if (messenger == null) { + return; + } + messenger.showSnackBar(SnackBar(content: Text(message))); } List _buildAuditInfo(UserAccount user, ShadThemeData theme) { diff --git a/lib/features/masters/vendor/presentation/controllers/vendor_controller.dart b/lib/features/masters/vendor/presentation/controllers/vendor_controller.dart index e25251a..2cb7526 100644 --- a/lib/features/masters/vendor/presentation/controllers/vendor_controller.dart +++ b/lib/features/masters/vendor/presentation/controllers/vendor_controller.dart @@ -41,13 +41,24 @@ class VendorController 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 isActive = switch (_statusFilter) { VendorStatusFilter.all => null, VendorStatusFilter.activeOnly => true, VendorStatusFilter.inactiveOnly => false, }; final response = await _repository.list( - page: page, + page: resolvedPage, pageSize: _pageSize, query: _query.isEmpty ? null : _query, isActive: isActive, diff --git a/lib/features/masters/vendor/presentation/pages/vendor_page.dart b/lib/features/masters/vendor/presentation/pages/vendor_page.dart index 227866e..897ddbe 100644 --- a/lib/features/masters/vendor/presentation/pages/vendor_page.dart +++ b/lib/features/masters/vendor/presentation/pages/vendor_page.dart @@ -85,7 +85,7 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> { final FocusNode _searchFocusNode = FocusNode(); final DateFormat _dateFormat = DateFormat('yyyy-MM-dd HH:mm'); String? _lastError; - bool _routeApplied = false; + String? _lastRouteSignature; @override void initState() { @@ -97,9 +97,14 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> { @override void didChangeDependencies() { super.didChangeDependencies(); - if (!_routeApplied) { - _routeApplied = true; - _applyRouteParameters(); + _syncWithRoute(widget.routeUri); + } + + @override + void didUpdateWidget(covariant _VendorEnabledPage oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.routeUri != widget.routeUri) { + _syncWithRoute(widget.routeUri); } } @@ -143,7 +148,7 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> { final currentPage = result?.page ?? 1; final totalPages = result == null || result.pageSize == 0 ? 1 - : (result.total / result.pageSize).ceil().clamp(1, 9999); + : (result.total / result.pageSize).ceil().clamp(1, 9999) as int; final hasNext = result == null ? false : (result.page * result.pageSize) < result.total; @@ -244,6 +249,14 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> { ), Row( children: [ + ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: _controller.isLoading || currentPage <= 1 + ? null + : () => _goToPage(1), + child: const Text('처음'), + ), + const SizedBox(width: 8), ShadButton.outline( size: ShadButtonSize.sm, onPressed: _controller.isLoading || currentPage <= 1 @@ -259,6 +272,14 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> { : () => _goToPage(currentPage + 1), child: const Text('다음'), ), + const SizedBox(width: 8), + ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: _controller.isLoading || currentPage >= totalPages + ? null + : () => _goToPage(totalPages), + child: const Text('마지막'), + ), ], ), ], @@ -308,8 +329,17 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> { } } - void _applyRouteParameters() { - final params = widget.routeUri.queryParameters; + void _syncWithRoute(Uri routeUri) { + final signature = routeUri.toString(); + if (_lastRouteSignature == signature) { + return; + } + _lastRouteSignature = signature; + _applyRouteParameters(routeUri); + } + + void _applyRouteParameters(Uri routeUri) { + final params = routeUri.queryParameters; final query = params['q'] ?? ''; final status = _statusFromParam(params['status']); final pageSizeParam = int.tryParse(params['page_size'] ?? ''); @@ -327,8 +357,14 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> { } void _goToPage(int page) { + final result = _controller.result; + final totalPages = result == null || result.pageSize == 0 + ? 1 + : (result.total / result.pageSize).ceil().clamp(1, 9999) as int; if (page < 1) { page = 1; + } else if (page > totalPages) { + page = totalPages; } _updateRoute(page: page); } @@ -448,7 +484,10 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> { isActive: isActiveNotifier.value, note: note.isEmpty ? null : note, ); - final navigator = Navigator.of(context); + final navigator = Navigator.of( + context, + rootNavigator: true, + ); final response = isEdit ? await _controller.update(vendorId!, input) : await _controller.create(input); @@ -471,7 +510,8 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> { return ShadButton.ghost( onPressed: isSaving ? null - : () => Navigator.of(context).pop(false), + : () => + Navigator.of(context, rootNavigator: true).pop(false), child: const Text('취소'), ); }, @@ -613,11 +653,13 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> { description: '"${vendor.vendorName}" 벤더를 삭제하시겠습니까?', actions: [ ShadButton.ghost( - onPressed: () => Navigator.of(context).pop(false), + onPressed: () => + Navigator.of(context, rootNavigator: true).pop(false), child: const Text('취소'), ), ShadButton( - onPressed: () => Navigator.of(context).pop(true), + onPressed: () => + Navigator.of(context, rootNavigator: true).pop(true), child: const Text('삭제'), ), ], @@ -641,10 +683,15 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> { } void _showSnack(String message) { - if (!mounted) return; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(message))); + if (!mounted) { + return; + } + final messenger = ScaffoldMessenger.maybeOf(context); + if (messenger == null) { + // 스캐폴드 메신저가 없는 경우에는 중단하여 런타임 오류를 방지한다. + return; + } + messenger.showSnackBar(SnackBar(content: Text(message))); } String _formatDateTime(DateTime? value) { diff --git a/lib/features/masters/warehouse/presentation/controllers/warehouse_controller.dart b/lib/features/masters/warehouse/presentation/controllers/warehouse_controller.dart index b75484a..80cb39b 100644 --- a/lib/features/masters/warehouse/presentation/controllers/warehouse_controller.dart +++ b/lib/features/masters/warehouse/presentation/controllers/warehouse_controller.dart @@ -39,13 +39,24 @@ class WarehouseController 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 isActive = switch (_statusFilter) { WarehouseStatusFilter.all => null, WarehouseStatusFilter.activeOnly => true, WarehouseStatusFilter.inactiveOnly => false, }; final response = await _repository.list( - page: page, + page: resolvedPage, pageSize: _pageSize, query: _query.isEmpty ? null : _query, isActive: isActive, diff --git a/lib/features/masters/warehouse/presentation/pages/warehouse_page.dart b/lib/features/masters/warehouse/presentation/pages/warehouse_page.dart index 297b59d..5bf6b1a 100644 --- a/lib/features/masters/warehouse/presentation/pages/warehouse_page.dart +++ b/lib/features/masters/warehouse/presentation/pages/warehouse_page.dart @@ -122,9 +122,10 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> { final error = _controller.errorMessage; if (error != null && error != _lastError && mounted) { _lastError = error; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(error))); + final messenger = ScaffoldMessenger.maybeOf(context); + if (messenger != null) { + messenger.showSnackBar(SnackBar(content: Text(error))); + } _controller.clearError(); } } @@ -151,7 +152,7 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> { final currentPage = result?.page ?? 1; final totalPages = result == null || result.pageSize == 0 ? 1 - : (result.total / result.pageSize).ceil().clamp(1, 9999); + : (result.total / result.pageSize).ceil().clamp(1, 9999) as int; final hasNext = result == null ? false : (result.page * result.pageSize) < result.total; @@ -255,6 +256,14 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> { ), Row( children: [ + ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: _controller.isLoading || currentPage <= 1 + ? null + : () => _goToPage(1), + child: const Text('처음'), + ), + const SizedBox(width: 8), ShadButton.outline( size: ShadButtonSize.sm, onPressed: _controller.isLoading || currentPage <= 1 @@ -270,6 +279,15 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> { : () => _goToPage(currentPage + 1), child: const Text('다음'), ), + const SizedBox(width: 8), + ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: + _controller.isLoading || currentPage >= totalPages + ? null + : () => _goToPage(totalPages), + child: const Text('마지막'), + ), ], ), ], @@ -341,8 +359,18 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> { } void _goToPage(int page) { + final result = _controller.result; + final int totalPages; + if (result == null || result.pageSize == 0) { + totalPages = 1; + } else { + final calculated = (result.total / result.pageSize).ceil(); + totalPages = calculated < 1 ? 1 : calculated; + } if (page < 1) { page = 1; + } else if (page > totalPages) { + page = totalPages; } _updateRoute(page: page); } @@ -522,7 +550,10 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> { isActive: isActiveNotifier.value, note: note.isEmpty ? null : note, ); - final navigator = Navigator.of(context); + final navigator = Navigator.of( + context, + rootNavigator: true, + ); final response = isEdit ? await _controller.update(warehouseId!, input) : await _controller.create(input); @@ -545,7 +576,8 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> { return ShadButton.ghost( onPressed: isSaving ? null - : () => Navigator.of(context).pop(false), + : () => + Navigator.of(context, rootNavigator: true).pop(false), child: const Text('취소'), ); }, @@ -812,11 +844,13 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> { description: '"${warehouse.warehouseName}" 창고를 삭제하시겠습니까?', actions: [ ShadButton.ghost( - onPressed: () => Navigator.of(context).pop(false), + onPressed: () => + Navigator.of(context, rootNavigator: true).pop(false), child: const Text('취소'), ), ShadButton( - onPressed: () => Navigator.of(context).pop(true), + onPressed: () => + Navigator.of(context, rootNavigator: true).pop(true), child: const Text('삭제'), ), ], @@ -840,10 +874,14 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> { } void _showSnack(String message) { - if (!mounted) return; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(message))); + if (!mounted) { + return; + } + final messenger = ScaffoldMessenger.maybeOf(context); + if (messenger == null) { + return; + } + messenger.showSnackBar(SnackBar(content: Text(message))); } String _formatDateTime(DateTime? value) { diff --git a/lib/features/reporting/presentation/pages/reporting_page.dart b/lib/features/reporting/presentation/pages/reporting_page.dart index 8ce44ef..2e62108 100644 --- a/lib/features/reporting/presentation/pages/reporting_page.dart +++ b/lib/features/reporting/presentation/pages/reporting_page.dart @@ -6,8 +6,9 @@ import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:url_launcher/url_launcher.dart'; -import 'package:superport_v2/core/constants/app_sections.dart'; import 'package:superport_v2/core/network/failure.dart'; +import 'package:superport_v2/core/common/utils/pagination_utils.dart'; +import 'package:superport_v2/core/constants/app_sections.dart'; import 'package:superport_v2/core/services/file_saver.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'; @@ -116,16 +117,19 @@ class _ReportingPageState extends State { _warehouseError = null; }); try { - final result = await _warehouseRepository.list( - pageSize: 100, - isActive: true, + final warehouses = await fetchAllPaginatedItems( + request: (page, pageSize) => _warehouseRepository.list( + page: page, + pageSize: pageSize, + isActive: true, + ), ); if (!mounted) { return; } final seen = {WarehouseFilterOption.all.cacheKey}; final options = [WarehouseFilterOption.all]; - for (final warehouse in result.items) { + for (final warehouse in warehouses) { final option = WarehouseFilterOption.fromWarehouse(warehouse); if (seen.add(option.cacheKey)) { options.add(option); diff --git a/lib/widgets/app_shell.dart b/lib/widgets/app_shell.dart index e8f53b0..2a7d30f 100644 --- a/lib/widgets/app_shell.dart +++ b/lib/widgets/app_shell.dart @@ -1,10 +1,16 @@ import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide; +import 'package:shadcn_ui/shadcn_ui.dart'; import '../core/constants/app_sections.dart'; -import '../core/theme/theme_controller.dart'; import '../core/permissions/permission_manager.dart'; +import '../core/theme/theme_controller.dart'; +import '../features/auth/application/auth_service.dart'; +import '../features/auth/domain/entities/auth_session.dart'; +import 'components/superport_dialog.dart'; /// 앱 기본 레이아웃을 제공하는 셸 위젯. 사이드 네비게이션과 AppBar를 구성한다. class AppShell extends StatelessWidget { @@ -30,6 +36,7 @@ class AppShell extends StatelessWidget { ]; final pages = filteredPages.isEmpty ? allAppPages : filteredPages; final themeController = ThemeControllerScope.of(context); + final authService = GetIt.I(); final appBar = _GradientAppBar( title: const _BrandTitle(), actions: [ @@ -38,11 +45,7 @@ class AppShell extends StatelessWidget { onChanged: themeController.update, ), const SizedBox(width: 8), - IconButton( - tooltip: '로그아웃', - icon: const Icon(lucide.LucideIcons.logOut), - onPressed: () => context.go(loginRoutePath), - ), + _AccountMenuButton(service: authService), const SizedBox(width: 8), ], ); @@ -393,3 +396,242 @@ int _selectedIndex(String location, List pages) { ); return prefix == -1 ? 0 : prefix; } + +/// 계정 정보를 확인하고 로그아웃을 수행하는 상단바 버튼. +class _AccountMenuButton extends StatelessWidget { + const _AccountMenuButton({required this.service}); + + final AuthService service; + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: service, + builder: (context, _) { + final session = service.session; + return IconButton( + tooltip: '계정 정보', + icon: const Icon(lucide.LucideIcons.userRound), + onPressed: () => _handlePressed(context, session), + ); + }, + ); + } + + Future _handlePressed( + BuildContext context, + AuthSession? session, + ) async { + final shouldLogout = await SuperportDialog.show( + context: context, + dialog: SuperportDialog( + title: '계정 정보', + description: session == null + ? '로그인 정보를 찾을 수 없습니다.' + : '현재 로그인된 계정 세부 정보를 확인하세요.', + child: _AccountInfoContent(session: session), + footer: Builder( + builder: (dialogContext) { + return Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 20), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + ShadButton.outline( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: const Text('닫기'), + ), + const SizedBox(width: 12), + ShadButton.destructive( + onPressed: session == null + ? null + : () => Navigator.of(dialogContext).pop(true), + child: const Text('로그아웃'), + ), + ], + ), + ); + }, + ), + scrollable: session != null && session.permissions.length > 6, + ), + ); + if (shouldLogout == true) { + await service.clearSession(); + if (!context.mounted) return; + context.go(loginRoutePath); + } + } +} + +/// 로그인된 계정의 핵심 정보를 보여주는 다이얼로그 본문. +class _AccountInfoContent extends StatelessWidget { + const _AccountInfoContent({required this.session}); + + final AuthSession? session; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + if (session == null) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Text( + '로그인된 사용자 정보를 불러오지 못했습니다. 다시 로그인하면 세션이 갱신됩니다.', + style: theme.textTheme.bodyMedium?.copyWith(color: colorScheme.error), + ), + ); + } + + final user = session!.user; + final expiryLabel = _formatExpiry(session!.expiresAt); + final permissionSummaries = _PermissionSummary.build(session!); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + _AccountInfoRow(label: '이름', value: user.name), + _AccountInfoRow(label: '사번', value: user.employeeNo ?? '-'), + _AccountInfoRow(label: '이메일', value: user.email ?? '-'), + _AccountInfoRow(label: '기본 그룹', value: user.primaryGroupName ?? '-'), + _AccountInfoRow(label: '토큰 만료', value: expiryLabel), + _AccountInfoRow( + label: '권한 리소스', + value: '${permissionSummaries.length}개', + ), + if (permissionSummaries.isNotEmpty) ...[ + const SizedBox(height: 16), + Text( + '권한 요약', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final summary in permissionSummaries.take(6)) + ShadBadge.outline( + child: Text( + '${summary.resource} · ${_formatActions(summary.actions)}', + ), + ), + if (permissionSummaries.length > 6) + ShadBadge.outline( + child: Text('외 ${permissionSummaries.length - 6}개 리소스'), + ), + ], + ), + ], + ], + ); + } + + String _formatExpiry(DateTime? expiresAt) { + if (expiresAt == null) { + return '만료 정보 없음'; + } + final formatter = DateFormat('yyyy-MM-dd HH:mm'); + return formatter.format(expiresAt.toLocal()); + } +} + +/// 다이얼로그에서 라벨과 값을 한 줄로 표시한다. +class _AccountInfoRow extends StatelessWidget { + const _AccountInfoRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 112, + child: Text( + label, + style: theme.textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w600, + color: colorScheme.onSurfaceVariant, + ), + ), + ), + Expanded( + child: Text( + value.isEmpty ? '-' : value, + style: theme.textTheme.bodyMedium, + ), + ), + ], + ), + ); + } +} + +/// 리소스별 권한 액션 목록 요약 데이터. +class _PermissionSummary { + const _PermissionSummary({required this.resource, required this.actions}); + + final String resource; + final List actions; + + static List<_PermissionSummary> build(AuthSession session) { + final Map> aggregated = {}; + for (final permission in session.permissions) { + final bucket = aggregated.putIfAbsent( + permission.resource, + () => {}, + ); + for (final raw in permission.actions) { + final normalized = raw.trim().toLowerCase(); + if (normalized.isEmpty) continue; + bucket.add(normalized); + } + } + final summaries = + aggregated.entries + .map( + (entry) => _PermissionSummary( + resource: entry.key, + actions: entry.value.toList()..sort(), + ), + ) + .toList() + ..sort((a, b) => a.resource.compareTo(b.resource)); + return summaries; + } +} + +String _formatActions(List actions) { + if (actions.isEmpty) { + return '권한 없음'; + } + final labels = actions.map(_koreanActionLabel).toList()..sort(); + return labels.join(', '); +} + +String _koreanActionLabel(String action) { + return switch (action.trim().toLowerCase()) { + 'view' => '조회', + 'read' => '조회', + 'create' => '생성', + 'edit' => '수정', + 'update' => '수정', + 'delete' => '삭제', + 'restore' => '복구', + 'approve' => '결재', + _ => action, + }; +} diff --git a/lib/widgets/components/feedback.dart b/lib/widgets/components/feedback.dart index 08e3a1e..407a728 100644 --- a/lib/widgets/components/feedback.dart +++ b/lib/widgets/components/feedback.dart @@ -51,7 +51,10 @@ class SuperportToast { ), }; - final messenger = ScaffoldMessenger.of(context); + final messenger = ScaffoldMessenger.maybeOf(context); + if (messenger == null) { + return; + } messenger ..hideCurrentSnackBar() ..showSnackBar( diff --git a/lib/widgets/components/superport_table.dart b/lib/widgets/components/superport_table.dart index 49a4d20..c252b5b 100644 --- a/lib/widgets/components/superport_table.dart +++ b/lib/widgets/components/superport_table.dart @@ -276,9 +276,15 @@ class _PaginationFooter extends StatelessWidget { @override Widget build(BuildContext context) { final theme = ShadTheme.of(context); - final currentPage = pagination.currentPage.clamp(1, pagination.totalPages); + final int totalPages = + pagination.totalPages <= 0 ? 1 : pagination.totalPages; + final int currentPage = pagination.currentPage < 1 + ? 1 + : (pagination.currentPage > totalPages + ? totalPages + : pagination.currentPage); final canGoPrev = currentPage > 1 && !isLoading; - final canGoNext = currentPage < pagination.totalPages && !isLoading; + final canGoNext = currentPage < totalPages && !isLoading; return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -306,10 +312,16 @@ class _PaginationFooter extends StatelessWidget { Row( children: [ Text( - '${pagination.totalItems}건 · 페이지 $currentPage / ${pagination.totalPages}', + '${pagination.totalItems}건 · 페이지 $currentPage / $totalPages', style: theme.textTheme.small, ), const SizedBox(width: 12), + ShadButton.ghost( + size: ShadButtonSize.sm, + onPressed: canGoPrev ? () => onPageChange?.call(1) : null, + child: const Icon(lucide.LucideIcons.chevronsLeft, size: 16), + ), + const SizedBox(width: 8), ShadButton.ghost( size: ShadButtonSize.sm, onPressed: canGoPrev @@ -325,6 +337,13 @@ class _PaginationFooter extends StatelessWidget { : null, child: const Icon(lucide.LucideIcons.chevronRight, size: 16), ), + const SizedBox(width: 8), + ShadButton.ghost( + size: ShadButtonSize.sm, + onPressed: + canGoNext ? () => onPageChange?.call(totalPages) : null, + child: const Icon(lucide.LucideIcons.chevronsRight, size: 16), + ), ], ), ], diff --git a/test/features/auth/domain/entities/auth_permission_test.dart b/test/features/auth/domain/entities/auth_permission_test.dart new file mode 100644 index 0000000..9837360 --- /dev/null +++ b/test/features/auth/domain/entities/auth_permission_test.dart @@ -0,0 +1,34 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:superport_v2/core/permissions/permission_manager.dart'; +import 'package:superport_v2/features/auth/domain/entities/auth_permission.dart'; + +void main() { + group('AuthPermission.toPermissionMap', () { + test('백엔드 표준 문자열을 프런트 권한으로 매핑한다', () { + final permission = AuthPermission( + resource: '/approvals', + actions: ['read', 'update', 'approve'], + ); + + final result = permission.toPermissionMap(); + + expect(result, contains('/approvals')); + final actions = result['/approvals']!; + expect(actions.contains(PermissionAction.view), isTrue); + expect(actions.contains(PermissionAction.edit), isTrue); + expect(actions.contains(PermissionAction.approve), isTrue); + }); + + test('알 수 없는 문자열은 무시해 빈 권한으로 반환한다', () { + final permission = AuthPermission( + resource: '/dashboard', + actions: ['unknown', 'legacy'], + ); + + final result = permission.toPermissionMap(); + + expect(result, isEmpty); + }); + }); +} diff --git a/test/features/masters/menu/presentation/controllers/menu_controller_test.dart b/test/features/masters/menu/presentation/controllers/menu_controller_test.dart index 1b37c5e..441842f 100644 --- a/test/features/masters/menu/presentation/controllers/menu_controller_test.dart +++ b/test/features/masters/menu/presentation/controllers/menu_controller_test.dart @@ -60,7 +60,7 @@ void main() { verify( () => repository.list( page: 1, - pageSize: 200, + pageSize: any(named: 'pageSize'), query: null, parentId: null, isActive: null, 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 new file mode 100644 index 0000000..9f5119a --- /dev/null +++ b/test/features/masters/product/presentation/widgets/vendor_autocomplete_field_test.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import 'package:superport_v2/features/masters/product/presentation/widgets/vendor_autocomplete_field.dart'; +import 'package:superport_v2/features/masters/vendor/domain/entities/vendor.dart'; + +void main() { + group('VendorAutocompleteField', () { + testWidgets('선택한 항목이 입력창에 반영된다', (tester) async { + final vendors = [ + Vendor( + id: 1, + vendorCode: 'V001', + vendorName: '테스트 제조사', + isActive: true, + isDeleted: false, + ), + ]; + int? selectedId; + + await tester.pumpWidget( + ShadApp( + home: Scaffold( + body: Center( + child: VendorAutocompleteField( + initialOptions: vendors, + onSelected: (id) => selectedId = id, + ), + ), + ), + ), + ); + + // 포커스 후 옵션 선택 + await tester.tap(find.byType(ShadInput)); + await tester.pumpAndSettle(); + + // RawAutocomplete 옵션은 Overlay에 그려진다. + await tester.tap(find.text('테스트 제조사 (V001)')); + await tester.pumpAndSettle(); + + expect(selectedId, equals(1)); + + final editableText = tester.widget(find.byType(EditableText)); + expect(editableText.controller.text, '테스트 제조사 (V001)'); + }); + }); +} diff --git a/test/widgets/app_shell_test.dart b/test/widgets/app_shell_test.dart new file mode 100644 index 0000000..2423b56 --- /dev/null +++ b/test/widgets/app_shell_test.dart @@ -0,0 +1,196 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:go_router/go_router.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import 'package:superport_v2/core/constants/app_sections.dart'; +import 'package:superport_v2/core/permissions/permission_manager.dart'; +import 'package:superport_v2/core/theme/superport_shad_theme.dart'; +import 'package:superport_v2/core/theme/theme_controller.dart'; +import 'package:superport_v2/core/services/token_storage.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/features/auth/domain/entities/login_request.dart'; +import 'package:superport_v2/features/auth/domain/repositories/auth_repository.dart'; +import 'package:superport_v2/widgets/app_shell.dart'; + +void main() { + setUp(() { + GetIt.I.reset(); + }); + + testWidgets('계정 버튼을 누르면 계정 정보 다이얼로그가 표시된다', (tester) async { + final session = _buildSession(); + final authService = _createAuthService(session); + GetIt.I.registerSingleton(authService); + addTearDown(authService.dispose); + await authService.login( + const LoginRequest(identifier: 'user@example.com', password: 'secret'), + ); + + await _pumpAppShell(tester); + + await tester.tap(find.byIcon(lucide.LucideIcons.userRound)); + await tester.pumpAndSettle(); + + expect(find.text('계정 정보'), findsOneWidget); + expect(find.text('김승인'), findsWidgets); + expect(find.text('E2025001'), findsOneWidget); + expect(find.text('물류팀'), findsOneWidget); + expect(find.textContaining('/approvals'), findsOneWidget); + }); + + testWidgets('다이얼로그에서 로그아웃을 누르면 세션이 초기화되고 로그인 화면으로 이동한다', (tester) async { + final session = _buildSession(); + final authService = _createAuthService(session); + GetIt.I.registerSingleton(authService); + addTearDown(authService.dispose); + await authService.login( + const LoginRequest(identifier: 'user@example.com', password: 'secret'), + ); + + await _pumpAppShell(tester); + + expect(authService.session, isNotNull); + + await tester.tap(find.byIcon(lucide.LucideIcons.userRound)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('로그아웃')); + await tester.pumpAndSettle(); + + expect(authService.session, isNull); + expect(find.text('로그인 페이지'), findsOneWidget); + }); +} + +Future _pumpAppShell(WidgetTester tester) async { + final themeController = ThemeController(); + final permissionManager = PermissionManager(); + final router = GoRouter( + initialLocation: dashboardRoutePath, + routes: [ + GoRoute( + path: loginRoutePath, + builder: (context, state) => const _LoginPlaceholder(), + ), + ShellRoute( + builder: (context, state, child) => + AppShell(currentLocation: state.uri.toString(), child: child), + routes: [ + GoRoute( + path: dashboardRoutePath, + builder: (context, state) => const _DashboardPlaceholder(), + ), + ], + ), + ], + ); + + addTearDown(themeController.dispose); + addTearDown(permissionManager.dispose); + addTearDown(router.dispose); + + await tester.pumpWidget( + PermissionScope( + manager: permissionManager, + child: ThemeControllerScope( + controller: themeController, + child: ShadApp.router( + routerConfig: router, + theme: SuperportShadTheme.light(), + darkTheme: SuperportShadTheme.dark(), + debugShowCheckedModeBanner: false, + ), + ), + ), + ); + await tester.pumpAndSettle(); +} + +AuthSession _buildSession() { + return AuthSession( + accessToken: 'access-token', + refreshToken: 'refresh-token', + expiresAt: DateTime.parse('2025-10-21T09:00:00Z'), + user: const AuthenticatedUser( + id: 7, + name: '김승인', + employeeNo: 'E2025001', + email: 'approver@example.com', + primaryGroupId: 3, + primaryGroupName: '물류팀', + ), + permissions: const [ + AuthPermission(resource: '/dashboard', actions: ['read']), + AuthPermission(resource: '/approvals', actions: ['read', 'update']), + ], + ); +} + +AuthService _createAuthService(AuthSession session) { + final repository = _FakeAuthRepository(session); + final tokenStorage = _MemoryTokenStorage(); + return AuthService(repository: repository, tokenStorage: tokenStorage); +} + +class _FakeAuthRepository implements AuthRepository { + _FakeAuthRepository(this.session); + + final AuthSession session; + + @override + Future login(LoginRequest request) async => session; + + @override + Future refresh(String refreshToken) async => session; +} + +class _MemoryTokenStorage implements TokenStorage { + String? _access; + String? _refresh; + + @override + Future clear() async { + _access = null; + _refresh = null; + } + + @override + Future readAccessToken() async => _access; + + @override + Future readRefreshToken() async => _refresh; + + @override + Future writeAccessToken(String? token) async { + _access = token; + } + + @override + Future writeRefreshToken(String? token) async { + _refresh = token; + } +} + +class _DashboardPlaceholder extends StatelessWidget { + const _DashboardPlaceholder(); + + @override + Widget build(BuildContext context) { + return const Scaffold(body: Center(child: Text('대시보드 본문'))); + } +} + +class _LoginPlaceholder extends StatelessWidget { + const _LoginPlaceholder(); + + @override + Widget build(BuildContext context) { + return const Scaffold(body: Center(child: Text('로그인 페이지'))); + } +}