diff --git a/assets/.env.development b/assets/.env.development index 8a9bbc6..a95529b 100644 --- a/assets/.env.development +++ b/assets/.env.development @@ -1,12 +1,14 @@ API_BASE_URL=http://localhost:8080 +TIMEOUT_MS=15000 +LOG_LEVEL=debug FEATURE_VENDORS_ENABLED=true FEATURE_PRODUCTS_ENABLED=true FEATURE_WAREHOUSES_ENABLED=true FEATURE_CUSTOMERS_ENABLED=true FEATURE_USERS_ENABLED=true -FEATURE_GROUPS_ENABLED=false -FEATURE_MENUS_ENABLED=false -FEATURE_GROUP_PERMISSIONS_ENABLED=false -FEATURE_APPROVALS_ENABLED=false +FEATURE_GROUPS_ENABLED=true +FEATURE_MENUS_ENABLED=true +FEATURE_GROUP_PERMISSIONS_ENABLED=true +FEATURE_APPROVALS_ENABLED=true FEATURE_ZIPCODE_SEARCH_ENABLED=false diff --git a/assets/.env.production b/assets/.env.production new file mode 100644 index 0000000..37ef181 --- /dev/null +++ b/assets/.env.production @@ -0,0 +1,14 @@ +API_BASE_URL=https://api.superport.com +TIMEOUT_MS=15000 +LOG_LEVEL=warning + +FEATURE_VENDORS_ENABLED=false +FEATURE_PRODUCTS_ENABLED=false +FEATURE_WAREHOUSES_ENABLED=false +FEATURE_CUSTOMERS_ENABLED=false +FEATURE_USERS_ENABLED=false +FEATURE_GROUPS_ENABLED=false +FEATURE_MENUS_ENABLED=false +FEATURE_GROUP_PERMISSIONS_ENABLED=false +FEATURE_APPROVALS_ENABLED=false +FEATURE_ZIPCODE_SEARCH_ENABLED=false diff --git a/doc/IMPLEMENTATION_TASKS.md b/doc/IMPLEMENTATION_TASKS.md index 919dd21..4c19f94 100644 --- a/doc/IMPLEMENTATION_TASKS.md +++ b/doc/IMPLEMENTATION_TASKS.md @@ -3,114 +3,114 @@ 본 체크리스트는 PRD(`doc/PRD_입출고_결재_v2.md`)를 기준으로 shadcn_ui 스타일과 반응형 패턴을 준수하여 화면을 구현하기 위한 단계별 작업 목록입니다. 작업 순서는 ① 코드 시작 전 최종 확인 → ② UI 스캐폴딩/상호작용 구현 → ③ 실제 API 연동(Dio/ApiClient/DI)입니다. Mock 데이터는 사용하지 않습니다. ## 0) 코드 시작 전 최종 확인(Repository/환경) -- [ ] Flutter 버전/채널 확인, `flutter pub get` -- [ ] `pubspec.yaml` 확인: `go_router`, `shadcn_ui`, `intl`, `two_dimensional_scrollables`, `lucide_icons_flutter` 포함 -- [ ] `Environment.initialize()` 호출 및 `.env.development`/`.env.production`에서 `API_BASE_URL`/`TIMEOUT_MS`/`LOG_LEVEL` 로드 -- [ ] 라우팅 스켈레톤(go_router) 구성: 로그인/대시보드/입·출·대여/마스터/결재/보고서 -- [ ] 글로벌 테마/ShadTheme 확인(폰트/간격/배지/버튼 일관) +- [x] Flutter 버전/채널 확인, `flutter pub get` +- [x] `pubspec.yaml` 확인: `go_router`, `shadcn_ui`, `intl`, `two_dimensional_scrollables`, `lucide_icons_flutter` 포함 +- [x] `Environment.initialize()` 호출 및 `.env.development`/`.env.production`에서 `API_BASE_URL`/`TIMEOUT_MS`/`LOG_LEVEL` 로드 +- [x] 라우팅 스켈레톤(go_router) 구성: 로그인/대시보드/입·출·대여/마스터/결재/보고서 +- [x] 글로벌 테마/ShadTheme 확인(폰트/간격/배지/버튼 일관) ## 1) 공통 컴포넌트/레이아웃(UI) -- [ ] AppLayout(좌 사이드바/상단 헤더/본문) 적용, 브레드크럼·타이틀·툴바 영역 정리 -- [ ] 테이블: `ShadTable.list` 표준화(고정 헤더/가로 스크롤/소팅/페이지네이션 UI만) -- [ ] 모달: `SuperportShadDialog`(헤더/본문/푸터 분리, 모바일 풀스크린) 공통 wrapper -- [ ] 입력 위젯: `ShadInput/Select/Switch`, `SuperportShadDatePicker/RangePicker` 적용 가이드 -- [ ] 필터바(검색/기간/상태/창고/Reset) 공통 위젯 -- [ ] 반응형 프리셋: 데스크톱/태블릿/모바일 열 가시성 설정(섹션 12 규칙 반영) -- [ ] 토스트/스낵바/스켈레톤/Empty 상태 공통 처리 +- [x] AppLayout(좌 사이드바/상단 헤더/본문) 적용, 브레드크럼·타이틀·툴바 영역 정리 (현황: 입·출·대여와 전 마스터 실화면에 `AppLayout`/브레드크럼/툴바 적용 완료, 결재/보고서 등 잔여 SpecPage 전환만 남음) +- [ ] 테이블: `ShadTable.list` 표준화(고정 헤더/가로 스크롤/소팅/페이지네이션 UI만) (현황: `SuperportTable` 컴포넌트 정의만 있고 실제 화면에서는 직접 `ShadTable.list`를 호출함) +- [ ] 모달: `SuperportShadDialog`(헤더/본문/푸터 분리, 모바일 풀스크린) 공통 wrapper (현황: 화면별로 `showDialog`를 직접 구성하며 공통 래퍼 미사용) +- [ ] 입력 위젯: `ShadInput/Select/Switch`, `SuperportShadDatePicker/RangePicker` 적용 가이드 (현황: 기본 입력 위젯 사용은 있으나 DatePicker 등 공통 컴포넌트/가이드 부재) +- [ ] 필터바(검색/기간/상태/창고/Reset) 공통 위젯 (현황: `FilterBar`를 입/출/대여 + 벤더/제품 화면에 적용했으나 나머지 화면 전파 및 필터 상태 표준화 필요) +- [ ] 반응형 프리셋: 데스크톱/태블릿/모바일 열 가시성 설정(섹션 12 규칙 반영) (현황: `responsive.dart`에 상수만 있고 실제 적용 사례 없음) +- [ ] 토스트/스낵바/스켈레톤/Empty 상태 공통 처리 (현황: 개별 화면에서 `SnackBar`와 임시 문구만 사용, 공통 처리 미정) ## 2) 인증/대시보드(UI) -- [ ] 로그인 화면(`/login`): 아이디/비밀번호 UI(제출/로딩/에러 표시 흐름) -- [ ] 대시보드(`/`): KPI 카드, 최근 트랜잭션, 내 결재 대기 리스트 — 스켈레톤/Empty 상태 구현 +- [ ] 로그인 화면(`/login`): 아이디/비밀번호 UI(제출/로딩/에러 표시 흐름) (현황: 텍스트 필드/버튼만 있고 로딩, 에러 메시지, 실제 인증 연동 없음) +- [ ] 대시보드(`/`): KPI 카드, 최근 트랜잭션, 내 결재 대기 리스트 — 스켈레톤/Empty 상태 구현 (현황: `SpecPage`로 요구사항만 노출, 실제 대시보드 UI 미구현) ## 3) 입고(`/inbounds`) UI -- [ ] 라우트/네비게이션 연결 -- [ ] 목록 테이블: 번호/처리일자/창고/트랜잭션번호/상태/작성자/품목수/총수량/비고 -- [ ] 필터: 기간/창고/상태/검색, 소팅/페이지네이션 -- [ ] 신규 모달: 헤더(처리일자/창고/상태/작성자/비고) + 시스템 필드(`transaction_type_id=입고` RO/숨김) -- [ ] 라인 테이블: 제품(자동완성)→제조사/단위 자동, 수량/단가/비고, (+)/(−) 행 편집 -- [ ] 검증: 필수/수량>=1/단가>=0, 상단 요약 + 인라인 에러 -- [ ] 수정 모달: 작성자/트랜잭션번호 RO, 종결 상태 수정 제한 +- [x] 라우트/네비게이션 연결 (현황: GoRouter에 `/inventory/inbound` 경로 등록, `AppShell` 내에서 진입 가능) +- [ ] 목록 테이블: 번호/처리일자/창고/트랜잭션번호/상태/작성자/품목수/총수량/비고 (현황: `ShadTable.list`를 직접 구성하지만 모의 데이터 기반이며 컬럼 구성이 사양과 다르고 고정 헤더/페이징 부재) +- [ ] 필터: 기간/창고/상태/검색, 소팅/페이지네이션 (현황: 검색어와 기간만 제공, 창고/상태/소팅/페이지네이션 미구현) +- [ ] 신규 모달: 헤더(처리일자/창고/상태/작성자/비고) + 시스템 필드(`transaction_type_id=입고` RO/숨김) (현황: 개별 `Dialog`에서 입력 필드 작성만 가능하며 `transaction_type_id` 처리 및 시스템 필드 구분 없음) +- [ ] 라인 테이블: 제품(자동완성)→제조사/단위 자동, 수량/단가/비고, (+)/(−) 행 편집 (현황: 수동 입력 필드만 제공되고 자동완성·연동 로직 부재) +- [ ] 검증: 필수/수량>=1/단가>=0, 상단 요약 + 인라인 에러 (현황: 품목명 비어 있음만 검사, 수량/단가 검증 및 요약/인라인 에러 미구현) +- [ ] 수정 모달: 작성자/트랜잭션번호 RO, 종결 상태 수정 제한 (현황: 동일 폼 재사용으로 모든 필드 편집 가능, 상태 제한 없음) ## 4) 출고(`/outbounds`) UI -- [ ] 목록 테이블: 번호/처리일자/창고/트랜잭션번호/상태/작성자/고객수/품목수/총수량/비고 -- [ ] 필터: 기간/창고/상태/고객/검색 -- [ ] 신규/수정 모달: 헤더(…)+ 시스템 필드(`transaction_type_id=출고` RO/숨김) -- [ ] 고객 연결(멀티): 고객사 자동완성 → 토큰/칩 UI, 최소 1건 검증 -- [ ] 라인 테이블: 제품/제조사RO/단위RO/수량/단가/비고 +- [ ] 목록 테이블: 번호/처리일자/창고/트랜잭션번호/상태/작성자/고객수/품목수/총수량/비고 (현황: 모의 데이터 기반 테이블이며 컬럼 구성이 사양과 다르고 페이징/소팅 없음) +- [ ] 필터: 기간/창고/상태/고객/검색 (현황: 검색어·기간만 제공, 창고/상태/고객 필터 부재) +- [ ] 신규/수정 모달: 헤더(…)+ 시스템 필드(`transaction_type_id=출고` RO/숨김) (현황: 공통 모달 없이 개별 `Dialog` 구성, `transaction_type_id` 자동 주입 처리 없음) +- [ ] 고객 연결(멀티): 고객사 자동완성 → 토큰/칩 UI, 최소 1건 검증 (현황: `ShadSelect.multiple`로 선택 UI는 있으나 자동완성/최소 1건 검증/실제 데이터 연동 미구현) +- [ ] 라인 테이블: 제품/제조사RO/단위RO/수량/단가/비고 (현황: 수동 입력 필드만 존재, 제조사/단위 자동 채움 및 읽기 전용 처리 미구현) ## 5) 대여(`/rentals`) UI -- [ ] 목록 테이블: 번호/처리일자/창고/대여구분/트랜잭션번호/상태/반납예정일/고객수/품목수/비고 -- [ ] 필터: 기간/창고/상태/대여구분/반납예정일 범위/검색 -- [ ] 신규/수정 모달: 헤더(…/대여구분/반납예정일) + 시스템 필드(`transaction_type_id` 대여/반납 자동 매핑) -- [ ] 고객 연결(멀티) + 라인 테이블(입고·출고와 동일) -- [ ] 규칙: 대여구분은 종결 후 변경 불가, 반납예정일은 진행 중 수정 가능 +- [ ] 목록 테이블: 번호/처리일자/창고/대여구분/트랜잭션번호/상태/반납예정일/고객수/품목수/비고 (현황: 모의 데이터 테이블로 사양 대비 컬럼/정렬/페이징 미충족) +- [ ] 필터: 기간/창고/상태/대여구분/반납예정일 범위/검색 (현황: 검색어·기간만 제공, 나머지 필터 미구현) +- [ ] 신규/수정 모달: 헤더(…/대여구분/반납예정일) + 시스템 필드(`transaction_type_id` 대여/반납 자동 매핑) (현황: 개별 `Dialog` 구성으로 필드 입력만 가능하며 시스템 필드/자동 매핑 로직 없음) +- [ ] 고객 연결(멀티) + 라인 테이블(입고·출고와 동일) (현황: 멀티 선택 UI는 있으나 자동완성/검증/공통 라인 테이블 추출 미완료) +- [ ] 규칙: 대여구분은 종결 후 변경 불가, 반납예정일은 진행 중 수정 가능 (현황: 상태별 편집 제한 로직 전혀 없음) ## 6) 마스터(UI) -- [x] 벤더: 목록/필터(q/사용여부), 신규/수정(코드RO), 삭제/복구 UI -- [x] 제품: 목록/필터(q/제조사/단위/사용), 신규/수정(코드RO) -- [ ] 창고: 목록/필터(q/사용), 신규/수정(우편번호 검색 모달 UI 연동) -- [x] 고객사: 목록/필터(q/유형/사용), 신규/수정(유형→is_partner/is_general 매핑 UI) -- [x] 사용자: 목록/필터(q/그룹/사용), 신규/수정(사번RO) -- [ ] 그룹: 목록/필터(q/기본/사용), 신규/수정(그룹명RO) -- [ ] 메뉴: 목록/필터(q/상위/사용), 신규/수정(메뉴코드RO) -- [ ] 그룹 권한: 목록/필터(그룹/메뉴/사용), 체크박스 매트릭스 편집 UI +- [x] 벤더: 목록/필터(q/사용여부), 신규/수정(코드RO), 삭제/복구 UI (현황: `FEATURE_VENDORS_ENABLED=true`일 때 `VendorController` 기반 CRUD/삭제·복구/상태필터 동작, API 경로 `/vendors` 연결은 실제 백엔드 준비 필요) +- [x] 제품: 목록/필터(q/제조사/단위/사용), 신규/수정(코드RO) (현황: `ProductController`가 벤더·단위 lookup을 선로드하고 CRUD 처리, `FEATURE_PRODUCTS_ENABLED` 플래그가 꺼져 있으면 SpecPage만 노출) +- [ ] 창고: 목록/필터(q/사용), 신규/수정(우편번호 검색 모달 UI 연동) (현황: `WarehouseController` CRUD·상태 필터는 구현됐으나 우편번호 검색 모달/`PostalSearchPage` 연동이 없어 텍스트 수기 입력 상태) +- [x] 고객사: 목록/필터(q/유형/사용), 신규/수정(유형→is_partner/is_general 매핑 UI) (현황: 유형 스위치가 `is_partner/is_general`로 매핑되며 CRUD 흐름 구현, 우편번호 검색은 미연동) +- [x] 사용자: 목록/필터(q/그룹/사용), 신규/수정(사번RO) (현황: 그룹 lookup을 선로드하고 사번 필드 읽기 전용 처리, `FEATURE_USERS_ENABLED` 플래그 필요) +- [x] 그룹: 목록/필터(q/기본/사용), 신규/수정(그룹명RO) (현황: `GroupController`가 페이지네이션/검색/토스트까지 처리, 실제 API 응답 필요) +- [x] 메뉴: 목록/필터(q/상위/사용), 신규/수정(메뉴코드RO) (현황: 메뉴 트리 CRUD UI 구현, feature flag false 시 SpecPage 표시) +- [x] 그룹 권한: 목록/필터(그룹/메뉴/사용), 체크박스 매트릭스 편집 UI (현황: 그룹·메뉴 lookup + 권한 매트릭스 편집/일괄 저장 흐름 구현, 실제 API 응답 미연결) ## 7) 결재(UI) -- [ ] 결재(`/approvals`): 목록/필터, 상세(개요/단계/이력 탭) -- [ ] 템플릿 불러오기: 단계 탭에서 템플릿 선택 UI(단계 리스트 반영) -- [ ] 단계 행위: 승인/반려/코멘트 버튼(가능 여부 상태에 따라 비활성/툴팁) -- [ ] 단계 관리(`/approval-steps`): 목록/편집(신규/수정) -- [ ] 이력(`/approval-histories`): 조회 전용 테이블 -- [ ] 템플릿(`/approval-templates`): 목록/헤더+단계 반복 폼 +- [ ] 결재(`/approvals`): 목록/필터, 상세(개요/단계/이력 탭) (현황: `/approvals/requests` 라우트를 `ApprovalPage`로 연결하고 AppLayout/FilterBar·단계 행위·템플릿 적용까지 연동했으며 추가 액션/권한 제어는 후속 예정) +- [x] 템플릿 불러오기: 단계 탭에서 템플릿 선택 UI(단계 리스트 반영) (현황: 템플릿 목록 로딩·선택·확인 다이얼로그·`assignSteps` 호출로 단계 일괄 적용까지 구현, 템플릿 CRUD는 미구현) +- [x] 단계 행위: 승인/반려/코멘트 버튼(가능 여부 상태에 따라 비활성/툴팁) (현황: 단계 버튼·툴팁·행위 다이얼로그를 구현했고 `ApprovalRepository.performStepAction` 연동 완료, 권한 기반 노출/후속 알림은 TODO) +- [ ] 단계 관리(`/approval-steps`): 목록/편집(신규/수정) (현황: feature flag On 시 AppLayout + 안내 카드 플레이스홀더 제공, CRUD/데이터 연동은 미구현) +- [ ] 이력(`/approval-histories`): 조회 전용 테이블 (현황: AppLayout 기반 안내 화면으로 전환, 실제 테이블/필터/다운로드는 미구현) +- [ ] 템플릿(`/approval-templates`): 목록/헤더+단계 반복 폼 (현황: AppLayout/FilterBar를 갖춘 플레이스홀더만 존재, 템플릿 CRUD 로직 미구현) ## 8) 우편번호 검색 모달(UI) -- [ ] 입력: 검색어 텍스트 -- [ ] 결과 테이블: 우편번호/시도/시군구/도로명/건물번호 -- [ ] 선택 시: 부모 폼 `zipcode`/주소 구성요소 채움 +- [ ] 입력: 검색어 텍스트 (현황: `/utilities/postal-search` 라우트가 SpecPage만 노출, 실제 모달 위젯/상호작용 없음) +- [ ] 결과 테이블: 우편번호/시도/시군구/도로명/건물번호 (현황: 결과 렌더링 미구현) +- [ ] 선택 시: 부모 폼 `zipcode`/주소 구성요소 채움 (현황: Warehouse/Customer 폼과의 데이터 바인딩 미구현) ## 9) 보고서(`/reports`) UI -- [ ] 조건 폼: 기간/유형/창고/상태 -- [ ] 액션: XLSX/PDF 버튼 — 미제공 시 버튼 비활성 UI(연동은 API 단계에서) +- [ ] 조건 폼: 기간/유형/창고/상태 (현황: `ReportingPage`를 AppLayout + FilterBar 플레이스홀더로 전환, 조건 버튼은 비활성 상태로 안내만 제공) +- [ ] 액션: XLSX/PDF 버튼 — 미제공 시 버튼 비활성 UI(연동은 API 단계에서) (현황: 헤더 액션에 비활성 버튼 배치, 실제 다운로드 연동과 상태 제어는 미구현) ## 10) 데이터 계층/상태 관리 -- [ ] 리포지토리 인터페이스(domain) 정의 및 구현(data): 트랜잭션/결재/마스터/룩업/우편번호 -- [ ] DTO ↔ 도메인 엔티티 매핑(응답 `{ items }`/`{ data }` 구조 표준화) -- [ ] 페이지네이션 상태(현재 페이지/사이즈/전체) 및 필터 상태 싱크(go_router querystring 연동) -- [ ] 정렬/검색/Include 옵션 직렬화 및 유지 +- [ ] 리포지토리 인터페이스(domain) 정의 및 구현(data): 트랜잭션/결재/마스터/룩업/우편번호 (현황: 마스터·결재·UOM에 대한 domain/data 레이어만 존재하고 입·출·대여/보고서/우편번호 리포지토리 미정의) +- [ ] DTO ↔ 도메인 엔티티 매핑(응답 `{ items }`/`{ data }` 구조 표준화) (현황: 마스터/결재 DTO는 작성됐으나 인벤토리/보고서 DTO 부재, `{ data }` fallback 처리 통일 미완료) +- [ ] 페이지네이션 상태(현재 페이지/사이즈/전체) 및 필터 상태 싱크(go_router querystring 연동) (현황: 각 Controller가 내부 `PaginatedResult`만 유지하고 라우터 querystring과 동기화되지 않음) +- [ ] 정렬/검색/Include 옵션 직렬화 및 유지 (현황: 검색어만 로컬 상태로 보관하며 sort/include 파라미터 직렬화/복원 로직 없음) ## 11) API 연동 단계(Dio/ApiClient/DI) -- [ ] 네트워킹 패키지 추가: `dio:^5.x`, `pretty_dio_logger`(dev 선택), 토큰 저장용 `flutter_secure_storage`(모바일)/웹 스토리지 -- [ ] `ApiClient`/`AuthInterceptor` 스켈레톤 작성(설계: `doc/API_CLIENT_SPEC.md`) -- [ ] `Environment.initialize()` → `get_it` DI에서 ApiClient 생성/주입 -- [ ] 공통 에러 매핑(400/404/409/422) 및 토스트/필드 바인딩 연결 -- [ ] 메뉴/권한 로딩 → 버튼/액션 노출 제어 +- [ ] 네트워킹 패키지 추가: `dio:^5.x`, `pretty_dio_logger`(dev 선택), 토큰 저장용 `flutter_secure_storage`(모바일)/웹 스토리지 (현황: `dio`/`pretty_dio_logger`는 추가되어 사용 중이나 `flutter_secure_storage` 및 웹 스토리지 분기 미도입) +- [x] `ApiClient`/`AuthInterceptor` 스켈레톤 작성(설계: `doc/API_CLIENT_SPEC.md`) (현황: `lib/core/network/api_client.dart`, `interceptors/auth_interceptor.dart`에 기본 뼈대 구현, 토큰 주입/401 재시도 로직은 TODO 상태) +- [x] `Environment.initialize()` → `get_it` DI에서 ApiClient 생성/주입 (현황: `main.dart`에서 초기화 후 `injection_container.dart`가 `ApiClient`와 각 리포지토리를 등록) +- [ ] 공통 에러 매핑(400/404/409/422) 및 토스트/필드 바인딩 연결 (현황: Dio 예외 처리 공통화 미구현, 화면에서 개별 SnackBar만 사용) +- [ ] 메뉴/권한 로딩 → 버튼/액션 노출 제어 (현황: 권한 기반 노출 제어 미구현, feature flag만 사용) - [ ] 각 화면 API 연결: - - 입고/출고/대여: 목록/상세/생성/수정/삭제/복구 + include/필터/정렬/페이지네이션 - - 마스터: vendors/products/warehouses/customers/employees/menus/groups/group-permissions(+ 일괄 저장) - - 결재: approvals(+steps/histories), actions, can-proceed, 템플릿 CRUD/단계 배치 - - 우편번호: `GET /zipcodes?...` - - 보고서: 다운로드 엔드포인트 연동(제공 시) + - 입고/출고/대여: 목록/상세/생성/수정/삭제/복구 + include/필터/정렬/페이지네이션 (현황: `ShadTable`에 Mock 데이터 하드코딩, 리포지토리/DTO 부재) + - 마스터: vendors/products/warehouses/customers/employees/menus/groups/group-permissions(+ 일괄 저장) (현황: 모든 마스터 화면이 `ApiClient` 기반 리포지토리로 CRUD/삭제·복구까지 호출하도록 작성되어 있으나 실제 엔드포인트 유효성 검증 필요) + - 결재: approvals(+steps/histories), actions, can-proceed, 템플릿 CRUD/단계 배치 (현황: 리포지토리 스켈레톤만 존재하며 UI는 AppLayout 기반으로 정리됐지만 실제 API 연동과 액션 처리는 미구현) + - 우편번호: `GET /zipcodes?...` (현황: 리포지토리/네트워킹 미구현) + - 보고서: 다운로드 엔드포인트 연동(제공 시) (현황: 보고서 화면은 AppLayout 플레이스홀더 상태, API 연동과 다운로드 흐름 미구현) ## 12) 검증/접근성/상호작용 -- [ ] 필수/형식/업무 규칙 검증(출고/대여 고객 최소 1건 등) -- [ ] 키보드: Esc 닫기, Enter 제출/셀 이동, Tab 포커스 이동, 포커스 트랩 -- [ ] 합계/요약 배지 실시간 반영(수량/단가 변경 시) +- [ ] 필수/형식/업무 규칙 검증(출고/대여 고객 최소 1건 등) (현황: 필수값 일부만 검사하고 수량/단가 ≥ 조건, 고객 최소 1건, 상태별 제한 등 업무 규칙 미구현) +- [ ] 키보드: Esc 닫기, Enter 제출/셀 이동, Tab 포커스 이동, 포커스 트랩 (현황: 기본 포커스만 제공되며 단축키/포커스 트랩 처리 미구현) +- [ ] 합계/요약 배지 실시간 반영(수량/단가 변경 시) (현황: 상세 카드에서 합계를 보여주지만 폼 입력 시 실시간 합산/배지 업데이트 없음) ## 13) 반응형/열 가시성 -- [ ] 데스크톱/태블릿/모바일 프리셋 구현(PRD 섹션 12 규칙 적용) -- [ ] 모바일 카드형 요약(핵심 3~4필드) 구성 +- [ ] 데스크톱/태블릿/모바일 프리셋 구현(PRD 섹션 12 규칙 적용) (현황: `widgets/components/responsive.dart`에 breakpoint 상수만 정의되어 있고 화면 적용 미진행) +- [ ] 모바일 카드형 요약(핵심 3~4필드) 구성 (현황: 모든 목록이 데스크톱 테이블만 제공) ## 14) 테스트/품질 -- [x] `flutter analyze` 경고 0 -- [x] 위젯 테스트: 테이블 렌더/필터/페이지네이션/모달 열기/검증 메시지 -- [ ] 내비 통합 테스트(선택): 로그인 → 대시보드 → 입/출/대여 → 결재 → 마스터 -- [ ] `dart format .` 적용 +- [x] `flutter analyze` 경고 0 (현황: 현재 소스는 analyzer 경고 없이 유지되나 기능 추가 시 재검증 필요) +- [x] 위젯 테스트: 테이블 렌더/필터/페이지네이션/모달 열기/검증 메시지 (현황: 마스터/결재 컨트롤러 중심의 위젯·단위 테스트가 존재하나 인벤토리/보고서 영역 테스트 미작성) +- [ ] 내비 통합 테스트(선택): 로그인 → 대시보드 → 입/출/대여 → 결재 → 마스터 (현황: 통합 테스트 미구현) +- [ ] `dart format .` 적용 (현황: 포맷 명령 자동화/검증 절차 부재) ## 15) Definition of Done(DoD) -- [ ] 모든 목록/폼/모달/필터/페이지네이션 동작 -- [ ] 모바일/태블릿/데스크톱 레이아웃 검증(핵심 열 가시성) -- [ ] 실제 API로 주요 플로우(신규/수정/삭제/복구) 검증 완료 -- [ ] 문서 최신화(PRD/체크리스트) +- [ ] 모든 목록/폼/모달/필터/페이지네이션 동작 (현황: 마스터 일부 기능만 CRUD 동작, 인벤토리/결재/보고서는 스켈레톤 단계) +- [ ] 모바일/태블릿/데스크톱 레이아웃 검증(핵심 열 가시성) (현황: 반응형 레이아웃 미구현) +- [ ] 실제 API로 주요 플로우(신규/수정/삭제/복구) 검증 완료 (현황: 실제 백엔드와의 통합 테스트 결과 미확인) +- [ ] 문서 최신화(PRD/체크리스트) (현황: IMPLEMENTATION_TASKS.md는 진행 중이나 PRD/사양 문서와의 싱크 필요) ## 참고 - PRD: `doc/PRD_입출고_결재_v2.md` diff --git a/lib/features/approvals/data/dtos/approval_dto.dart b/lib/features/approvals/data/dtos/approval_dto.dart new file mode 100644 index 0000000..d844015 --- /dev/null +++ b/lib/features/approvals/data/dtos/approval_dto.dart @@ -0,0 +1,292 @@ +import 'package:superport_v2/core/common/models/paginated_result.dart'; + +import '../../domain/entities/approval.dart'; + +class ApprovalDto { + ApprovalDto({ + this.id, + required this.approvalNo, + this.transactionNo, + required this.status, + this.currentStep, + required this.requester, + required this.requestedAt, + this.decidedAt, + this.note, + this.isActive = true, + this.isDeleted = false, + this.steps = const [], + this.histories = const [], + this.createdAt, + this.updatedAt, + }); + + final int? id; + final String approvalNo; + final String? transactionNo; + final ApprovalStatusDto status; + final ApprovalStepDto? currentStep; + final ApprovalRequesterDto requester; + final DateTime requestedAt; + final DateTime? decidedAt; + final String? note; + final bool isActive; + final bool isDeleted; + final List steps; + final List histories; + final DateTime? createdAt; + final DateTime? updatedAt; + + factory ApprovalDto.fromJson(Map json) { + return ApprovalDto( + id: json['id'] as int?, + approvalNo: json['approval_no'] as String, + transactionNo: json['transaction'] is Map + ? (json['transaction']['transaction_no'] as String?) + : json['transaction_no'] as String?, + status: ApprovalStatusDto.fromJson( + (json['status'] as Map? ?? const {}), + ), + currentStep: json['current_step'] is Map + ? ApprovalStepDto.fromJson( + json['current_step'] as Map, + ) + : null, + requester: ApprovalRequesterDto.fromJson( + (json['requester'] as Map? ?? const {}), + ), + requestedAt: _parseDate(json['requested_at']) ?? DateTime.now(), + decidedAt: _parseDate(json['decided_at']), + note: json['note'] as String?, + isActive: (json['is_active'] as bool?) ?? true, + isDeleted: (json['is_deleted'] as bool?) ?? false, + steps: (json['steps'] as List? ?? []) + .whereType>() + .map(ApprovalStepDto.fromJson) + .toList(), + histories: (json['histories'] as List? ?? []) + .whereType>() + .map(ApprovalHistoryDto.fromJson) + .toList(), + createdAt: _parseDate(json['created_at']), + updatedAt: _parseDate(json['updated_at']), + ); + } + + Approval toEntity() => Approval( + id: id, + approvalNo: approvalNo, + transactionNo: transactionNo ?? '-', + status: status.toEntity(), + currentStep: currentStep?.toEntity(), + requester: requester.toEntity(), + requestedAt: requestedAt, + decidedAt: decidedAt, + note: note, + isActive: isActive, + isDeleted: isDeleted, + steps: steps.map((e) => e.toEntity()).toList(), + histories: histories.map((e) => e.toEntity()).toList(), + createdAt: createdAt, + updatedAt: updatedAt, + ); + + static PaginatedResult parsePaginated(Map? json) { + final items = (json?['items'] as List? ?? []) + .whereType>() + .map(ApprovalDto.fromJson) + .map((dto) => dto.toEntity()) + .toList(); + return PaginatedResult( + items: items, + page: json?['page'] as int? ?? 1, + pageSize: json?['page_size'] as int? ?? items.length, + total: json?['total'] as int? ?? items.length, + ); + } +} + +class ApprovalStatusDto { + ApprovalStatusDto({required this.id, required this.name, this.color}); + + final int id; + final String name; + final String? color; + + factory ApprovalStatusDto.fromJson(Map json) { + return ApprovalStatusDto( + id: json['id'] as int? ?? json['status_id'] as int? ?? 0, + name: json['name'] as String? ?? json['status_name'] as String? ?? '-', + color: json['color'] as String?, + ); + } + + ApprovalStatus toEntity() => ApprovalStatus(id: id, name: name, color: color); +} + +class ApprovalRequesterDto { + ApprovalRequesterDto({ + required this.id, + required this.employeeNo, + required this.name, + }); + + final int id; + final String employeeNo; + final String name; + + factory ApprovalRequesterDto.fromJson(Map json) { + return ApprovalRequesterDto( + id: json['id'] as int? ?? json['employee_id'] as int? ?? 0, + employeeNo: json['employee_no'] as String? ?? '-', + name: json['name'] as String? ?? json['employee_name'] as String? ?? '-', + ); + } + + ApprovalRequester toEntity() => + ApprovalRequester(id: id, employeeNo: employeeNo, name: name); +} + +class ApprovalApproverDto { + ApprovalApproverDto({ + required this.id, + required this.employeeNo, + required this.name, + }); + + final int id; + final String employeeNo; + final String name; + + factory ApprovalApproverDto.fromJson(Map json) { + return ApprovalApproverDto( + id: json['id'] as int? ?? json['approver_id'] as int? ?? 0, + employeeNo: json['employee_no'] as String? ?? '-', + name: json['name'] as String? ?? json['employee_name'] as String? ?? '-', + ); + } + + ApprovalApprover toEntity() => + ApprovalApprover(id: id, employeeNo: employeeNo, name: name); +} + +class ApprovalStepDto { + ApprovalStepDto({ + this.id, + required this.stepOrder, + required this.approver, + required this.status, + required this.assignedAt, + this.decidedAt, + this.note, + }); + + final int? id; + final int stepOrder; + final ApprovalApproverDto approver; + final ApprovalStatusDto status; + final DateTime assignedAt; + final DateTime? decidedAt; + final String? note; + + factory ApprovalStepDto.fromJson(Map json) { + return ApprovalStepDto( + id: json['id'] as int?, + stepOrder: json['step_order'] as int? ?? 0, + approver: ApprovalApproverDto.fromJson( + (json['approver'] as Map? ?? const {}), + ), + status: ApprovalStatusDto.fromJson( + (json['status'] as Map? ?? const {}), + ), + assignedAt: _parseDate(json['assigned_at']) ?? DateTime.now(), + decidedAt: _parseDate(json['decided_at']), + note: json['note'] as String?, + ); + } + + ApprovalStep toEntity() => ApprovalStep( + id: id, + stepOrder: stepOrder, + approver: approver.toEntity(), + status: status.toEntity(), + assignedAt: assignedAt, + decidedAt: decidedAt, + note: note, + ); +} + +class ApprovalHistoryDto { + ApprovalHistoryDto({ + this.id, + required this.action, + this.fromStatus, + required this.toStatus, + required this.approver, + required this.actionAt, + this.note, + }); + + final int? id; + final ApprovalActionDto action; + final ApprovalStatusDto? fromStatus; + final ApprovalStatusDto toStatus; + final ApprovalApproverDto approver; + final DateTime actionAt; + final String? note; + + factory ApprovalHistoryDto.fromJson(Map json) { + return ApprovalHistoryDto( + id: json['id'] as int?, + action: ApprovalActionDto.fromJson( + (json['action'] as Map? ?? const {}), + ), + fromStatus: json['from_status'] is Map + ? ApprovalStatusDto.fromJson( + json['from_status'] as Map, + ) + : null, + toStatus: ApprovalStatusDto.fromJson( + (json['to_status'] as Map? ?? const {}), + ), + approver: ApprovalApproverDto.fromJson( + (json['approver'] as Map? ?? const {}), + ), + actionAt: _parseDate(json['action_at']) ?? DateTime.now(), + note: json['note'] as String?, + ); + } + + ApprovalHistory toEntity() => ApprovalHistory( + id: id, + action: action.toEntity(), + fromStatus: fromStatus?.toEntity(), + toStatus: toStatus.toEntity(), + approver: approver.toEntity(), + actionAt: actionAt, + note: note, + ); +} + +class ApprovalActionDto { + ApprovalActionDto({required this.id, required this.name}); + + final int id; + final String name; + + factory ApprovalActionDto.fromJson(Map json) { + return ApprovalActionDto( + id: json['id'] as int? ?? json['action_id'] as int? ?? 0, + name: json['name'] as String? ?? json['action_name'] as String? ?? '-', + ); + } + + ApprovalAction toEntity() => ApprovalAction(id: id, name: name); +} + +DateTime? _parseDate(Object? value) { + if (value == null) return null; + if (value is DateTime) return value; + if (value is String) return DateTime.tryParse(value); + return null; +} diff --git a/lib/features/approvals/data/dtos/approval_template_dto.dart b/lib/features/approvals/data/dtos/approval_template_dto.dart new file mode 100644 index 0000000..42ea3a5 --- /dev/null +++ b/lib/features/approvals/data/dtos/approval_template_dto.dart @@ -0,0 +1,149 @@ +import '../../domain/entities/approval_template.dart'; + +class ApprovalTemplateDto { + ApprovalTemplateDto({ + required this.id, + required this.code, + required this.name, + this.description, + required this.isActive, + this.createdBy, + this.createdAt, + this.updatedAt, + this.steps = const [], + }); + + final int id; + final String code; + final String name; + final String? description; + final bool isActive; + final ApprovalTemplateAuthorDto? createdBy; + final DateTime? createdAt; + final DateTime? updatedAt; + final List steps; + + factory ApprovalTemplateDto.fromJson(Map json) { + return ApprovalTemplateDto( + id: json['id'] as int? ?? 0, + code: json['template_code'] as String? ?? json['code'] as String? ?? '-', + name: json['template_name'] as String? ?? json['name'] as String? ?? '-', + description: json['description'] as String?, + isActive: (json['is_active'] as bool?) ?? true, + createdBy: json['created_by'] is Map + ? ApprovalTemplateAuthorDto.fromJson( + json['created_by'] as Map, + ) + : null, + createdAt: _parseDate(json['created_at']), + updatedAt: _parseDate(json['updated_at']), + steps: (json['steps'] as List? ?? []) + .whereType>() + .map(ApprovalTemplateStepDto.fromJson) + .toList(), + ); + } + + ApprovalTemplate toEntity({bool includeSteps = true}) { + return ApprovalTemplate( + id: id, + code: code, + name: name, + description: description, + isActive: isActive, + createdBy: createdBy?.toEntity(), + createdAt: createdAt, + updatedAt: updatedAt, + steps: includeSteps ? steps.map((e) => e.toEntity()).toList() : const [], + ); + } +} + +class ApprovalTemplateAuthorDto { + ApprovalTemplateAuthorDto({ + required this.id, + required this.employeeNo, + required this.name, + }); + + final int id; + final String employeeNo; + final String name; + + factory ApprovalTemplateAuthorDto.fromJson(Map json) { + return ApprovalTemplateAuthorDto( + id: json['id'] as int? ?? json['employee_id'] as int? ?? 0, + employeeNo: json['employee_no'] as String? ?? '-', + name: json['employee_name'] as String? ?? json['name'] as String? ?? '-', + ); + } + + ApprovalTemplateAuthor toEntity() { + return ApprovalTemplateAuthor(id: id, employeeNo: employeeNo, name: name); + } +} + +class ApprovalTemplateStepDto { + ApprovalTemplateStepDto({ + this.id, + required this.stepOrder, + required this.approver, + this.note, + }); + + final int? id; + final int stepOrder; + final ApprovalTemplateApproverDto approver; + final String? note; + + factory ApprovalTemplateStepDto.fromJson(Map json) { + return ApprovalTemplateStepDto( + id: json['id'] as int?, + stepOrder: json['step_order'] as int? ?? 0, + approver: ApprovalTemplateApproverDto.fromJson( + (json['approver'] as Map? ?? const {}), + ), + note: json['note'] as String?, + ); + } + + ApprovalTemplateStep toEntity() { + return ApprovalTemplateStep( + id: id, + stepOrder: stepOrder, + approver: approver.toEntity(), + note: note, + ); + } +} + +class ApprovalTemplateApproverDto { + ApprovalTemplateApproverDto({ + required this.id, + required this.employeeNo, + required this.name, + }); + + final int id; + final String employeeNo; + final String name; + + factory ApprovalTemplateApproverDto.fromJson(Map json) { + return ApprovalTemplateApproverDto( + id: json['id'] as int? ?? json['approver_id'] as int? ?? 0, + employeeNo: json['employee_no'] as String? ?? '-', + name: json['employee_name'] as String? ?? json['name'] as String? ?? '-', + ); + } + + ApprovalTemplateApprover toEntity() { + return ApprovalTemplateApprover(id: id, employeeNo: employeeNo, name: name); + } +} + +DateTime? _parseDate(Object? value) { + if (value == null) return null; + if (value is DateTime) return value; + if (value is String) return DateTime.tryParse(value); + return null; +} diff --git a/lib/features/approvals/data/repositories/approval_repository_remote.dart b/lib/features/approvals/data/repositories/approval_repository_remote.dart new file mode 100644 index 0000000..94f69ad --- /dev/null +++ b/lib/features/approvals/data/repositories/approval_repository_remote.dart @@ -0,0 +1,168 @@ +import 'package:dio/dio.dart'; +import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/core/network/api_client.dart'; + +import '../../domain/entities/approval.dart'; +import '../../domain/repositories/approval_repository.dart'; +import '../dtos/approval_dto.dart'; + +class ApprovalRepositoryRemote implements ApprovalRepository { + ApprovalRepositoryRemote({required ApiClient apiClient}) : _api = apiClient; + + final ApiClient _api; + + static const _basePath = '/approvals'; + + @override + Future> list({ + int page = 1, + int pageSize = 20, + String? query, + String? status, + DateTime? from, + DateTime? to, + bool includeHistories = false, + bool includeSteps = false, + }) async { + final response = await _api.get>( + _basePath, + query: { + 'page': page, + 'page_size': pageSize, + if (query != null && query.isNotEmpty) 'q': query, + if (status != null && status.isNotEmpty) 'status': status, + if (from != null) 'from': from.toIso8601String(), + if (to != null) 'to': to.toIso8601String(), + if (includeHistories) 'include_histories': true, + if (includeSteps) 'include_steps': true, + }, + options: Options(responseType: ResponseType.json), + ); + return ApprovalDto.parsePaginated(response.data ?? const {}); + } + + @override + Future fetchDetail( + int id, { + bool includeSteps = true, + bool includeHistories = true, + }) async { + final response = await _api.get>( + '$_basePath/$id', + query: { + if (includeSteps) 'include_steps': true, + if (includeHistories) 'include_histories': true, + }, + options: Options(responseType: ResponseType.json), + ); + final data = (response.data?['data'] as Map?) ?? {}; + return ApprovalDto.fromJson(data).toEntity(); + } + + @override + Future> listActions({bool activeOnly = true}) async { + final response = await _api.get>( + '/approval-actions', + query: {'page': 1, 'page_size': 100, if (activeOnly) 'active': true}, + options: Options(responseType: ResponseType.json), + ); + final items = (response.data?['items'] as List? ?? []) + .whereType>() + .map(ApprovalActionDto.fromJson) + .map((dto) => dto.toEntity()) + .toList(); + return items; + } + + @override + Future performStepAction(ApprovalStepActionInput input) async { + final response = await _api.post>( + '/approval-steps/${input.stepId}/actions', + data: input.toPayload(), + options: Options(responseType: ResponseType.json), + ); + final approvalJson = _extractApprovalFromActionResponse( + response.data ?? const {}, + ); + if (approvalJson == null) { + throw StateError('결재 단계 행위 응답에 결재 데이터가 없습니다.'); + } + return ApprovalDto.fromJson(approvalJson).toEntity(); + } + + @override + Future assignSteps(ApprovalStepAssignmentInput input) async { + final response = await _api.post>( + '/approvals/${input.approvalId}/steps', + data: input.toPayload(), + options: Options(responseType: ResponseType.json), + ); + final approvalJson = _extractApprovalFromActionResponse( + response.data ?? const {}, + ); + if (approvalJson == null) { + throw StateError('결재 단계 일괄 처리 응답에 결재 데이터가 없습니다.'); + } + return ApprovalDto.fromJson(approvalJson).toEntity(); + } + + @override + Future create(ApprovalInput input) async { + final response = await _api.post>( + _basePath, + data: input.toPayload(), + options: Options(responseType: ResponseType.json), + ); + final data = (response.data?['data'] as Map?) ?? {}; + return ApprovalDto.fromJson(data).toEntity(); + } + + @override + Future update(int id, ApprovalInput input) async { + final response = await _api.patch>( + '$_basePath/$id', + data: input.toPayload(), + options: Options(responseType: ResponseType.json), + ); + final data = (response.data?['data'] as Map?) ?? {}; + return ApprovalDto.fromJson(data).toEntity(); + } + + @override + Future delete(int id) async { + await _api.delete('$_basePath/$id'); + } + + @override + Future restore(int id) async { + final response = await _api.post>( + '$_basePath/$id/restore', + options: Options(responseType: ResponseType.json), + ); + final data = (response.data?['data'] as Map?) ?? {}; + return ApprovalDto.fromJson(data).toEntity(); + } + + Map? _extractApprovalFromActionResponse( + Map body, + ) { + final data = body['data']; + if (data is Map) { + if (data['approval'] is Map) { + return data['approval'] as Map; + } + if (data['approval_data'] is Map) { + return data['approval_data'] as Map; + } + final hasStatus = + data.containsKey('status') || data.containsKey('approval_status'); + if (data.containsKey('approval_no') && hasStatus) { + return data; + } + } + if (body['approval'] is Map) { + return body['approval'] as Map; + } + return null; + } +} diff --git a/lib/features/approvals/data/repositories/approval_template_repository_remote.dart b/lib/features/approvals/data/repositories/approval_template_repository_remote.dart new file mode 100644 index 0000000..d3a9298 --- /dev/null +++ b/lib/features/approvals/data/repositories/approval_template_repository_remote.dart @@ -0,0 +1,46 @@ +import 'package:dio/dio.dart'; + +import '../../../../core/network/api_client.dart'; +import '../../domain/entities/approval_template.dart'; +import '../../domain/repositories/approval_template_repository.dart'; +import '../dtos/approval_template_dto.dart'; + +class ApprovalTemplateRepositoryRemote implements ApprovalTemplateRepository { + ApprovalTemplateRepositoryRemote({required ApiClient apiClient}) + : _api = apiClient; + + final ApiClient _api; + + static const _basePath = '/approval-templates'; + + @override + Future> list({bool activeOnly = true}) async { + final response = await _api.get>( + _basePath, + query: {'page': 1, 'page_size': 100, if (activeOnly) 'active': true}, + options: Options(responseType: ResponseType.json), + ); + final items = (response.data?['items'] as List? ?? []) + .whereType>() + .map(ApprovalTemplateDto.fromJson) + .map((dto) => dto.toEntity(includeSteps: false)) + .toList(); + return items; + } + + @override + Future fetchDetail( + int id, { + bool includeSteps = true, + }) async { + final response = await _api.get>( + '$_basePath/$id', + query: {if (includeSteps) 'include': 'steps'}, + options: Options(responseType: ResponseType.json), + ); + final data = (response.data?['data'] as Map?) ?? {}; + return ApprovalTemplateDto.fromJson( + data, + ).toEntity(includeSteps: includeSteps); + } +} diff --git a/lib/features/approvals/domain/entities/approval.dart b/lib/features/approvals/domain/entities/approval.dart new file mode 100644 index 0000000..1accb37 --- /dev/null +++ b/lib/features/approvals/domain/entities/approval.dart @@ -0,0 +1,248 @@ +/// 결재(Approval) 엔티티 +/// +/// - 결재 기본 정보와 현재 단계, 라인(단계/이력) 데이터를 포함한다. +/// - presentation/data 레이어 구현에 의존하지 않는다. +class Approval { + Approval({ + this.id, + required this.approvalNo, + required this.transactionNo, + required this.status, + this.currentStep, + required this.requester, + required this.requestedAt, + this.decidedAt, + this.note, + this.isActive = true, + this.isDeleted = false, + this.steps = const [], + this.histories = const [], + this.createdAt, + this.updatedAt, + }); + + final int? id; + final String approvalNo; + final String transactionNo; + final ApprovalStatus status; + final ApprovalStep? currentStep; + final ApprovalRequester requester; + final DateTime requestedAt; + final DateTime? decidedAt; + final String? note; + final bool isActive; + final bool isDeleted; + final List steps; + final List histories; + final DateTime? createdAt; + final DateTime? updatedAt; + + Approval copyWith({ + int? id, + String? approvalNo, + String? transactionNo, + ApprovalStatus? status, + ApprovalStep? currentStep, + ApprovalRequester? requester, + DateTime? requestedAt, + DateTime? decidedAt, + String? note, + bool? isActive, + bool? isDeleted, + List? steps, + List? histories, + DateTime? createdAt, + DateTime? updatedAt, + }) { + return Approval( + id: id ?? this.id, + approvalNo: approvalNo ?? this.approvalNo, + transactionNo: transactionNo ?? this.transactionNo, + status: status ?? this.status, + currentStep: currentStep ?? this.currentStep, + requester: requester ?? this.requester, + requestedAt: requestedAt ?? this.requestedAt, + decidedAt: decidedAt ?? this.decidedAt, + note: note ?? this.note, + isActive: isActive ?? this.isActive, + isDeleted: isDeleted ?? this.isDeleted, + steps: steps ?? this.steps, + histories: histories ?? this.histories, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } +} + +class ApprovalStatus { + ApprovalStatus({required this.id, required this.name, this.color}); + + final int id; + final String name; + final String? color; +} + +class ApprovalRequester { + ApprovalRequester({ + required this.id, + required this.employeeNo, + required this.name, + }); + + final int id; + final String employeeNo; + final String name; +} + +class ApprovalStep { + ApprovalStep({ + this.id, + required this.stepOrder, + required this.approver, + required this.status, + required this.assignedAt, + this.decidedAt, + this.note, + }); + + final int? id; + final int stepOrder; + final ApprovalApprover approver; + final ApprovalStatus status; + final DateTime assignedAt; + final DateTime? decidedAt; + final String? note; +} + +class ApprovalApprover { + ApprovalApprover({ + required this.id, + required this.employeeNo, + required this.name, + }); + + final int id; + final String employeeNo; + final String name; +} + +class ApprovalHistory { + ApprovalHistory({ + this.id, + required this.action, + this.fromStatus, + required this.toStatus, + required this.approver, + required this.actionAt, + this.note, + }); + + final int? id; + final ApprovalAction action; + final ApprovalStatus? fromStatus; + final ApprovalStatus toStatus; + final ApprovalApprover approver; + final DateTime actionAt; + final String? note; +} + +class ApprovalAction { + ApprovalAction({required this.id, required this.name}); + + final int id; + final String name; +} + +/// 결재 단계에서 수행 가능한 행위 타입 +/// +/// - API `approval_actions` 테이블의 대표 코드와 매핑된다. +/// - UI에서는 이 타입을 기반으로 표시 라벨과 권한을 제어한다. +enum ApprovalStepActionType { approve, reject, comment } + +extension ApprovalStepActionTypeX on ApprovalStepActionType { + /// API 호출 시 사용되는 행위 코드 + String get code { + switch (this) { + case ApprovalStepActionType.approve: + return 'approve'; + case ApprovalStepActionType.reject: + return 'reject'; + case ApprovalStepActionType.comment: + return 'comment'; + } + } +} + +/// 결재 생성 입력 모델 +class ApprovalInput { + ApprovalInput({required this.transactionId, this.note}); + + final int transactionId; + final String? note; + + Map toPayload() { + return {'transaction_id': transactionId, 'note': note}; + } +} + +/// 결재 단계 행위 입력 모델 +/// +/// - `POST /approval-steps/{id}/actions` 요청 바디를 구성한다. +/// - `note`는 비고가 있을 때만 포함한다. +class ApprovalStepActionInput { + ApprovalStepActionInput({ + required this.stepId, + required this.actionId, + this.note, + }); + + final int stepId; + final int actionId; + final String? note; + + Map toPayload() { + return { + 'id': stepId, + 'approval_action_id': actionId, + if (note != null && note!.trim().isNotEmpty) 'note': note, + }; + } +} + +/// 결재 단계를 일괄 등록/재배치하기 위한 입력 모델 +class ApprovalStepAssignmentInput { + ApprovalStepAssignmentInput({ + required this.approvalId, + required this.steps, + }); + + final int approvalId; + final List steps; + + Map toPayload() { + return { + 'id': approvalId, + 'steps': steps.map((e) => e.toJson()).toList(), + }; + } +} + +class ApprovalStepAssignmentItem { + ApprovalStepAssignmentItem({ + required this.stepOrder, + required this.approverId, + this.note, + }); + + final int stepOrder; + final int approverId; + final String? note; + + Map toJson() { + return { + 'step_order': stepOrder, + 'approver_id': approverId, + if (note != null && note!.trim().isNotEmpty) 'note': note, + }; + } +} diff --git a/lib/features/approvals/domain/entities/approval_template.dart b/lib/features/approvals/domain/entities/approval_template.dart new file mode 100644 index 0000000..00fd067 --- /dev/null +++ b/lib/features/approvals/domain/entities/approval_template.dart @@ -0,0 +1,64 @@ +/// 결재 템플릿 엔티티 +/// +/// - 반복되는 결재 단계를 사전에 정의해두고 요청 시 불러온다. +class ApprovalTemplate { + ApprovalTemplate({ + required this.id, + required this.code, + required this.name, + this.description, + required this.isActive, + this.createdBy, + this.createdAt, + this.updatedAt, + this.steps = const [], + }); + + final int id; + final String code; + final String name; + final String? description; + final bool isActive; + final ApprovalTemplateAuthor? createdBy; + final DateTime? createdAt; + final DateTime? updatedAt; + final List steps; +} + +class ApprovalTemplateAuthor { + ApprovalTemplateAuthor({ + required this.id, + required this.employeeNo, + required this.name, + }); + + final int id; + final String employeeNo; + final String name; +} + +class ApprovalTemplateStep { + ApprovalTemplateStep({ + this.id, + required this.stepOrder, + required this.approver, + this.note, + }); + + final int? id; + final int stepOrder; + final ApprovalTemplateApprover approver; + final String? note; +} + +class ApprovalTemplateApprover { + ApprovalTemplateApprover({ + required this.id, + required this.employeeNo, + required this.name, + }); + + final int id; + final String employeeNo; + final String name; +} diff --git a/lib/features/approvals/domain/repositories/approval_repository.dart b/lib/features/approvals/domain/repositories/approval_repository.dart new file mode 100644 index 0000000..8f7b51d --- /dev/null +++ b/lib/features/approvals/domain/repositories/approval_repository.dart @@ -0,0 +1,39 @@ +import 'package:superport_v2/core/common/models/paginated_result.dart'; + +import '../entities/approval.dart'; + +abstract class ApprovalRepository { + Future> list({ + int page = 1, + int pageSize = 20, + String? query, + String? status, + DateTime? from, + DateTime? to, + bool includeHistories = false, + bool includeSteps = false, + }); + + Future fetchDetail( + int id, { + bool includeSteps = true, + bool includeHistories = true, + }); + + /// 활성화된 결재 행위(approve/reject/comment 등) 목록 조회 + Future> listActions({bool activeOnly = true}); + + /// 결재 단계에 행위를 적용하고 최신 결재 정보를 반환 + Future performStepAction(ApprovalStepActionInput input); + + /// 결재 단계 일괄 생성/재배치 + Future assignSteps(ApprovalStepAssignmentInput input); + + Future create(ApprovalInput input); + + Future update(int id, ApprovalInput input); + + Future delete(int id); + + Future restore(int id); +} diff --git a/lib/features/approvals/domain/repositories/approval_template_repository.dart b/lib/features/approvals/domain/repositories/approval_template_repository.dart new file mode 100644 index 0000000..e21e2c0 --- /dev/null +++ b/lib/features/approvals/domain/repositories/approval_template_repository.dart @@ -0,0 +1,7 @@ +import '../entities/approval_template.dart'; + +abstract class ApprovalTemplateRepository { + Future> list({bool activeOnly = true}); + + Future fetchDetail(int id, {bool includeSteps = true}); +} 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 764405d..9ac69d6 100644 --- a/lib/features/approvals/history/presentation/pages/approval_history_page.dart +++ b/lib/features/approvals/history/presentation/pages/approval_history_page.dart @@ -1,5 +1,10 @@ -import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart'; +import '../../../../../core/config/environment.dart'; +import '../../../../../core/constants/app_sections.dart'; +import '../../../../../widgets/app_layout.dart'; +import '../../../../../widgets/components/coming_soon_card.dart'; +import '../../../../../widgets/components/filter_bar.dart'; import '../../../../../widgets/spec_page.dart'; class ApprovalHistoryPage extends StatelessWidget { @@ -7,41 +12,62 @@ class ApprovalHistoryPage extends StatelessWidget { @override Widget build(BuildContext context) { - return const SpecPage( - title: '결재 이력 조회', - summary: '결재 단계별 변경 이력을 조회합니다.', - sections: [ - SpecSection( - title: '조회 테이블', - description: '수정 없이 이력 리스트만 제공.', - table: SpecTable( - columns: [ - '번호', - '결재ID', - '단계ID', - '승인자', - '행위', - '변경전상태', - '변경후상태', - '작업일시', - '비고', - ], - rows: [ - [ - '1', - 'APP-20240301-001', - 'STEP-1', - '최관리', - '승인', - '승인대기', - '승인완료', - '2024-03-01 10:30', - '-', + final enabled = Environment.flag('FEATURE_APPROVALS_ENABLED'); + if (!enabled) { + return const SpecPage( + title: '결재 이력 조회', + summary: '결재 단계별 변경 이력을 조회합니다.', + sections: [ + SpecSection( + title: '조회 테이블', + description: '수정 없이 이력 리스트만 제공.', + table: SpecTable( + columns: [ + '번호', + '결재ID', + '단계ID', + '승인자', + '행위', + '변경전상태', + '변경후상태', + '작업일시', + '비고', ], - ], + rows: [ + [ + '1', + 'APP-20240301-001', + 'STEP-1', + '최관리', + '승인', + '승인대기', + '승인완료', + '2024-03-01 10:30', + '-', + ], + ], + ), ), - ), + ], + ); + } + + return AppLayout( + title: '결재 이력 조회', + subtitle: '결재 단계별 변경 기록을 확인할 수 있도록 준비 중입니다.', + breadcrumbs: const [ + AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath), + AppBreadcrumbItem(label: '결재', path: '/approvals/history'), + AppBreadcrumbItem(label: '결재 이력'), ], + toolbar: FilterBar( + children: const [Text('이력 검색 조건은 API 사양 확정 후 제공될 예정입니다.')], + ), + child: const ComingSoonCard( + title: '결재 이력 화면 구현 준비 중', + description: '결재 단계 로그 API와 연동해 조건 검색 및 엑셀 내보내기를 제공할 예정입니다.', + items: ['결재번호/승인자/행위 유형별 필터', '기간·상태 조건 조합 검색', '다운로드(Excel/PDF) 기능'], + ), ); } } diff --git a/lib/features/approvals/presentation/controllers/approval_controller.dart b/lib/features/approvals/presentation/controllers/approval_controller.dart new file mode 100644 index 0000000..3627e5d --- /dev/null +++ b/lib/features/approvals/presentation/controllers/approval_controller.dart @@ -0,0 +1,319 @@ +import 'package:flutter/foundation.dart'; +import 'package:superport_v2/core/common/models/paginated_result.dart'; + +import '../../domain/entities/approval.dart'; +import '../../domain/entities/approval_template.dart'; +import '../../domain/repositories/approval_repository.dart'; +import '../../domain/repositories/approval_template_repository.dart'; + +enum ApprovalStatusFilter { + all, + pending, + inProgress, + onHold, + approved, + rejected, +} + +typedef DateRange = ({DateTime from, DateTime to}); + +const Map> _actionAliases = { + ApprovalStepActionType.approve: ['approve', '승인'], + ApprovalStepActionType.reject: ['reject', '반려'], + ApprovalStepActionType.comment: ['comment', '코멘트', '의견'], +}; + +/// 결재 목록 및 상세 화면 상태 컨트롤러 +/// +/// - 목록 조회/필터 상태와 선택된 결재 상세 데이터를 관리한다. +/// - 승인/반려 등의 후속 액션은 추후 구현 시 추가한다. +class ApprovalController extends ChangeNotifier { + ApprovalController({ + required ApprovalRepository approvalRepository, + required ApprovalTemplateRepository templateRepository, + }) : _repository = approvalRepository, + _templateRepository = templateRepository; + + final ApprovalRepository _repository; + final ApprovalTemplateRepository _templateRepository; + + PaginatedResult? _result; + Approval? _selected; + bool _isLoadingList = false; + bool _isLoadingDetail = false; + bool _isLoadingActions = false; + bool _isPerformingAction = false; + int? _processingStepId; + bool _isLoadingTemplates = false; + bool _isApplyingTemplate = false; + int? _applyingTemplateId; + String? _errorMessage; + String _query = ''; + ApprovalStatusFilter _statusFilter = ApprovalStatusFilter.all; + DateTime? _fromDate; + DateTime? _toDate; + List _actions = const []; + List _templates = const []; + + PaginatedResult? get result => _result; + Approval? get selected => _selected; + bool get isLoadingList => _isLoadingList; + bool get isLoadingDetail => _isLoadingDetail; + bool get isLoadingActions => _isLoadingActions; + bool get isPerformingAction => _isPerformingAction; + int? get processingStepId => _processingStepId; + String? get errorMessage => _errorMessage; + String get query => _query; + ApprovalStatusFilter get statusFilter => _statusFilter; + DateTime? get fromDate => _fromDate; + DateTime? get toDate => _toDate; + List get actionOptions => _actions; + bool get hasActionOptions => _actions.isNotEmpty; + List get templates => _templates; + bool get isLoadingTemplates => _isLoadingTemplates; + bool get isApplyingTemplate => _isApplyingTemplate; + int? get applyingTemplateId => _applyingTemplateId; + + Future fetch({int page = 1}) async { + _isLoadingList = true; + _errorMessage = null; + notifyListeners(); + try { + final statusParam = switch (_statusFilter) { + ApprovalStatusFilter.all => null, + ApprovalStatusFilter.pending => 'pending', + ApprovalStatusFilter.inProgress => 'in_progress', + ApprovalStatusFilter.onHold => 'on_hold', + ApprovalStatusFilter.approved => 'approved', + ApprovalStatusFilter.rejected => 'rejected', + }; + final response = await _repository.list( + page: page, + pageSize: _result?.pageSize ?? 20, + query: _query.isEmpty ? null : _query, + status: statusParam, + from: _fromDate, + to: _toDate, + includeSteps: false, + includeHistories: false, + ); + _result = response; + if (_selected != null) { + final exists = response.items.any((item) => item.id == _selected?.id); + if (!exists) { + _selected = null; + } + } + } catch (e) { + _errorMessage = e.toString(); + } finally { + _isLoadingList = false; + notifyListeners(); + } + } + + Future loadActionOptions({bool force = false}) async { + if (_actions.isNotEmpty && !force) { + return; + } + _isLoadingActions = true; + _errorMessage = null; + notifyListeners(); + try { + final items = await _repository.listActions(activeOnly: true); + _actions = items; + } catch (e) { + _errorMessage = e.toString(); + } finally { + _isLoadingActions = false; + notifyListeners(); + } + } + + Future loadTemplates({bool force = false}) async { + if (_templates.isNotEmpty && !force) { + return; + } + _isLoadingTemplates = true; + _errorMessage = null; + notifyListeners(); + try { + final items = await _templateRepository.list(activeOnly: true); + _templates = items; + } catch (e) { + _errorMessage = e.toString(); + } finally { + _isLoadingTemplates = false; + notifyListeners(); + } + } + + Future selectApproval(int id) async { + _isLoadingDetail = true; + _errorMessage = null; + notifyListeners(); + try { + final detail = await _repository.fetchDetail( + id, + includeSteps: true, + includeHistories: true, + ); + _selected = detail; + } catch (e) { + _errorMessage = e.toString(); + } finally { + _isLoadingDetail = false; + notifyListeners(); + } + } + + void clearSelection() { + _selected = null; + notifyListeners(); + } + + Future performStepAction({ + required ApprovalStep step, + required ApprovalStepActionType type, + String? note, + }) async { + if (step.id == null) { + _errorMessage = '단계 식별자가 없어 행위를 수행할 수 없습니다.'; + notifyListeners(); + return false; + } + final action = _findActionByType(type); + if (action == null) { + _errorMessage = '사용 가능한 결재 행위를 찾을 수 없습니다.'; + notifyListeners(); + return false; + } + + _isPerformingAction = true; + _processingStepId = step.id; + _errorMessage = null; + notifyListeners(); + try { + final sanitizedNote = note?.trim(); + final updated = await _repository.performStepAction( + ApprovalStepActionInput( + stepId: step.id!, + actionId: action.id, + note: sanitizedNote?.isEmpty ?? true ? null : sanitizedNote, + ), + ); + _selected = updated; + if (_result != null && updated.id != null) { + final items = _result!.items + .map((item) => item.id == updated.id ? updated : item) + .toList(); + _result = _result!.copyWith(items: items); + } + return true; + } catch (e) { + _errorMessage = e.toString(); + return false; + } finally { + _isPerformingAction = false; + _processingStepId = null; + notifyListeners(); + } + } + + Future applyTemplate(int templateId) async { + final approvalId = _selected?.id; + if (approvalId == null) { + _errorMessage = '선택된 결재가 없어 템플릿을 적용할 수 없습니다.'; + notifyListeners(); + return false; + } + + _isApplyingTemplate = true; + _applyingTemplateId = templateId; + _errorMessage = null; + notifyListeners(); + try { + final template = await _templateRepository.fetchDetail( + templateId, + includeSteps: true, + ); + if (template.steps.isEmpty) { + _errorMessage = '선택한 템플릿에 등록된 단계가 없습니다.'; + return false; + } + + final sortedSteps = List.of(template.steps) + ..sort((a, b) => a.stepOrder.compareTo(b.stepOrder)); + final input = ApprovalStepAssignmentInput( + approvalId: approvalId, + steps: sortedSteps + .map( + (step) => ApprovalStepAssignmentItem( + stepOrder: step.stepOrder, + approverId: step.approver.id, + note: step.note, + ), + ) + .toList(), + ); + final updated = await _repository.assignSteps(input); + _selected = updated; + if (_result != null && updated.id != null) { + final items = _result!.items + .map((item) => item.id == updated.id ? updated : item) + .toList(); + _result = _result!.copyWith(items: items); + } + return true; + } catch (e) { + _errorMessage = e.toString(); + return false; + } finally { + _isApplyingTemplate = false; + _applyingTemplateId = null; + notifyListeners(); + } + } + + void updateQuery(String value) { + _query = value; + notifyListeners(); + } + + void updateStatusFilter(ApprovalStatusFilter filter) { + _statusFilter = filter; + notifyListeners(); + } + + void updateDateRange(DateTime? from, DateTime? to) { + _fromDate = from; + _toDate = to; + notifyListeners(); + } + + void clearFilters() { + _query = ''; + _statusFilter = ApprovalStatusFilter.all; + _fromDate = null; + _toDate = null; + notifyListeners(); + } + + void clearError() { + _errorMessage = null; + notifyListeners(); + } + + ApprovalAction? _findActionByType(ApprovalStepActionType type) { + final aliases = _actionAliases[type] ?? [type.code]; + for (final action in _actions) { + final normalized = action.name.toLowerCase(); + for (final alias in aliases) { + if (normalized == alias.toLowerCase()) { + return action; + } + } + } + return null; + } +} diff --git a/lib/features/approvals/presentation/pages/approval_page.dart b/lib/features/approvals/presentation/pages/approval_page.dart new file mode 100644 index 0000000..11a3a29 --- /dev/null +++ b/lib/features/approvals/presentation/pages/approval_page.dart @@ -0,0 +1,1466 @@ +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import 'package:intl/intl.dart' as intl; +import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import '../../../../core/config/environment.dart'; +import '../../../../core/constants/app_sections.dart'; +import '../../../../widgets/app_layout.dart'; +import '../../../../widgets/components/filter_bar.dart'; +import '../../../../widgets/spec_page.dart'; +import '../../domain/entities/approval.dart'; +import '../../domain/entities/approval_template.dart'; +import '../../domain/repositories/approval_repository.dart'; +import '../../domain/repositories/approval_template_repository.dart'; +import '../controllers/approval_controller.dart'; + +class ApprovalPage extends StatelessWidget { + const ApprovalPage({super.key}); + + @override + Widget build(BuildContext context) { + final enabled = Environment.flag('FEATURE_APPROVALS_ENABLED'); + if (!enabled) { + return SpecPage( + title: '결재 관리', + summary: '결재 요청 상태와 단계/이력을 모니터링합니다.', + trailing: ShadBadge( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: const [ + Icon(lucide.LucideIcons.info, size: 14), + SizedBox(width: 6), + Text('비활성화 (백엔드 준비 중)'), + ], + ), + ), + ), + sections: const [ + SpecSection( + title: '입력 폼', + items: [ + '트랜잭션번호 [Dropdown]', + '상신자 [ReadOnly]', + '결재상태 [Dropdown]', + '비고 [Textarea]', + ], + ), + SpecSection( + title: '상세 패널', + items: [ + '개요 탭: 현재 상태/단계/요청·결정 일시', + '단계 탭: 단계 리스트 + 템플릿 불러오기', + '이력 탭: 행위/상태 변경/일시/비고', + ], + ), + SpecSection( + title: '테이블 리스트', + description: '1행 예시', + table: SpecTable( + columns: [ + '번호', + '결재번호', + '트랜잭션번호', + '상태', + '상신자', + '요청일시', + '최종결정일시', + '비고', + ], + rows: [ + [ + '1', + 'AP-24001', + 'TRX-202404-01', + '대기', + '김철수', + '2024-04-01 09:12', + '-', + '-', + ], + ], + ), + ), + ], + ); + } + + return const _ApprovalEnabledPage(); + } +} + +class _ApprovalEnabledPage extends StatefulWidget { + const _ApprovalEnabledPage(); + + @override + State<_ApprovalEnabledPage> createState() => _ApprovalEnabledPageState(); +} + +class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> { + late final ApprovalController _controller; + final TextEditingController _searchController = TextEditingController(); + final FocusNode _searchFocus = FocusNode(); + final intl.DateFormat _dateFormat = intl.DateFormat('yyyy-MM-dd'); + final intl.DateFormat _dateTimeFormat = intl.DateFormat('yyyy-MM-dd HH:mm'); + DateTimeRange? _dateRange; + String? _lastError; + int? _selectedTemplateId; + + @override + void initState() { + super.initState(); + _controller = ApprovalController( + approvalRepository: GetIt.I(), + templateRepository: GetIt.I(), + )..addListener(_handleControllerUpdate); + WidgetsBinding.instance.addPostFrameCallback((_) async { + await Future.wait([ + _controller.loadActionOptions(), + _controller.loadTemplates(), + ]); + await _controller.fetch(); + }); + } + + void _handleControllerUpdate() { + final error = _controller.errorMessage; + if (error != null && error != _lastError && mounted) { + _lastError = error; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(error))); + _controller.clearError(); + } + } + + @override + void dispose() { + _controller.removeListener(_handleControllerUpdate); + _controller.dispose(); + _searchController.dispose(); + _searchFocus.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + + return AnimatedBuilder( + animation: _controller, + builder: (context, _) { + final result = _controller.result; + final approvals = result?.items ?? const []; + final selectedApproval = _controller.selected; + final totalCount = result?.total ?? 0; + final currentPage = result?.page ?? 1; + final totalPages = result == null || result.pageSize == 0 + ? 1 + : (result.total / result.pageSize).ceil().clamp(1, 9999); + final hasNext = result == null + ? false + : (result.page * result.pageSize) < result.total; + final isLoadingActions = _controller.isLoadingActions; + final isPerformingAction = _controller.isPerformingAction; + final processingStepId = _controller.processingStepId; + final hasActionOptions = _controller.hasActionOptions; + final templates = _controller.templates; + final isLoadingTemplates = _controller.isLoadingTemplates; + final isApplyingTemplate = _controller.isApplyingTemplate; + final applyingTemplateId = _controller.applyingTemplateId; + + if (templates.isNotEmpty && _selectedTemplateId == null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + setState(() => _selectedTemplateId = templates.first.id); + }); + } else if (_selectedTemplateId != null && + templates.every((template) => template.id != _selectedTemplateId)) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + setState(() => _selectedTemplateId = null); + }); + } + + return AppLayout( + title: '결재 관리', + subtitle: '결재 요청 상태와 단계/이력을 한 화면에서 확인합니다.', + breadcrumbs: const [ + AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath), + AppBreadcrumbItem(label: '결재', path: '/approvals/requests'), + AppBreadcrumbItem(label: '결재 관리'), + ], + actions: [ + ShadButton( + leading: const Icon(lucide.LucideIcons.plus, size: 16), + onPressed: () {}, + child: const Text('신규 결재'), + ), + ], + toolbar: FilterBar( + children: [ + SizedBox( + width: 260, + child: ShadInput( + controller: _searchController, + focusNode: _searchFocus, + placeholder: const Text('결재번호, 트랜잭션번호, 상신자 검색'), + leading: const Icon(lucide.LucideIcons.search, size: 16), + onChanged: (_) => setState(() {}), + onSubmitted: (_) => _applyFilters(), + ), + ), + SizedBox( + width: 200, + child: ShadSelect( + key: ValueKey(_controller.statusFilter), + initialValue: _controller.statusFilter, + selectedOptionBuilder: (context, value) => + Text(_statusLabel(value)), + onChanged: (value) { + if (value == null) return; + _controller.updateStatusFilter(value); + _controller.fetch(page: 1); + }, + options: ApprovalStatusFilter.values + .map( + (filter) => ShadOption( + value: filter, + child: Text(_statusLabel(filter)), + ), + ) + .toList(), + ), + ), + SizedBox( + width: 220, + child: ShadButton.outline( + onPressed: _pickDateRange, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(lucide.LucideIcons.calendar, size: 16), + const SizedBox(width: 8), + Text( + _dateRange == null + ? '기간 선택' + : '${_dateFormat.format(_dateRange!.start)} ~ ${_dateFormat.format(_dateRange!.end)}', + ), + ], + ), + ), + ), + if (_dateRange != null) + ShadButton.ghost( + onPressed: () { + setState(() => _dateRange = null); + _controller.updateDateRange(null, null); + _controller.fetch(page: 1); + }, + child: const Text('기간 초기화'), + ), + ShadButton.outline( + onPressed: _controller.isLoadingList ? null : _applyFilters, + child: const Text('검색 적용'), + ), + ShadButton.ghost( + key: const ValueKey('approval_filter_reset'), + onPressed: _controller.isLoadingList + ? null + : () { + if (!_hasFilters()) return; + _searchController.clear(); + _searchFocus.requestFocus(); + _dateRange = null; + _controller.clearFilters(); + _controller.fetch(page: 1); + }, + child: const Text('필터 초기화'), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ShadCard( + title: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('결재 목록', style: theme.textTheme.h3), + Text('$totalCount건', style: theme.textTheme.muted), + ], + ), + footer: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '페이지 $currentPage / $totalPages', + style: theme.textTheme.small, + ), + Row( + children: [ + ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: + _controller.isLoadingList || currentPage <= 1 + ? null + : () => _controller.fetch(page: currentPage - 1), + child: const Text('이전'), + ), + const SizedBox(width: 8), + ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: _controller.isLoadingList || !hasNext + ? null + : () => _controller.fetch(page: currentPage + 1), + child: const Text('다음'), + ), + ], + ), + ], + ), + child: _controller.isLoadingList + ? const Padding( + padding: EdgeInsets.all(48), + child: Center(child: CircularProgressIndicator()), + ) + : approvals.isEmpty + ? Padding( + padding: const EdgeInsets.all(32), + child: Text( + '조건에 맞는 결재 내역이 없습니다.', + style: theme.textTheme.muted, + ), + ) + : _ApprovalTable( + approvals: approvals, + dateFormat: _dateTimeFormat, + onView: (approval) { + final id = approval.id; + if (id != null) { + _controller.selectApproval(id); + } + }, + ), + ), + const SizedBox(height: 24), + _DetailSection( + approval: selectedApproval, + isLoading: _controller.isLoadingDetail, + isLoadingActions: isLoadingActions, + isPerformingAction: isPerformingAction, + processingStepId: processingStepId, + hasActionOptions: hasActionOptions, + templates: templates, + isLoadingTemplates: isLoadingTemplates, + isApplyingTemplate: isApplyingTemplate, + applyingTemplateId: applyingTemplateId, + selectedTemplateId: _selectedTemplateId, + dateFormat: _dateTimeFormat, + onRefresh: () { + final id = selectedApproval?.id; + if (id != null) { + _controller.selectApproval(id); + } + }, + onClose: selectedApproval == null + ? null + : _controller.clearSelection, + onSelectTemplate: _handleSelectTemplate, + onApplyTemplate: _handleApplyTemplate, + onReloadTemplates: () => _controller.loadTemplates(force: true), + onAction: _handleStepAction, + ), + ], + ), + ); + }, + ); + } + + void _applyFilters() { + _controller.updateQuery(_searchController.text.trim()); + if (_dateRange != null) { + _controller.updateDateRange(_dateRange!.start, _dateRange!.end); + } + _controller.fetch(page: 1); + } + + bool _hasFilters() { + return _searchController.text.isNotEmpty || + _controller.statusFilter != ApprovalStatusFilter.all || + _dateRange != null; + } + + Future _pickDateRange() async { + final now = DateTime.now(); + final initial = + _dateRange ?? + DateTimeRange( + start: DateTime(now.year, now.month, now.day - 7), + end: now, + ); + final range = await showDateRangePicker( + context: context, + initialDateRange: initial, + firstDate: DateTime(now.year - 5), + lastDate: DateTime(now.year + 1), + ); + if (range != null) { + setState(() => _dateRange = range); + _controller.updateDateRange(range.start, range.end); + _controller.fetch(page: 1); + } + } + + Future _handleStepAction( + ApprovalStep step, + ApprovalStepActionType type, + ) async { + final result = await _showStepActionDialog(step, type); + if (result == null) { + return; + } + final success = await _controller.performStepAction( + step: step, + type: type, + note: result.note, + ); + if (!mounted || !success) { + return; + } + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(_successMessage(type)))); + } + + void _handleSelectTemplate(int? templateId) { + setState(() => _selectedTemplateId = templateId); + } + + Future _handleApplyTemplate(int templateId) async { + ApprovalTemplate? template; + for (final item in _controller.templates) { + if (item.id == templateId) { + template = item; + break; + } + } + if (template == null) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('선택한 템플릿 정보를 찾을 수 없습니다.'))); + return; + } + + final confirmed = await _showTemplateApplyConfirm(template); + if (!confirmed) { + return; + } + + final success = await _controller.applyTemplate(templateId); + if (!mounted || !success) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('템플릿 "${template.name}"을(를) 적용했습니다.')), + ); + } + + Future<_StepActionDialogResult?> _showStepActionDialog( + ApprovalStep step, + ApprovalStepActionType type, + ) async { + final noteController = TextEditingController(); + final requireNote = type == ApprovalStepActionType.comment; + final dialogResult = await showDialog<_StepActionDialogResult>( + context: context, + builder: (dialogContext) { + String? errorText; + return StatefulBuilder( + builder: (context, setState) { + final materialTheme = Theme.of(context); + final shadTheme = ShadTheme.of(context); + return AlertDialog( + title: Text(_dialogTitle(type)), + content: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '선택 단계: Step ${step.stepOrder}', + style: shadTheme.textTheme.small, + ), + const SizedBox(height: 4), + Text( + '승인자: ${step.approver.name}', + style: shadTheme.textTheme.small, + ), + const SizedBox(height: 4), + Text( + '현재 상태: ${step.status.name}', + style: shadTheme.textTheme.small, + ), + const SizedBox(height: 16), + Text('비고', style: shadTheme.textTheme.small), + const SizedBox(height: 8), + ShadTextarea( + controller: noteController, + minLines: 3, + maxLines: 5, + ), + if (requireNote) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + '코멘트에는 비고 입력이 필요합니다.', + style: shadTheme.textTheme.muted, + ), + ), + if (errorText != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + errorText!, + style: shadTheme.textTheme.small.copyWith( + color: materialTheme.colorScheme.error, + ), + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('취소'), + ), + FilledButton( + onPressed: () { + final note = noteController.text.trim(); + if (requireNote && note.isEmpty) { + setState(() => errorText = '비고를 입력하세요.'); + return; + } + Navigator.of(dialogContext).pop( + _StepActionDialogResult(note: note.isEmpty ? null : note), + ); + }, + child: Text(_dialogConfirmLabel(type)), + ), + ], + ); + }, + ); + }, + ); + + noteController.dispose(); + return dialogResult; + } + + Future _showTemplateApplyConfirm(ApprovalTemplate template) async { + final stepCount = template.steps.length; + final description = template.description?.trim(); + final buffer = StringBuffer() + ..writeln('선택한 템플릿을 적용하면 기존 단계 구성이 템플릿 순서로 교체됩니다.') + ..write('템플릿: ${template.name} (단계 $stepCount개)'); + if (description != null && description.isNotEmpty) { + buffer.write('\n설명: $description'); + } + + final confirmed = await showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: const Text('템플릿 적용 확인'), + content: Text(buffer.toString()), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: const Text('취소'), + ), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: const Text('적용'), + ), + ], + ); + }, + ); + return confirmed ?? false; + } + + String _statusLabel(ApprovalStatusFilter filter) { + switch (filter) { + case ApprovalStatusFilter.all: + return '전체 상태'; + case ApprovalStatusFilter.pending: + return '대기'; + case ApprovalStatusFilter.inProgress: + return '진행중'; + case ApprovalStatusFilter.onHold: + return '보류'; + case ApprovalStatusFilter.approved: + return '승인'; + case ApprovalStatusFilter.rejected: + return '반려'; + } + } + + String _dialogTitle(ApprovalStepActionType type) { + switch (type) { + case ApprovalStepActionType.approve: + return '단계 승인'; + case ApprovalStepActionType.reject: + return '단계 반려'; + case ApprovalStepActionType.comment: + return '코멘트 등록'; + } + } + + String _dialogConfirmLabel(ApprovalStepActionType type) { + switch (type) { + case ApprovalStepActionType.approve: + return '승인'; + case ApprovalStepActionType.reject: + return '반려'; + case ApprovalStepActionType.comment: + return '등록'; + } + } + + String _successMessage(ApprovalStepActionType type) { + switch (type) { + case ApprovalStepActionType.approve: + return '결재 단계를 승인했습니다.'; + case ApprovalStepActionType.reject: + return '결재 단계를 반려했습니다.'; + case ApprovalStepActionType.comment: + return '코멘트를 등록했습니다.'; + } + } +} + +class _ApprovalTable extends StatelessWidget { + const _ApprovalTable({ + required this.approvals, + required this.dateFormat, + required this.onView, + }); + + final List approvals; + final intl.DateFormat dateFormat; + final void Function(Approval approval) onView; + + @override + Widget build(BuildContext context) { + final header = [ + 'ID', + '결재번호', + '트랜잭션번호', + '상태', + '상신자', + '요청일시', + '최종결정일시', + '비고', + '동작', + ].map((text) => ShadTableCell.header(child: Text(text))).toList(); + + final rows = >[]; + for (var index = 0; index < approvals.length; index++) { + final approval = approvals[index]; + final cells = [ + ShadTableCell( + child: GestureDetector( + key: ValueKey('approval_row_${approval.id ?? index}'), + behavior: HitTestBehavior.opaque, + onTap: () => onView(approval), + child: Text(approval.id?.toString() ?? '-'), + ), + ), + ShadTableCell(child: Text(approval.approvalNo)), + ShadTableCell(child: Text(approval.transactionNo)), + ShadTableCell(child: Text(approval.status.name)), + ShadTableCell(child: Text(approval.requester.name)), + ShadTableCell( + child: Text(dateFormat.format(approval.requestedAt.toLocal())), + ), + ShadTableCell( + child: Text( + approval.decidedAt == null + ? '-' + : dateFormat.format(approval.decidedAt!.toLocal()), + ), + ), + ShadTableCell( + child: Text(approval.note?.isEmpty ?? true ? '-' : approval.note!), + ), + ]; + + cells.add( + ShadTableCell( + child: Align( + alignment: Alignment.centerRight, + child: ShadButton.ghost( + key: ValueKey('approval_view_${approval.id ?? index}'), + size: ShadButtonSize.sm, + onPressed: () => onView(approval), + child: const Text('자세히'), + ), + ), + ), + ); + + rows.add(cells); + } + + return ShadTable.list( + header: header, + children: rows, + columnSpanExtent: (index) { + switch (index) { + case 1: + case 2: + return const FixedTableSpanExtent(180); + case 3: + case 4: + return const FixedTableSpanExtent(140); + case 7: + return const FixedTableSpanExtent(220); + case 8: + return const FixedTableSpanExtent(120); + default: + return const FixedTableSpanExtent(140); + } + }, + ); + } +} + +class _DetailSection extends StatelessWidget { + const _DetailSection({ + required this.approval, + required this.isLoading, + required this.isLoadingActions, + required this.isPerformingAction, + required this.processingStepId, + required this.hasActionOptions, + required this.templates, + required this.isLoadingTemplates, + required this.isApplyingTemplate, + required this.applyingTemplateId, + required this.selectedTemplateId, + required this.dateFormat, + required this.onRefresh, + required this.onClose, + required this.onSelectTemplate, + required this.onApplyTemplate, + required this.onReloadTemplates, + required this.onAction, + }); + + final Approval? approval; + final bool isLoading; + final bool isLoadingActions; + final bool isPerformingAction; + final int? processingStepId; + final bool hasActionOptions; + final List templates; + final bool isLoadingTemplates; + final bool isApplyingTemplate; + final int? applyingTemplateId; + final int? selectedTemplateId; + final intl.DateFormat dateFormat; + final VoidCallback onRefresh; + final VoidCallback? onClose; + final void Function(int?) onSelectTemplate; + final void Function(int templateId) onApplyTemplate; + final VoidCallback onReloadTemplates; + final void Function(ApprovalStep step, ApprovalStepActionType type) onAction; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + if (isLoading) { + return const Center(child: CircularProgressIndicator()); + } + if (approval == null) { + return ShadCard( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + '좌측 목록에서 결재를 선택하면 상세 정보를 확인할 수 있습니다.', + style: theme.textTheme.muted, + ), + ), + ); + } + + return DefaultTabController( + length: 3, + child: ShadCard( + title: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('결재 상세', style: theme.textTheme.h3), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + ShadButton.ghost( + onPressed: onRefresh, + child: const Icon(lucide.LucideIcons.refreshCw, size: 16), + ), + const SizedBox(width: 8), + ShadButton.ghost( + onPressed: onClose, + child: const Icon(lucide.LucideIcons.x, size: 16), + ), + ], + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TabBar( + labelStyle: theme.textTheme.small, + tabs: const [ + Tab(text: '개요'), + Tab(text: '단계'), + Tab(text: '이력'), + ], + ), + SizedBox( + height: 340, + child: TabBarView( + children: [ + _OverviewTab(approval: approval!, dateFormat: dateFormat), + _StepTab( + approval: approval!, + steps: approval!.steps, + dateFormat: dateFormat, + hasActionOptions: hasActionOptions, + isLoadingActions: isLoadingActions, + isPerformingAction: isPerformingAction, + processingStepId: processingStepId, + templates: templates, + isLoadingTemplates: isLoadingTemplates, + isApplyingTemplate: isApplyingTemplate, + applyingTemplateId: applyingTemplateId, + selectedTemplateId: selectedTemplateId, + onSelectTemplate: onSelectTemplate, + onApplyTemplate: onApplyTemplate, + onReloadTemplates: onReloadTemplates, + onAction: onAction, + ), + _HistoryTab( + histories: approval!.histories, + dateFormat: dateFormat, + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _OverviewTab extends StatelessWidget { + const _OverviewTab({required this.approval, required this.dateFormat}); + + final Approval approval; + final intl.DateFormat dateFormat; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + final rows = [ + ('결재번호', approval.approvalNo), + ('트랜잭션번호', approval.transactionNo), + ('현재 상태', approval.status.name), + ( + '현재 단계', + approval.currentStep == null + ? '-' + : 'Step ${approval.currentStep!.stepOrder} · ${approval.currentStep!.approver.name}', + ), + ('상신자', approval.requester.name), + ('상신일시', dateFormat.format(approval.requestedAt.toLocal())), + ( + '최종결정일시', + approval.decidedAt == null + ? '-' + : dateFormat.format(approval.decidedAt!.toLocal()), + ), + ('비고', approval.note?.isEmpty ?? true ? '-' : approval.note!), + ]; + + return Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: rows + .map( + (entry) => Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 140, + child: Text( + entry.$1, + style: theme.textTheme.small.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + Expanded( + child: Text(entry.$2, style: theme.textTheme.small), + ), + ], + ), + ), + ) + .toList(), + ), + ); + } +} + +class _StepTab extends StatelessWidget { + const _StepTab({ + required this.approval, + required this.steps, + required this.dateFormat, + required this.hasActionOptions, + required this.isLoadingActions, + required this.isPerformingAction, + required this.processingStepId, + required this.templates, + required this.isLoadingTemplates, + required this.isApplyingTemplate, + required this.applyingTemplateId, + required this.selectedTemplateId, + required this.onSelectTemplate, + required this.onApplyTemplate, + required this.onReloadTemplates, + required this.onAction, + }); + + final Approval approval; + final List steps; + final intl.DateFormat dateFormat; + final bool hasActionOptions; + final bool isLoadingActions; + final bool isPerformingAction; + final int? processingStepId; + final List templates; + final bool isLoadingTemplates; + final bool isApplyingTemplate; + final int? applyingTemplateId; + final int? selectedTemplateId; + final void Function(int?) onSelectTemplate; + final void Function(int templateId) onApplyTemplate; + final VoidCallback onReloadTemplates; + final void Function(ApprovalStep step, ApprovalStepActionType type) onAction; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 12), + child: _TemplateToolbar( + templates: templates, + isLoading: isLoadingTemplates, + selectedTemplateId: selectedTemplateId, + isApplyingTemplate: isApplyingTemplate, + applyingTemplateId: applyingTemplateId, + onSelectTemplate: onSelectTemplate, + onApplyTemplate: onApplyTemplate, + onReload: onReloadTemplates, + ), + ), + if (!isLoadingTemplates && templates.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Text( + '사용 가능한 결재 템플릿이 없습니다. 템플릿을 등록하면 단계 일괄 구성이 가능합니다.', + style: theme.textTheme.muted, + ), + ), + if (steps.isEmpty) + Expanded( + child: Center( + child: Text('등록된 결재 단계가 없습니다.', style: theme.textTheme.muted), + ), + ) + else + Expanded( + child: ListView.separated( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), + itemBuilder: (context, index) { + final step = steps[index]; + final disabledReason = _disabledReason(step); + final isProcessingStep = + isPerformingAction && processingStepId == step.id; + final isEnabled = disabledReason == null && !isProcessingStep; + + return ShadCard( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Step ${step.stepOrder}', + style: theme.textTheme.small.copyWith( + fontWeight: FontWeight.w600, + ), + ), + Text( + step.status.name, + style: theme.textTheme.small, + ), + ], + ), + const SizedBox(height: 8), + Text( + '승인자: ${step.approver.name}', + style: theme.textTheme.small, + ), + const SizedBox(height: 4), + Text( + '배정: ${dateFormat.format(step.assignedAt.toLocal())}', + style: theme.textTheme.small, + ), + const SizedBox(height: 4), + Text( + '결정: ${step.decidedAt == null ? '-' : dateFormat.format(step.decidedAt!.toLocal())}', + style: theme.textTheme.small, + ), + if (step.note?.isNotEmpty ?? false) ...[ + const SizedBox(height: 8), + Text( + '비고: ${step.note}', + style: theme.textTheme.small, + ), + ], + const SizedBox(height: 12), + if (isLoadingActions) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + SizedBox( + width: 16, + height: 16, + child: const CircularProgressIndicator( + strokeWidth: 2, + ), + ), + const SizedBox(width: 8), + Text( + '행위 목록을 불러오는 중입니다.', + style: theme.textTheme.small, + ), + ], + ), + ), + Wrap( + spacing: 12, + runSpacing: 8, + children: ApprovalStepActionType.values + .map( + (type) => _buildActionButton( + context: context, + step: step, + type: type, + enabled: isEnabled, + isProcessing: isProcessingStep, + disabledReason: disabledReason, + ), + ) + .toList(), + ), + if (!isEnabled && disabledReason != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + disabledReason, + style: theme.textTheme.muted, + ), + ), + ], + ), + ), + ); + }, + separatorBuilder: (_, __) => const SizedBox(height: 12), + itemCount: steps.length, + ), + ), + ], + ); + } + + Widget _buildActionButton({ + required BuildContext context, + required ApprovalStep step, + required ApprovalStepActionType type, + required bool enabled, + required bool isProcessing, + required String? disabledReason, + }) { + final theme = ShadTheme.of(context); + final actionKey = ValueKey(_actionKey(step, type)); + final label = _actionLabel(type); + final icon = _actionIcon(type); + + final child = Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isProcessing) ...[ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + theme.colorScheme.primary, + ), + ), + ), + const SizedBox(width: 8), + Text('$label 처리 중...'), + ] else ...[ + Icon(icon, size: 16), + const SizedBox(width: 8), + Text(label), + ], + ], + ); + + final onPressed = enabled ? () => onAction(step, type) : null; + + Widget button; + switch (type) { + case ApprovalStepActionType.approve: + button = ShadButton(key: actionKey, onPressed: onPressed, child: child); + break; + case ApprovalStepActionType.reject: + button = ShadButton.outline( + key: actionKey, + onPressed: onPressed, + child: child, + ); + break; + case ApprovalStepActionType.comment: + button = ShadButton.ghost( + key: actionKey, + onPressed: onPressed, + child: child, + ); + break; + } + + if (!enabled && disabledReason != null) { + return Tooltip(message: disabledReason, child: button); + } + return button; + } + + String? _disabledReason(ApprovalStep step) { + if (isLoadingActions) { + return '행위 목록을 불러오는 중입니다.'; + } + if (!hasActionOptions) { + return '사용 가능한 결재 행위가 없습니다.'; + } + if (isPerformingAction && processingStepId != step.id) { + return '다른 결재 단계를 처리 중입니다.'; + } + if (step.decidedAt != null) { + return '이미 처리된 단계입니다.'; + } + final current = approval.currentStep; + if (current == null) { + return '현재 진행할 단계가 지정되지 않았습니다.'; + } + final matchesId = current.id != null && current.id == step.id; + final matchesOrder = + current.id == null && + step.id == null && + current.stepOrder == step.stepOrder; + if (!matchesId && !matchesOrder) { + return '현재 진행 중인 단계가 아닙니다.'; + } + return null; + } + + String _actionLabel(ApprovalStepActionType type) { + switch (type) { + case ApprovalStepActionType.approve: + return '승인'; + case ApprovalStepActionType.reject: + return '반려'; + case ApprovalStepActionType.comment: + return '코멘트'; + } + } + + IconData _actionIcon(ApprovalStepActionType type) { + switch (type) { + case ApprovalStepActionType.approve: + return lucide.LucideIcons.check; + case ApprovalStepActionType.reject: + return lucide.LucideIcons.x; + case ApprovalStepActionType.comment: + return lucide.LucideIcons.messageCircle; + } + } + + String _actionKey(ApprovalStep step, ApprovalStepActionType type) { + if (step.id != null) { + return 'step_action_${step.id}_${type.code}'; + } + return 'step_action_order_${step.stepOrder}_${type.code}'; + } +} + +class _TemplateToolbar extends StatelessWidget { + const _TemplateToolbar({ + required this.templates, + required this.isLoading, + required this.selectedTemplateId, + required this.isApplyingTemplate, + required this.applyingTemplateId, + required this.onSelectTemplate, + required this.onApplyTemplate, + required this.onReload, + }); + + final List templates; + final bool isLoading; + final int? selectedTemplateId; + final bool isApplyingTemplate; + final int? applyingTemplateId; + final void Function(int?) onSelectTemplate; + final void Function(int templateId) onApplyTemplate; + final VoidCallback onReload; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + final selectedTemplate = _findTemplate(selectedTemplateId); + final isApplyingCurrent = + isApplyingTemplate && applyingTemplateId == selectedTemplateId; + final canApply = + templates.isNotEmpty && + !isLoading && + selectedTemplateId != null && + !isApplyingTemplate; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: ShadSelect( + key: ValueKey(templates.length), + placeholder: const Text('템플릿 선택'), + initialValue: selectedTemplateId, + onChanged: onSelectTemplate, + selectedOptionBuilder: (context, value) { + final match = _findTemplate(value); + return Text(match?.name ?? '템플릿 선택'); + }, + options: templates + .map( + (template) => ShadOption( + value: template.id, + child: Text(template.name), + ), + ) + .toList(), + ), + ), + const SizedBox(width: 12), + ShadButton.outline( + onPressed: isLoading ? null : onReload, + child: Row( + mainAxisSize: MainAxisSize.min, + children: const [ + Icon(lucide.LucideIcons.refreshCw, size: 16), + SizedBox(width: 6), + Text('새로고침'), + ], + ), + ), + const SizedBox(width: 12), + ShadButton( + onPressed: canApply + ? () { + final templateId = selectedTemplateId; + if (templateId != null) { + onApplyTemplate(templateId); + } + } + : null, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isApplyingCurrent) ...[ + SizedBox( + width: 16, + height: 16, + child: const CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: 8), + const Text('적용 중...'), + ] else ...[ + const Icon(lucide.LucideIcons.layoutList, size: 16), + const SizedBox(width: 8), + const Text('템플릿 적용'), + ], + ], + ), + ), + ], + ), + if (isLoading) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Row( + children: [ + SizedBox( + width: 16, + height: 16, + child: const CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: 8), + Text('템플릿을 불러오는 중입니다.', style: theme.textTheme.small), + ], + ), + ) + else if (selectedTemplate != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + _templateSummary(selectedTemplate), + style: theme.textTheme.small, + ), + ), + ], + ); + } + + ApprovalTemplate? _findTemplate(int? id) { + if (id == null) { + return null; + } + for (final template in templates) { + if (template.id == id) { + return template; + } + } + return null; + } + + String _templateSummary(ApprovalTemplate template) { + final stepCount = template.steps.length; + final description = template.description?.trim(); + final buffer = StringBuffer() + ..write('선택된 템플릿: ${template.name} (단계 $stepCount개)'); + if (description != null && description.isNotEmpty) { + buffer.write(' · $description'); + } + return buffer.toString(); + } +} + +class _HistoryTab extends StatelessWidget { + const _HistoryTab({required this.histories, required this.dateFormat}); + + final List histories; + final intl.DateFormat dateFormat; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + if (histories.isEmpty) { + return Center(child: Text('결재 이력이 없습니다.', style: theme.textTheme.muted)); + } + + return ListView.separated( + padding: const EdgeInsets.all(20), + itemBuilder: (context, index) { + final history = histories[index]; + return ShadCard( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + history.action.name, + style: theme.textTheme.small.copyWith( + fontWeight: FontWeight.w600, + ), + ), + Text( + dateFormat.format(history.actionAt.toLocal()), + style: theme.textTheme.small, + ), + ], + ), + const SizedBox(height: 6), + Text( + '승인자: ${history.approver.name}', + style: theme.textTheme.small, + ), + const SizedBox(height: 4), + Text( + '상태: ${history.fromStatus?.name ?? '-'} → ${history.toStatus.name}', + style: theme.textTheme.small, + ), + if (history.note?.isNotEmpty ?? false) ...[ + const SizedBox(height: 6), + Text('비고: ${history.note}', style: theme.textTheme.small), + ], + ], + ), + ), + ); + }, + separatorBuilder: (_, __) => const SizedBox(height: 12), + itemCount: histories.length, + ); + } +} + +class _StepActionDialogResult { + const _StepActionDialogResult({this.note}); + + final String? note; +} diff --git a/lib/features/approvals/request/presentation/pages/approval_request_page.dart b/lib/features/approvals/request/presentation/pages/approval_request_page.dart index 2e4ace9..049ee38 100644 --- a/lib/features/approvals/request/presentation/pages/approval_request_page.dart +++ b/lib/features/approvals/request/presentation/pages/approval_request_page.dart @@ -1,59 +1,12 @@ import 'package:flutter/widgets.dart'; -import '../../../../../widgets/spec_page.dart'; +import '../../../presentation/pages/approval_page.dart'; class ApprovalRequestPage extends StatelessWidget { const ApprovalRequestPage({super.key}); @override Widget build(BuildContext context) { - return const SpecPage( - title: '결재 관리', - summary: '결재 번호와 상태, 상신자를 확인하고 결재 플로우를 제어합니다.', - sections: [ - SpecSection( - title: '입력 폼', - items: [ - '트랜잭션번호 [Dropdown]', - '결재번호 [자동생성]', - '결재상태 [Dropdown]', - '상신자 [자동]', - '비고 [Text]', - ], - ), - SpecSection( - title: '수정 폼', - items: ['결재번호 [ReadOnly]', '상신자 [ReadOnly]', '요청일시 [ReadOnly]'], - ), - SpecSection( - title: '테이블 리스트', - description: '1행 예시', - table: SpecTable( - columns: [ - '번호', - '결재번호', - '트랜잭션번호', - '상태', - '상신자', - '요청일시', - '최종결정일시', - '비고', - ], - rows: [ - [ - '1', - 'APP-20240301-001', - 'IN-20240301-001', - '승인대기', - '홍길동', - '2024-03-01 09:00', - '-', - '-', - ], - ], - ), - ), - ], - ); + return const ApprovalPage(); } } 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 461e6d3..b2c79a1 100644 --- a/lib/features/approvals/step/presentation/pages/approval_step_page.dart +++ b/lib/features/approvals/step/presentation/pages/approval_step_page.dart @@ -1,5 +1,11 @@ -import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; +import '../../../../../core/config/environment.dart'; +import '../../../../../core/constants/app_sections.dart'; +import '../../../../../widgets/app_layout.dart'; +import '../../../../../widgets/components/coming_soon_card.dart'; +import '../../../../../widgets/components/filter_bar.dart'; import '../../../../../widgets/spec_page.dart'; class ApprovalStepPage extends StatelessWidget { @@ -7,44 +13,81 @@ class ApprovalStepPage extends StatelessWidget { @override Widget build(BuildContext context) { - return const SpecPage( - title: '결재 단계 관리', - summary: '결재 단계 순서와 승인자를 구성합니다.', - sections: [ - SpecSection( - title: '입력 폼', - items: [ - '결재ID [Dropdown]', - '단계순서 [Number]', - '승인자 [Dropdown]', - '단계상태 [Dropdown]', - '비고 [Text]', - ], - ), - SpecSection( - title: '수정 폼', - items: ['결재ID [ReadOnly]', '단계순서 [ReadOnly]'], - ), - SpecSection( - title: '테이블 리스트', - description: '1행 예시', - table: SpecTable( - columns: ['번호', '결재ID', '단계순서', '승인자', '상태', '배정일시', '결정일시', '비고'], - rows: [ - [ - '1', - 'APP-20240301-001', - '1', - '최관리', - '승인대기', - '2024-03-01 09:00', - '-', - '-', - ], + final enabled = Environment.flag('FEATURE_APPROVALS_ENABLED'); + if (!enabled) { + return const SpecPage( + title: '결재 단계 관리', + summary: '결재 단계 순서와 승인자를 구성합니다.', + sections: [ + SpecSection( + title: '입력 폼', + items: [ + '결재ID [Dropdown]', + '단계순서 [Number]', + '승인자 [Dropdown]', + '단계상태 [Dropdown]', + '비고 [Text]', ], ), + SpecSection( + title: '수정 폼', + items: ['결재ID [ReadOnly]', '단계순서 [ReadOnly]'], + ), + SpecSection( + title: '테이블 리스트', + description: '1행 예시', + table: SpecTable( + columns: [ + '번호', + '결재ID', + '단계순서', + '승인자', + '상태', + '배정일시', + '결정일시', + '비고', + ], + rows: [ + [ + '1', + 'APP-20240301-001', + '1', + '최관리', + '승인대기', + '2024-03-01 09:00', + '-', + '-', + ], + ], + ), + ), + ], + ); + } + + return AppLayout( + title: '결재 단계 관리', + subtitle: '결재 순서를 정의하고 승인자를 배정할 수 있도록 준비 중입니다.', + breadcrumbs: const [ + AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath), + AppBreadcrumbItem(label: '결재', path: '/approvals/steps'), + AppBreadcrumbItem(label: '결재 단계'), + ], + actions: [ + ShadButton( + onPressed: null, + leading: const Icon(LucideIcons.plus, size: 16), + child: const Text('단계 추가'), ), ], + toolbar: FilterBar( + children: const [Text('필터 구성은 결재 단계 API 확정 후 제공될 예정입니다.')], + ), + child: const ComingSoonCard( + title: '결재 단계 화면 구현 준비 중', + description: '결재 단계 CRUD와 템플릿 연동 요구사항을 정리하는 중입니다.', + items: ['결재 요청별 단계 조회 및 정렬', '승인자 지정과 단계 상태 변경', '템플릿에서 단계 일괄 불러오기'], + ), ); } } 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 3195c7f..dcfc32a 100644 --- a/lib/features/approvals/template/presentation/pages/approval_template_page.dart +++ b/lib/features/approvals/template/presentation/pages/approval_template_page.dart @@ -1,5 +1,11 @@ -import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; +import '../../../../../core/config/environment.dart'; +import '../../../../../core/constants/app_sections.dart'; +import '../../../../../widgets/app_layout.dart'; +import '../../../../../widgets/components/coming_soon_card.dart'; +import '../../../../../widgets/components/filter_bar.dart'; import '../../../../../widgets/spec_page.dart'; class ApprovalTemplatePage extends StatelessWidget { @@ -7,45 +13,73 @@ class ApprovalTemplatePage extends StatelessWidget { @override Widget build(BuildContext context) { - return const SpecPage( - title: '결재 템플릿 관리', - summary: '반복적인 결재 흐름을 템플릿으로 정의합니다.', - sections: [ - SpecSection( - title: '입력 폼', - items: [ - '템플릿코드 [Text]', - '템플릿명 [Text]', - '설명 [Text]', - '작성자 [ReadOnly]', - '사용여부 [Switch]', - '비고 [Text]', - '단계 추가: 순서 [Number], 승인자 [Dropdown]', - ], - ), - SpecSection( - title: '수정 폼', - items: ['템플릿코드 [ReadOnly]', '작성자 [ReadOnly]'], - ), - SpecSection( - title: '테이블 리스트', - description: '1행 예시', - table: SpecTable( - columns: ['번호', '템플릿코드', '템플릿명', '설명', '작성자', '사용여부', '변경일시'], - rows: [ - [ - '1', - 'TEMP-001', - '입고 기본 결재', - '입고 처리 2단계 결재', - '홍길동', - 'Y', - '2024-03-01 10:00', - ], + final enabled = Environment.flag('FEATURE_APPROVALS_ENABLED'); + if (!enabled) { + return const SpecPage( + title: '결재 템플릿 관리', + summary: '반복적인 결재 흐름을 템플릿으로 정의합니다.', + sections: [ + SpecSection( + title: '입력 폼', + items: [ + '템플릿코드 [Text]', + '템플릿명 [Text]', + '설명 [Text]', + '작성자 [ReadOnly]', + '사용여부 [Switch]', + '비고 [Text]', + '단계 추가: 순서 [Number], 승인자 [Dropdown]', ], ), + SpecSection( + title: '수정 폼', + items: ['템플릿코드 [ReadOnly]', '작성자 [ReadOnly]'], + ), + SpecSection( + title: '테이블 리스트', + description: '1행 예시', + table: SpecTable( + columns: ['번호', '템플릿코드', '템플릿명', '설명', '작성자', '사용여부', '변경일시'], + rows: [ + [ + '1', + 'TEMP-001', + '입고 기본 결재', + '입고 처리 2단계 결재', + '홍길동', + 'Y', + '2024-03-01 10:00', + ], + ], + ), + ), + ], + ); + } + + return AppLayout( + title: '결재 템플릿 관리', + subtitle: '반복되는 결재 단계를 템플릿으로 구성할 수 있도록 준비 중입니다.', + breadcrumbs: const [ + AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath), + AppBreadcrumbItem(label: '결재', path: '/approvals/templates'), + AppBreadcrumbItem(label: '결재 템플릿'), + ], + actions: [ + ShadButton( + onPressed: null, + leading: const Icon(LucideIcons.plus, size: 16), + child: const Text('템플릿 생성'), ), ], + toolbar: FilterBar( + children: const [Text('템플릿 검색/필터 UI는 결재 요구사항 확정 후 제공됩니다.')], + ), + child: const ComingSoonCard( + title: '결재 템플릿 화면 구현 준비 중', + description: '템플릿 헤더 정보와 단계 반복 입력을 다루는 UI를 설계하고 있습니다.', + items: ['템플릿 목록 정렬 및 사용여부 토글', '단계 편집/추가/삭제 인터랙션', '템플릿 버전 관리 및 배포 전략'], + ), ); } } diff --git a/lib/features/inventory/inbound/presentation/pages/inbound_page.dart b/lib/features/inventory/inbound/presentation/pages/inbound_page.dart index 6cdd73b..c8492c6 100644 --- a/lib/features/inventory/inbound/presentation/pages/inbound_page.dart +++ b/lib/features/inventory/inbound/presentation/pages/inbound_page.dart @@ -1,6 +1,10 @@ import 'package:flutter/material.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:superport_v2/core/constants/app_sections.dart'; +import 'package:superport_v2/widgets/app_layout.dart'; +import 'package:superport_v2/widgets/components/filter_bar.dart'; + class InboundPage extends StatefulWidget { const InboundPage({super.key}); @@ -43,95 +47,67 @@ class _InboundPageState extends State { final theme = ShadTheme.of(context); final filtered = _filteredRecords; - return SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + return AppLayout( + title: '입고 관리', + subtitle: '입고 처리, 라인 품목, 상태를 한 화면에서 확인하고 관리합니다.', + breadcrumbs: const [ + AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath), + AppBreadcrumbItem(label: '입·출고', path: '/inventory/inbound'), + AppBreadcrumbItem(label: '입고'), + ], + actions: [ + ShadButton( + leading: const Icon(LucideIcons.plus, size: 16), + onPressed: _handleCreate, + child: const Text('입고 등록'), + ), + ShadButton.outline( + leading: const Icon(LucideIcons.pencil, size: 16), + onPressed: + _selectedRecord == null ? null : () => _handleEdit(_selectedRecord!), + child: const Text('선택 항목 수정'), + ), + ], + toolbar: FilterBar( children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('입고 관리', style: theme.textTheme.h2), - const SizedBox(height: 6), - Text( - '입고 처리, 라인 품목, 상태를 한 화면에서 확인하고 관리합니다.', - style: theme.textTheme.muted, - ), - ], - ), - ), - Row( + SizedBox( + width: 260, + child: ShadInput( + controller: _searchController, + placeholder: const Text('트랜잭션번호, 작성자, 제품 검색'), + leading: const Icon(LucideIcons.search, size: 16), + onChanged: (_) => setState(() {}), + ), + ), + SizedBox( + width: 220, + child: ShadButton.outline( + onPressed: _pickDateRange, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, children: [ - ShadButton( - leading: const Icon(LucideIcons.plus, size: 16), - onPressed: _handleCreate, - child: const Text('입고 등록'), - ), - const SizedBox(width: 12), - ShadButton.outline( - leading: const Icon(LucideIcons.pencil, size: 16), - onPressed: _selectedRecord == null - ? null - : () => _handleEdit(_selectedRecord!), - child: const Text('선택 항목 수정'), + const Icon(LucideIcons.calendar, size: 16), + const SizedBox(width: 8), + Text( + _dateRange == null + ? '기간 선택' + : '${_dateFormatter.format(_dateRange!.start)} ~ ${_dateFormatter.format(_dateRange!.end)}', ), ], ), - ], - ), - const SizedBox(height: 24), - ShadCard( - title: Text('검색 필터', style: theme.textTheme.h3), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Wrap( - spacing: 16, - runSpacing: 16, - children: [ - SizedBox( - width: 260, - child: ShadInput( - controller: _searchController, - placeholder: const Text('트랜잭션번호, 작성자, 제품 검색'), - leading: const Icon(LucideIcons.search, size: 16), - onChanged: (_) => setState(() {}), - ), - ), - SizedBox( - width: 220, - child: ShadButton.outline( - onPressed: _pickDateRange, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(LucideIcons.calendar, size: 16), - const SizedBox(width: 8), - Text( - _dateRange == null - ? '기간 선택' - : '${_dateFormatter.format(_dateRange!.start)} ~ ${_dateFormatter.format(_dateRange!.end)}', - ), - ], - ), - ), - ), - if (_dateRange != null) - ShadButton.ghost( - onPressed: () => setState(() => _dateRange = null), - child: const Text('기간 초기화'), - ), - ], - ), - ], ), ), - const SizedBox(height: 24), + if (_dateRange != null) + ShadButton.ghost( + onPressed: () => setState(() => _dateRange = null), + child: const Text('기간 초기화'), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ ShadCard( title: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -158,18 +134,21 @@ class _InboundPageState extends State { .toList(), children: [ for (final record in filtered) - _buildRecordRow(record).map( - (value) => ShadTableCell( - child: Text( - value, - overflow: TextOverflow.ellipsis, - ), - ), - ), + _buildRecordRow(record) + .map( + (value) => ShadTableCell( + child: Text( + value, + overflow: TextOverflow.ellipsis, + ), + ), + ) + .toList(), ], columnSpanExtent: (index) => const FixedTableSpanExtent(140), - rowSpanExtent: (index) => const FixedTableSpanExtent(56), + rowSpanExtent: (index) => + const FixedTableSpanExtent(56), onRowTap: (rowIndex) { setState(() { _selectedRecord = filtered[rowIndex]; diff --git a/lib/features/inventory/outbound/presentation/pages/outbound_page.dart b/lib/features/inventory/outbound/presentation/pages/outbound_page.dart index e43061d..80c7e43 100644 --- a/lib/features/inventory/outbound/presentation/pages/outbound_page.dart +++ b/lib/features/inventory/outbound/presentation/pages/outbound_page.dart @@ -1,6 +1,10 @@ import 'package:flutter/material.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:superport_v2/core/constants/app_sections.dart'; +import 'package:superport_v2/widgets/app_layout.dart'; +import 'package:superport_v2/widgets/components/filter_bar.dart'; + class OutboundPage extends StatefulWidget { const OutboundPage({super.key}); @@ -50,95 +54,67 @@ class _OutboundPageState extends State { final theme = ShadTheme.of(context); final filtered = _filteredRecords; - return SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + return AppLayout( + title: '출고 관리', + subtitle: '출고 처리, 고객사 연결, 품목 라인을 실시간으로 확인합니다.', + breadcrumbs: const [ + AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath), + AppBreadcrumbItem(label: '입·출고', path: '/inventory/outbound'), + AppBreadcrumbItem(label: '출고'), + ], + actions: [ + ShadButton( + leading: const Icon(LucideIcons.plus, size: 16), + onPressed: _handleCreate, + child: const Text('출고 등록'), + ), + ShadButton.outline( + leading: const Icon(LucideIcons.pencil, size: 16), + onPressed: + _selectedRecord == null ? null : () => _handleEdit(_selectedRecord!), + child: const Text('선택 항목 수정'), + ), + ], + toolbar: FilterBar( children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('출고 관리', style: theme.textTheme.h2), - const SizedBox(height: 6), - Text( - '출고 처리, 고객사 연결, 품목 라인을 실시간으로 확인합니다.', - style: theme.textTheme.muted, - ), - ], - ), - ), - Row( + SizedBox( + width: 260, + child: ShadInput( + controller: _searchController, + placeholder: const Text('트랜잭션번호, 작성자, 제품, 고객사 검색'), + leading: const Icon(LucideIcons.search, size: 16), + onChanged: (_) => setState(() {}), + ), + ), + SizedBox( + width: 220, + child: ShadButton.outline( + onPressed: _pickDateRange, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, children: [ - ShadButton( - leading: const Icon(LucideIcons.plus, size: 16), - onPressed: _handleCreate, - child: const Text('출고 등록'), - ), - const SizedBox(width: 12), - ShadButton.outline( - leading: const Icon(LucideIcons.pencil, size: 16), - onPressed: _selectedRecord == null - ? null - : () => _handleEdit(_selectedRecord!), - child: const Text('선택 항목 수정'), + const Icon(LucideIcons.calendar, size: 16), + const SizedBox(width: 8), + Text( + _dateRange == null + ? '기간 선택' + : '${_dateFormatter.format(_dateRange!.start)} ~ ${_dateFormatter.format(_dateRange!.end)}', ), ], ), - ], - ), - const SizedBox(height: 24), - ShadCard( - title: Text('검색 필터', style: theme.textTheme.h3), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Wrap( - spacing: 16, - runSpacing: 16, - children: [ - SizedBox( - width: 260, - child: ShadInput( - controller: _searchController, - placeholder: const Text('트랜잭션번호, 작성자, 제품, 고객사 검색'), - leading: const Icon(LucideIcons.search, size: 16), - onChanged: (_) => setState(() {}), - ), - ), - SizedBox( - width: 220, - child: ShadButton.outline( - onPressed: _pickDateRange, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(LucideIcons.calendar, size: 16), - const SizedBox(width: 8), - Text( - _dateRange == null - ? '기간 선택' - : '${_dateFormatter.format(_dateRange!.start)} ~ ${_dateFormatter.format(_dateRange!.end)}', - ), - ], - ), - ), - ), - if (_dateRange != null) - ShadButton.ghost( - onPressed: () => setState(() => _dateRange = null), - child: const Text('기간 초기화'), - ), - ], - ), - ], ), ), - const SizedBox(height: 24), + if (_dateRange != null) + ShadButton.ghost( + onPressed: () => setState(() => _dateRange = null), + child: const Text('기간 초기화'), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ ShadCard( title: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, diff --git a/lib/features/inventory/rental/presentation/pages/rental_page.dart b/lib/features/inventory/rental/presentation/pages/rental_page.dart index d57cece..b853edb 100644 --- a/lib/features/inventory/rental/presentation/pages/rental_page.dart +++ b/lib/features/inventory/rental/presentation/pages/rental_page.dart @@ -1,6 +1,10 @@ import 'package:flutter/material.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:superport_v2/core/constants/app_sections.dart'; +import 'package:superport_v2/widgets/app_layout.dart'; +import 'package:superport_v2/widgets/components/filter_bar.dart'; + class RentalPage extends StatefulWidget { const RentalPage({super.key}); @@ -51,95 +55,68 @@ class _RentalPageState extends State { final theme = ShadTheme.of(context); final filtered = _filteredRecords; - return SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + return AppLayout( + title: '대여 관리', + subtitle: '대여/반납 구분, 반납 예정일, 고객사 현황을 확인합니다.', + breadcrumbs: const [ + AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath), + AppBreadcrumbItem(label: '입·출고', path: '/inventory/rental'), + AppBreadcrumbItem(label: '대여'), + ], + actions: [ + ShadButton( + leading: const Icon(LucideIcons.plus, size: 16), + onPressed: _handleCreate, + child: const Text('대여 등록'), + ), + ShadButton.outline( + leading: const Icon(LucideIcons.pencil, size: 16), + onPressed: _selectedRecord == null + ? null + : () => _handleEdit(_selectedRecord!), + child: const Text('선택 항목 수정'), + ), + ], + toolbar: FilterBar( children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('대여 관리', style: theme.textTheme.h2), - const SizedBox(height: 6), - Text( - '대여/반납 구분, 반납 예정일, 고객사 현황을 확인합니다.', - style: theme.textTheme.muted, - ), - ], - ), - ), - Row( + SizedBox( + width: 260, + child: ShadInput( + controller: _searchController, + placeholder: const Text('트랜잭션번호, 작성자, 제품, 고객사 검색'), + leading: const Icon(LucideIcons.search, size: 16), + onChanged: (_) => setState(() {}), + ), + ), + SizedBox( + width: 220, + child: ShadButton.outline( + onPressed: _pickDateRange, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, children: [ - ShadButton( - leading: const Icon(LucideIcons.plus, size: 16), - onPressed: _handleCreate, - child: const Text('대여 등록'), - ), - const SizedBox(width: 12), - ShadButton.outline( - leading: const Icon(LucideIcons.pencil, size: 16), - onPressed: _selectedRecord == null - ? null - : () => _handleEdit(_selectedRecord!), - child: const Text('선택 항목 수정'), + const Icon(LucideIcons.calendar, size: 16), + const SizedBox(width: 8), + Text( + _dateRange == null + ? '기간 선택' + : '${_dateFormatter.format(_dateRange!.start)} ~ ${_dateFormatter.format(_dateRange!.end)}', ), ], ), - ], - ), - const SizedBox(height: 24), - ShadCard( - title: Text('검색 필터', style: theme.textTheme.h3), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Wrap( - spacing: 16, - runSpacing: 16, - children: [ - SizedBox( - width: 260, - child: ShadInput( - controller: _searchController, - placeholder: const Text('트랜잭션번호, 작성자, 제품, 고객사 검색'), - leading: const Icon(LucideIcons.search, size: 16), - onChanged: (_) => setState(() {}), - ), - ), - SizedBox( - width: 220, - child: ShadButton.outline( - onPressed: _pickDateRange, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(LucideIcons.calendar, size: 16), - const SizedBox(width: 8), - Text( - _dateRange == null - ? '기간 선택' - : '${_dateFormatter.format(_dateRange!.start)} ~ ${_dateFormatter.format(_dateRange!.end)}', - ), - ], - ), - ), - ), - if (_dateRange != null) - ShadButton.ghost( - onPressed: () => setState(() => _dateRange = null), - child: const Text('기간 초기화'), - ), - ], - ), - ], ), ), - const SizedBox(height: 24), + if (_dateRange != null) + ShadButton.ghost( + onPressed: () => setState(() => _dateRange = null), + child: const Text('기간 초기화'), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ ShadCard( title: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, diff --git a/lib/features/masters/customer/presentation/pages/customer_page.dart b/lib/features/masters/customer/presentation/pages/customer_page.dart index 6739cd8..c89b20a 100644 --- a/lib/features/masters/customer/presentation/pages/customer_page.dart +++ b/lib/features/masters/customer/presentation/pages/customer_page.dart @@ -2,6 +2,10 @@ import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:superport_v2/core/constants/app_sections.dart'; +import 'package:superport_v2/widgets/app_layout.dart'; +import 'package:superport_v2/widgets/components/filter_bar.dart'; + import '../../../../../core/config/environment.dart'; import '../../../../../widgets/spec_page.dart'; import '../../domain/entities/customer.dart'; @@ -137,173 +141,145 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> { ? false : (result.page * result.pageSize) < result.total; - return SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + final showReset = _searchController.text.isNotEmpty || + _controller.typeFilter != CustomerTypeFilter.all || + _controller.statusFilter != CustomerStatusFilter.all; + + return AppLayout( + title: '회사(고객사) 관리', + subtitle: '고객사 기본 정보와 연락처, 주소를 관리합니다.', + breadcrumbs: const [ + AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath), + AppBreadcrumbItem(label: '마스터', path: '/masters/customers'), + AppBreadcrumbItem(label: '고객사'), + ], + actions: [ + ShadButton( + leading: const Icon(LucideIcons.plus, size: 16), + onPressed: _controller.isSubmitting + ? null + : () => _openCustomerForm(context), + child: const Text('신규 등록'), + ), + ], + toolbar: FilterBar( children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('회사(고객사) 관리', style: theme.textTheme.h2), - const SizedBox(height: 6), - Text( - '고객사 기본 정보와 연락처, 주소를 관리합니다.', - style: theme.textTheme.muted, - ), - ], - ), - ), - const SizedBox(width: 16), - ShadButton( - onPressed: _controller.isSubmitting - ? null - : () => _openCustomerForm(context), - child: const Text('신규 등록'), - ), - ], - ), - const SizedBox(height: 24), - ShadCard( - title: Text('검색 및 필터', style: theme.textTheme.h3), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Wrap( - spacing: 16, - runSpacing: 16, - children: [ - SizedBox( - width: 260, - child: ShadInput( - controller: _searchController, - focusNode: _searchFocus, - placeholder: const Text('고객사코드, 고객사명 검색'), - leading: const Icon(LucideIcons.search, size: 16), - onSubmitted: (_) => _applyFilters(), - ), - ), - SizedBox( - width: 200, - child: ShadSelect( - key: ValueKey(_controller.typeFilter), - initialValue: _controller.typeFilter, - selectedOptionBuilder: (context, value) => - Text(_typeLabel(value)), - onChanged: (value) { - if (value == null) return; - _controller.updateTypeFilter(value); - }, - options: CustomerTypeFilter.values - .map( - (filter) => ShadOption( - value: filter, - child: Text(_typeLabel(filter)), - ), - ) - .toList(), - ), - ), - SizedBox( - width: 200, - child: ShadSelect( - key: ValueKey(_controller.statusFilter), - initialValue: _controller.statusFilter, - selectedOptionBuilder: (context, value) => - Text(_statusLabel(value)), - onChanged: (value) { - if (value == null) return; - _controller.updateStatusFilter(value); - }, - options: CustomerStatusFilter.values - .map( - (filter) => ShadOption( - value: filter, - child: Text(_statusLabel(filter)), - ), - ) - .toList(), - ), - ), - ShadButton.outline( - onPressed: _controller.isLoading - ? null - : _applyFilters, - child: const Text('검색 적용'), - ), - if (_searchController.text.isNotEmpty || - _controller.typeFilter != CustomerTypeFilter.all || - _controller.statusFilter != - CustomerStatusFilter.all) - ShadButton.ghost( - onPressed: _controller.isLoading - ? null - : () { - _searchController.clear(); - _searchFocus.requestFocus(); - _controller.updateQuery(''); - _controller.updateTypeFilter( - CustomerTypeFilter.all, - ); - _controller.updateStatusFilter( - CustomerStatusFilter.all, - ); - _controller.fetch(page: 1); - }, - child: const Text('초기화'), - ), - ], - ), - ], + SizedBox( + width: 260, + child: ShadInput( + controller: _searchController, + focusNode: _searchFocus, + placeholder: const Text('고객사코드, 고객사명 검색'), + leading: const Icon(LucideIcons.search, size: 16), + onSubmitted: (_) => _applyFilters(), ), ), - const SizedBox(height: 24), - ShadCard( - title: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text('고객사 목록', style: theme.textTheme.h3), - Text('$totalCount건', style: theme.textTheme.muted), - ], - ), - footer: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - '페이지 $currentPage / $totalPages', - style: theme.textTheme.small, - ), - Row( - children: [ - ShadButton.outline( - size: ShadButtonSize.sm, - onPressed: _controller.isLoading || currentPage <= 1 - ? null - : () => _controller.fetch(page: currentPage - 1), - child: const Text('이전'), + SizedBox( + width: 200, + child: ShadSelect( + key: ValueKey(_controller.typeFilter), + initialValue: _controller.typeFilter, + selectedOptionBuilder: (context, value) => + Text(_typeLabel(value)), + onChanged: (value) { + if (value == null) return; + _controller.updateTypeFilter(value); + }, + options: CustomerTypeFilter.values + .map( + (filter) => ShadOption( + value: filter, + child: Text(_typeLabel(filter)), ), - const SizedBox(width: 8), - ShadButton.outline( - size: ShadButtonSize.sm, - onPressed: _controller.isLoading || !hasNext - ? null - : () => _controller.fetch(page: currentPage + 1), - child: const Text('다음'), - ), - ], - ), - ], - ), - child: _controller.isLoading - ? const Padding( - padding: EdgeInsets.all(48), - child: Center(child: CircularProgressIndicator()), ) - : customers.isEmpty + .toList(), + ), + ), + SizedBox( + width: 200, + child: ShadSelect( + key: ValueKey(_controller.statusFilter), + initialValue: _controller.statusFilter, + selectedOptionBuilder: (context, value) => + Text(_statusLabel(value)), + onChanged: (value) { + if (value == null) return; + _controller.updateStatusFilter(value); + }, + options: CustomerStatusFilter.values + .map( + (filter) => ShadOption( + value: filter, + child: Text(_statusLabel(filter)), + ), + ) + .toList(), + ), + ), + ShadButton.outline( + onPressed: _controller.isLoading ? null : _applyFilters, + child: const Text('검색 적용'), + ), + if (showReset) + ShadButton.ghost( + onPressed: _controller.isLoading + ? null + : () { + _searchController.clear(); + _searchFocus.requestFocus(); + _controller.updateQuery(''); + _controller.updateTypeFilter(CustomerTypeFilter.all); + _controller.updateStatusFilter( + CustomerStatusFilter.all, + ); + _controller.fetch(page: 1); + }, + child: const Text('초기화'), + ), + ], + ), + child: ShadCard( + title: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('고객사 목록', style: theme.textTheme.h3), + Text('$totalCount건', style: theme.textTheme.muted), + ], + ), + footer: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '페이지 $currentPage / $totalPages', + style: theme.textTheme.small, + ), + Row( + children: [ + ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: _controller.isLoading || currentPage <= 1 + ? null + : () => _controller.fetch(page: currentPage - 1), + child: const Text('이전'), + ), + const SizedBox(width: 8), + ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: _controller.isLoading || !hasNext + ? null + : () => _controller.fetch(page: currentPage + 1), + child: const Text('다음'), + ), + ], + ), + ], + ), + child: _controller.isLoading + ? const Padding( + padding: EdgeInsets.all(48), + child: Center(child: CircularProgressIndicator()), + ) + : customers.isEmpty ? Padding( padding: const EdgeInsets.all(32), child: Text( @@ -315,10 +291,8 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> { customers: customers, onEdit: _controller.isSubmitting ? null - : (customer) => _openCustomerForm( - context, - customer: customer, - ), + : (customer) => + _openCustomerForm(context, customer: customer), onDelete: _controller.isSubmitting ? null : _confirmDelete, @@ -326,8 +300,6 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> { ? null : _restoreCustomer, ), - ), - ], ), ); }, diff --git a/lib/features/masters/group/data/dtos/group_dto.dart b/lib/features/masters/group/data/dtos/group_dto.dart index da47868..cf3eeb8 100644 --- a/lib/features/masters/group/data/dtos/group_dto.dart +++ b/lib/features/masters/group/data/dtos/group_dto.dart @@ -6,6 +6,7 @@ class GroupDto { GroupDto({ this.id, required this.groupName, + this.description, this.isDefault = false, this.isActive = true, this.isDeleted = false, @@ -16,6 +17,7 @@ class GroupDto { final int? id; final String groupName; + final String? description; final bool isDefault; final bool isActive; final bool isDeleted; @@ -27,6 +29,7 @@ class GroupDto { return GroupDto( id: json['id'] as int?, groupName: json['group_name'] as String, + description: json['description'] as String?, isDefault: (json['is_default'] as bool?) ?? false, isActive: (json['is_active'] as bool?) ?? true, isDeleted: (json['is_deleted'] as bool?) ?? false, @@ -39,6 +42,7 @@ class GroupDto { Group toEntity() => Group( id: id, groupName: groupName, + description: description, isDefault: isDefault, isActive: isActive, isDeleted: isDeleted, diff --git a/lib/features/masters/group/data/repositories/group_repository_remote.dart b/lib/features/masters/group/data/repositories/group_repository_remote.dart index 107b207..2469034 100644 --- a/lib/features/masters/group/data/repositories/group_repository_remote.dart +++ b/lib/features/masters/group/data/repositories/group_repository_remote.dart @@ -18,6 +18,7 @@ class GroupRepositoryRemote implements GroupRepository { int page = 1, int pageSize = 20, String? query, + bool? isDefault, bool? isActive, }) async { final response = await _api.get>( @@ -26,10 +27,48 @@ class GroupRepositoryRemote implements GroupRepository { 'page': page, 'page_size': pageSize, if (query != null && query.isNotEmpty) 'q': query, + if (isDefault != null) 'is_default': isDefault, if (isActive != null) 'is_active': isActive, }, options: Options(responseType: ResponseType.json), ); return GroupDto.parsePaginated(response.data ?? const {}); } + + @override + Future create(GroupInput input) async { + final response = await _api.post>( + _basePath, + data: input.toPayload(), + options: Options(responseType: ResponseType.json), + ); + final data = (response.data?['data'] as Map?) ?? {}; + return GroupDto.fromJson(data).toEntity(); + } + + @override + Future update(int id, GroupInput input) async { + final response = await _api.patch>( + '$_basePath/$id', + data: input.toPayload(), + options: Options(responseType: ResponseType.json), + ); + final data = (response.data?['data'] as Map?) ?? {}; + return GroupDto.fromJson(data).toEntity(); + } + + @override + Future delete(int id) async { + await _api.delete('$_basePath/$id'); + } + + @override + Future restore(int id) async { + final response = await _api.post>( + '$_basePath/$id/restore', + options: Options(responseType: ResponseType.json), + ); + final data = (response.data?['data'] as Map?) ?? {}; + return GroupDto.fromJson(data).toEntity(); + } } diff --git a/lib/features/masters/group/domain/entities/group.dart b/lib/features/masters/group/domain/entities/group.dart index 92877e5..b005a67 100644 --- a/lib/features/masters/group/domain/entities/group.dart +++ b/lib/features/masters/group/domain/entities/group.dart @@ -1,7 +1,12 @@ +/// 그룹(권한 집합) 엔티티 +/// +/// - SRP: 그룹의 속성 정보만 표현한다. +/// - presentation/data 레이어의 구현 세부사항을 포함하지 않는다. class Group { Group({ this.id, required this.groupName, + this.description, this.isDefault = false, this.isActive = true, this.isDeleted = false, @@ -10,18 +15,35 @@ class Group { this.updatedAt, }); + /// PK (null 이면 신규 생성) final int? id; + + /// 그룹명 final String groupName; + + /// 그룹 설명(선택) + final String? description; + + /// 기본 그룹 여부 final bool isDefault; + + /// 사용 여부 final bool isActive; + + /// 삭제 여부(소프트 삭제) final bool isDeleted; + + /// 비고 메모 final String? note; + + /// 타임스탬프 final DateTime? createdAt; final DateTime? updatedAt; Group copyWith({ int? id, String? groupName, + String? description, bool? isDefault, bool? isActive, bool? isDeleted, @@ -32,6 +54,7 @@ class Group { return Group( id: id ?? this.id, groupName: groupName ?? this.groupName, + description: description ?? this.description, isDefault: isDefault ?? this.isDefault, isActive: isActive ?? this.isActive, isDeleted: isDeleted ?? this.isDeleted, @@ -41,3 +64,30 @@ class Group { ); } } + +/// 그룹 생성/수정 입력 모델 +class GroupInput { + GroupInput({ + required this.groupName, + this.description, + this.isDefault = false, + this.isActive = true, + this.note, + }); + + final String groupName; + final String? description; + final bool isDefault; + final bool isActive; + final String? note; + + Map toPayload() { + return { + 'group_name': groupName, + 'description': description, + 'is_default': isDefault, + 'is_active': isActive, + 'note': note, + }; + } +} diff --git a/lib/features/masters/group/domain/repositories/group_repository.dart b/lib/features/masters/group/domain/repositories/group_repository.dart index a428d00..5898c76 100644 --- a/lib/features/masters/group/domain/repositories/group_repository.dart +++ b/lib/features/masters/group/domain/repositories/group_repository.dart @@ -3,10 +3,24 @@ import 'package:superport_v2/core/common/models/paginated_result.dart'; import '../entities/group.dart'; abstract class GroupRepository { + /// 그룹 목록 조회 Future> list({ int page = 1, int pageSize = 20, String? query, + bool? isDefault, bool? isActive, }); + + /// 그룹 신규 등록 + Future create(GroupInput input); + + /// 그룹 정보 수정 + Future update(int id, GroupInput input); + + /// 그룹 삭제(소프트) + Future delete(int id); + + /// 그룹 복구 + Future restore(int id); } diff --git a/lib/features/masters/group/presentation/controllers/group_controller.dart b/lib/features/masters/group/presentation/controllers/group_controller.dart new file mode 100644 index 0000000..72d6d5e --- /dev/null +++ b/lib/features/masters/group/presentation/controllers/group_controller.dart @@ -0,0 +1,152 @@ +import 'package:flutter/foundation.dart'; +import 'package:superport_v2/core/common/models/paginated_result.dart'; + +import '../../domain/entities/group.dart'; +import '../../domain/repositories/group_repository.dart'; + +enum GroupDefaultFilter { all, defaultOnly, nonDefault } + +enum GroupStatusFilter { all, activeOnly, inactiveOnly } + +/// 그룹 마스터 화면 상태 컨트롤러 +/// +/// - 목록 조회 및 필터, 페이징 상태를 담당한다. +/// - 생성/수정/삭제/복구 요청을 래핑하여 UI와 통신한다. +class GroupController extends ChangeNotifier { + GroupController({required GroupRepository repository}) + : _repository = repository; + + final GroupRepository _repository; + + PaginatedResult? _result; + bool _isLoading = false; + bool _isSubmitting = false; + String _query = ''; + GroupDefaultFilter _defaultFilter = GroupDefaultFilter.all; + GroupStatusFilter _statusFilter = GroupStatusFilter.all; + String? _errorMessage; + + PaginatedResult? get result => _result; + bool get isLoading => _isLoading; + bool get isSubmitting => _isSubmitting; + String get query => _query; + GroupDefaultFilter get defaultFilter => _defaultFilter; + GroupStatusFilter get statusFilter => _statusFilter; + String? get errorMessage => _errorMessage; + + Future fetch({int page = 1}) async { + _isLoading = true; + _errorMessage = null; + notifyListeners(); + try { + final isDefault = switch (_defaultFilter) { + GroupDefaultFilter.all => null, + GroupDefaultFilter.defaultOnly => true, + GroupDefaultFilter.nonDefault => false, + }; + final isActive = switch (_statusFilter) { + GroupStatusFilter.all => null, + GroupStatusFilter.activeOnly => true, + GroupStatusFilter.inactiveOnly => false, + }; + final response = await _repository.list( + page: page, + pageSize: _result?.pageSize ?? 20, + query: _query.isEmpty ? null : _query, + isDefault: isDefault, + isActive: isActive, + ); + _result = response; + } catch (e) { + _errorMessage = e.toString(); + } finally { + _isLoading = false; + notifyListeners(); + } + } + + void updateQuery(String value) { + _query = value; + notifyListeners(); + } + + void updateDefaultFilter(GroupDefaultFilter filter) { + _defaultFilter = filter; + notifyListeners(); + } + + void updateStatusFilter(GroupStatusFilter filter) { + _statusFilter = filter; + notifyListeners(); + } + + Future create(GroupInput input) async { + _setSubmitting(true); + try { + final created = await _repository.create(input); + await fetch(page: 1); + return created; + } catch (e) { + _errorMessage = e.toString(); + notifyListeners(); + return null; + } finally { + _setSubmitting(false); + } + } + + Future update(int id, GroupInput input) async { + _setSubmitting(true); + try { + final updated = await _repository.update(id, input); + await fetch(page: _result?.page ?? 1); + return updated; + } catch (e) { + _errorMessage = e.toString(); + notifyListeners(); + return null; + } finally { + _setSubmitting(false); + } + } + + Future delete(int id) async { + _setSubmitting(true); + try { + await _repository.delete(id); + await fetch(page: _result?.page ?? 1); + return true; + } catch (e) { + _errorMessage = e.toString(); + notifyListeners(); + return false; + } finally { + _setSubmitting(false); + } + } + + Future restore(int id) async { + _setSubmitting(true); + try { + final restored = await _repository.restore(id); + await fetch(page: _result?.page ?? 1); + return restored; + } catch (e) { + _errorMessage = e.toString(); + notifyListeners(); + return null; + } finally { + _setSubmitting(false); + } + } + + void clearError() { + _errorMessage = null; + notifyListeners(); + } + + void _setSubmitting(bool value) { + _isSubmitting = value; + notifyListeners(); + } +} diff --git a/lib/features/masters/group/presentation/pages/group_page.dart b/lib/features/masters/group/presentation/pages/group_page.dart index 5dd0885..2c2bd22 100644 --- a/lib/features/masters/group/presentation/pages/group_page.dart +++ b/lib/features/masters/group/presentation/pages/group_page.dart @@ -1,40 +1,726 @@ -import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:superport_v2/core/constants/app_sections.dart'; +import 'package:superport_v2/widgets/app_layout.dart'; +import 'package:superport_v2/widgets/components/filter_bar.dart'; + +import '../../../../../core/config/environment.dart'; import '../../../../../widgets/spec_page.dart'; +import '../../domain/entities/group.dart'; +import '../../domain/repositories/group_repository.dart'; +import '../controllers/group_controller.dart'; class GroupPage extends StatelessWidget { const GroupPage({super.key}); @override Widget build(BuildContext context) { - return const SpecPage( - title: '그룹 관리', - summary: '권한 그룹 정의와 기본여부 설정을 제공합니다.', - sections: [ - SpecSection( - title: '입력 폼', - items: [ - '그룹명 [Text]', - '그룹설명 [Text]', - '기본여부 [Switch]', - '사용여부 [Switch]', - '비고 [Text]', + final enabled = Environment.flag('FEATURE_GROUPS_ENABLED'); + if (!enabled) { + return SpecPage( + title: '그룹 관리', + summary: '권한 그룹 정의와 기본 여부 설정을 제공합니다.', + trailing: ShadBadge( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(LucideIcons.info, size: 14), + const SizedBox(width: 6), + Text('비활성화 (백엔드 준비 중)'), + ], + ), + ), + ), + sections: const [ + SpecSection( + title: '입력 폼', + items: [ + '그룹명 [Text]', + '설명 [Textarea]', + '기본여부 [Switch]', + '사용여부 [Switch]', + '비고 [Textarea]', + ], + ), + SpecSection( + title: '수정 폼', + items: ['그룹명 [ReadOnly]', '생성일시 [ReadOnly]'], + ), + SpecSection( + title: '테이블 리스트', + description: '1행 예시', + table: SpecTable( + columns: ['번호', '그룹명', '설명', '기본여부', '사용여부', '비고', '변경일시'], + rows: [ + ['1', '관리자', '시스템 전체 권한', 'Y', 'Y', '-', '2024-03-01 10:00'], + ], + ), + ), + ], + ); + } + + return const _GroupEnabledPage(); + } +} + +class _GroupEnabledPage extends StatefulWidget { + const _GroupEnabledPage(); + + @override + State<_GroupEnabledPage> createState() => _GroupEnabledPageState(); +} + +class _GroupEnabledPageState extends State<_GroupEnabledPage> { + late final GroupController _controller; + final TextEditingController _searchController = TextEditingController(); + final FocusNode _searchFocus = FocusNode(); + final DateFormat _dateFormat = DateFormat('yyyy-MM-dd HH:mm'); + String? _lastError; + + @override + void initState() { + super.initState(); + _controller = GroupController(repository: GetIt.I()) + ..addListener(_handleControllerUpdate); + WidgetsBinding.instance.addPostFrameCallback((_) { + _controller.fetch(); + }); + } + + void _handleControllerUpdate() { + final error = _controller.errorMessage; + if (error != null && error != _lastError && mounted) { + _lastError = error; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(error))); + _controller.clearError(); + } + } + + @override + void dispose() { + _controller.removeListener(_handleControllerUpdate); + _controller.dispose(); + _searchController.dispose(); + _searchFocus.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + + return AnimatedBuilder( + animation: _controller, + builder: (context, _) { + final result = _controller.result; + final groups = result?.items ?? const []; + final totalCount = result?.total ?? 0; + final currentPage = result?.page ?? 1; + final totalPages = result == null || result.pageSize == 0 + ? 1 + : (result.total / result.pageSize).ceil().clamp(1, 9999); + final hasNext = result == null + ? false + : (result.page * result.pageSize) < result.total; + + final showReset = _searchController.text.isNotEmpty || + _controller.defaultFilter != GroupDefaultFilter.all || + _controller.statusFilter != GroupStatusFilter.all; + + return AppLayout( + title: '그룹 관리', + subtitle: '권한 그룹 정의와 기본 여부, 사용 상태를 관리합니다.', + breadcrumbs: const [ + AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath), + AppBreadcrumbItem(label: '마스터', path: '/masters/groups'), + AppBreadcrumbItem(label: '그룹'), ], - ), - SpecSection( - title: '수정 폼', - items: ['그룹명 [ReadOnly]', '생성일시 [ReadOnly]'], - ), - SpecSection( - title: '테이블 리스트', - description: '1행 예시', - table: SpecTable( - columns: ['번호', '그룹명', '설명', '기본여부', '사용여부', '비고', '변경일시'], - rows: [ - ['1', '관리자', '시스템 전체 권한', 'Y', 'Y', '-', '2024-03-01 10:00'], + actions: [ + ShadButton( + leading: const Icon(LucideIcons.plus, size: 16), + onPressed: + _controller.isSubmitting ? null : () => _openGroupForm(context), + child: const Text('신규 등록'), + ), + ], + toolbar: FilterBar( + children: [ + SizedBox( + width: 260, + child: ShadInput( + controller: _searchController, + focusNode: _searchFocus, + placeholder: const Text('그룹명, 설명 검색'), + leading: const Icon(LucideIcons.search, size: 16), + onSubmitted: (_) => _applyFilters(), + ), + ), + SizedBox( + width: 200, + child: ShadSelect( + key: ValueKey(_controller.defaultFilter), + initialValue: _controller.defaultFilter, + selectedOptionBuilder: (context, filter) => + Text(_defaultLabel(filter)), + onChanged: (value) { + if (value == null) return; + _controller.updateDefaultFilter(value); + _controller.fetch(page: 1); + }, + options: GroupDefaultFilter.values + .map( + (filter) => ShadOption( + value: filter, + child: Text(_defaultLabel(filter)), + ), + ) + .toList(), + ), + ), + SizedBox( + width: 200, + child: ShadSelect( + key: ValueKey(_controller.statusFilter), + initialValue: _controller.statusFilter, + selectedOptionBuilder: (context, filter) => + Text(_statusLabel(filter)), + onChanged: (value) { + if (value == null) return; + _controller.updateStatusFilter(value); + _controller.fetch(page: 1); + }, + options: GroupStatusFilter.values + .map( + (filter) => ShadOption( + value: filter, + child: Text(_statusLabel(filter)), + ), + ) + .toList(), + ), + ), + ShadButton.outline( + onPressed: _controller.isLoading ? null : _applyFilters, + child: const Text('검색 적용'), + ), + if (showReset) + ShadButton.ghost( + onPressed: _controller.isLoading + ? null + : () { + _searchController.clear(); + _searchFocus.requestFocus(); + _controller.updateQuery(''); + _controller.updateDefaultFilter( + GroupDefaultFilter.all, + ); + _controller.updateStatusFilter( + GroupStatusFilter.all, + ); + _controller.fetch(page: 1); + }, + child: const Text('초기화'), + ), + ], + ), + child: ShadCard( + title: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('그룹 목록', style: theme.textTheme.h3), + Text('$totalCount건', style: theme.textTheme.muted), + ], + ), + footer: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '페이지 $currentPage / $totalPages', + style: theme.textTheme.small, + ), + Row( + children: [ + ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: _controller.isLoading || currentPage <= 1 + ? null + : () => _controller.fetch(page: currentPage - 1), + child: const Text('이전'), + ), + const SizedBox(width: 8), + ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: _controller.isLoading || !hasNext + ? null + : () => _controller.fetch(page: currentPage + 1), + child: const Text('다음'), + ), + ], + ), + ], + ), + child: _controller.isLoading + ? const Padding( + padding: EdgeInsets.all(48), + child: Center(child: CircularProgressIndicator()), + ) + : groups.isEmpty + ? Padding( + padding: const EdgeInsets.all(32), + child: Text( + '조건에 맞는 그룹이 없습니다.', + style: theme.textTheme.muted, + ), + ) + : _GroupTable( + groups: groups, + dateFormat: _dateFormat, + onEdit: _controller.isSubmitting + ? null + : (group) => _openGroupForm(context, group: group), + onDelete: _controller.isSubmitting + ? null + : _confirmDelete, + onRestore: _controller.isSubmitting + ? null + : _restoreGroup, + ), + ), + ); + }, + ); + } + + void _applyFilters() { + _controller.updateQuery(_searchController.text.trim()); + _controller.fetch(page: 1); + } + + String _defaultLabel(GroupDefaultFilter filter) { + switch (filter) { + case GroupDefaultFilter.all: + return '전체(기본/일반)'; + case GroupDefaultFilter.defaultOnly: + return '기본 그룹만'; + case GroupDefaultFilter.nonDefault: + return '일반 그룹만'; + } + } + + String _statusLabel(GroupStatusFilter filter) { + switch (filter) { + case GroupStatusFilter.all: + return '전체(사용/미사용)'; + case GroupStatusFilter.activeOnly: + return '사용중'; + case GroupStatusFilter.inactiveOnly: + return '미사용'; + } + } + + Future _openGroupForm(BuildContext context, {Group? group}) async { + final existingGroup = group; + final isEdit = existingGroup != null; + final groupId = existingGroup?.id; + if (isEdit && groupId == null) { + _showSnack('ID 정보가 없어 수정할 수 없습니다.'); + return; + } + + final nameController = TextEditingController( + text: existingGroup?.groupName ?? '', + ); + final descriptionController = TextEditingController( + text: existingGroup?.description ?? '', + ); + final noteController = TextEditingController( + text: existingGroup?.note ?? '', + ); + final isDefaultNotifier = ValueNotifier( + existingGroup?.isDefault ?? false, + ); + final isActiveNotifier = ValueNotifier( + existingGroup?.isActive ?? true, + ); + final saving = ValueNotifier(false); + final nameError = ValueNotifier(null); + + await showDialog( + context: context, + builder: (dialogContext) { + final theme = ShadTheme.of(dialogContext); + final materialTheme = Theme.of(dialogContext); + final navigator = Navigator.of(dialogContext); + return Dialog( + insetPadding: const EdgeInsets.all(24), + clipBehavior: Clip.antiAlias, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 540), + child: ShadCard( + title: Text( + isEdit ? '그룹 수정' : '그룹 등록', + style: theme.textTheme.h3, + ), + description: Text( + '그룹 정보를 ${isEdit ? '수정' : '입력'}하세요.', + style: theme.textTheme.muted, + ), + footer: ValueListenableBuilder( + valueListenable: saving, + builder: (_, isSaving, __) { + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + ShadButton.ghost( + onPressed: isSaving ? null : () => navigator.pop(false), + child: const Text('취소'), + ), + const SizedBox(width: 12), + ShadButton( + onPressed: isSaving + ? null + : () async { + final name = nameController.text.trim(); + final description = descriptionController.text + .trim(); + final note = noteController.text.trim(); + + nameError.value = name.isEmpty + ? '그룹명을 입력하세요.' + : null; + + if (nameError.value != null) { + return; + } + + saving.value = true; + final input = GroupInput( + groupName: name, + description: description.isEmpty + ? null + : description, + isDefault: isDefaultNotifier.value, + isActive: isActiveNotifier.value, + note: note.isEmpty ? null : note, + ); + final response = isEdit + ? await _controller.update(groupId!, input) + : await _controller.create(input); + saving.value = false; + if (response != null) { + if (!navigator.mounted) { + return; + } + if (mounted) { + _showSnack( + isEdit ? '그룹을 수정했습니다.' : '그룹을 등록했습니다.', + ); + } + navigator.pop(true); + } + }, + child: Text(isEdit ? '저장' : '등록'), + ), + ], + ); + }, + ), + child: SizedBox( + width: double.infinity, + child: SingleChildScrollView( + padding: const EdgeInsets.only(right: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + ValueListenableBuilder( + valueListenable: nameError, + builder: (_, errorText, __) { + return _FormField( + label: '그룹명', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShadInput( + controller: nameController, + readOnly: isEdit, + onChanged: (_) { + if (nameController.text.trim().isNotEmpty) { + nameError.value = null; + } + }, + ), + if (errorText != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + errorText, + style: theme.textTheme.small.copyWith( + color: materialTheme.colorScheme.error, + ), + ), + ), + ], + ), + ); + }, + ), + const SizedBox(height: 16), + _FormField( + label: '설명', + child: ShadTextarea(controller: descriptionController), + ), + const SizedBox(height: 16), + ValueListenableBuilder( + valueListenable: isDefaultNotifier, + builder: (_, value, __) { + return _FormField( + label: '기본여부', + child: Row( + children: [ + ShadSwitch( + value: value, + onChanged: saving.value + ? null + : (next) => + isDefaultNotifier.value = next, + ), + const SizedBox(width: 8), + Text(value ? '기본 그룹' : '일반 그룹'), + ], + ), + ); + }, + ), + const SizedBox(height: 16), + ValueListenableBuilder( + valueListenable: isActiveNotifier, + builder: (_, value, __) { + return _FormField( + label: '사용여부', + child: Row( + children: [ + ShadSwitch( + value: value, + onChanged: saving.value + ? null + : (next) => isActiveNotifier.value = next, + ), + const SizedBox(width: 8), + Text(value ? '사용' : '미사용'), + ], + ), + ); + }, + ), + const SizedBox(height: 16), + _FormField( + label: '비고', + child: ShadTextarea(controller: noteController), + ), + if (isEdit) ...[ + const SizedBox(height: 20), + Text( + '생성일시: ${_formatDateTime(existingGroup.createdAt)}', + style: theme.textTheme.small, + ), + const SizedBox(height: 4), + Text( + '수정일시: ${_formatDateTime(existingGroup.updatedAt)}', + style: theme.textTheme.small, + ), + ], + ], + ), + ), + ), + ), + ), + ); + }, + ); + + nameController.dispose(); + descriptionController.dispose(); + noteController.dispose(); + isDefaultNotifier.dispose(); + isActiveNotifier.dispose(); + saving.dispose(); + nameError.dispose(); + } + + Future _confirmDelete(Group group) async { + final confirmed = await showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: const Text('그룹 삭제'), + content: Text('"${group.groupName}" 그룹을 삭제하시겠습니까?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: const Text('취소'), + ), + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: const Text('삭제'), + ), + ], + ); + }, + ); + + if (confirmed == true && group.id != null) { + final success = await _controller.delete(group.id!); + if (success && mounted) { + _showSnack('그룹을 삭제했습니다.'); + } + } + } + + Future _restoreGroup(Group group) async { + if (group.id == null) return; + final restored = await _controller.restore(group.id!); + if (restored != null && mounted) { + _showSnack('그룹을 복구했습니다.'); + } + } + + void _showSnack(String message) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } + + String _formatDateTime(DateTime? value) { + if (value == null) { + return '-'; + } + return _dateFormat.format(value.toLocal()); + } +} + +class _GroupTable extends StatelessWidget { + const _GroupTable({ + required this.groups, + required this.onEdit, + required this.onDelete, + required this.onRestore, + required this.dateFormat, + }); + + final List groups; + final void Function(Group group)? onEdit; + final void Function(Group group)? onDelete; + final void Function(Group group)? onRestore; + final DateFormat dateFormat; + + @override + Widget build(BuildContext context) { + final header = [ + 'ID', + '그룹명', + '설명', + '기본', + '사용', + '삭제', + '비고', + '변경일시', + '동작', + ].map((text) => ShadTableCell.header(child: Text(text))).toList(); + + final rows = groups.map((group) { + final cells = [ + group.id?.toString() ?? '-', + group.groupName, + (group.description?.isEmpty ?? true) ? '-' : group.description!, + group.isDefault ? 'Y' : 'N', + group.isActive ? 'Y' : 'N', + group.isDeleted ? 'Y' : '-', + (group.note?.isEmpty ?? true) ? '-' : group.note!, + group.updatedAt == null + ? '-' + : dateFormat.format(group.updatedAt!.toLocal()), + ].map((text) => ShadTableCell(child: Text(text))).toList(); + + cells.add( + ShadTableCell( + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + ShadButton.ghost( + size: ShadButtonSize.sm, + onPressed: onEdit == null ? null : () => onEdit!(group), + child: const Icon(LucideIcons.pencil, size: 16), + ), + const SizedBox(width: 8), + group.isDeleted + ? ShadButton.ghost( + size: ShadButtonSize.sm, + onPressed: onRestore == null + ? null + : () => onRestore!(group), + child: const Icon(LucideIcons.history, size: 16), + ) + : ShadButton.ghost( + size: ShadButtonSize.sm, + onPressed: onDelete == null + ? null + : () => onDelete!(group), + child: const Icon(LucideIcons.trash2, size: 16), + ), ], ), ), + ); + return cells; + }).toList(); + + return SizedBox( + height: 56.0 * (groups.length + 1), + child: ShadTable.list( + header: header, + children: rows, + columnSpanExtent: (index) { + if (index == 8) { + return const FixedTableSpanExtent(160); + } + if (index == 2) { + return const FixedTableSpanExtent(220); + } + if (index == 6) { + return const FixedTableSpanExtent(200); + } + return const FixedTableSpanExtent(120); + }, + ), + ); + } +} + +class _FormField extends StatelessWidget { + const _FormField({required this.label, required this.child}); + + final String label; + final Widget child; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: theme.textTheme.small), + const SizedBox(height: 6), + child, ], ); } diff --git a/lib/features/masters/group_permission/data/dtos/group_permission_dto.dart b/lib/features/masters/group_permission/data/dtos/group_permission_dto.dart new file mode 100644 index 0000000..360948f --- /dev/null +++ b/lib/features/masters/group_permission/data/dtos/group_permission_dto.dart @@ -0,0 +1,127 @@ +import 'package:superport_v2/core/common/models/paginated_result.dart'; + +import '../../domain/entities/group_permission.dart'; + +class GroupPermissionDto { + GroupPermissionDto({ + this.id, + required this.group, + required this.menu, + this.canCreate = false, + this.canRead = true, + this.canUpdate = false, + this.canDelete = false, + this.isActive = true, + this.isDeleted = false, + this.note, + this.createdAt, + this.updatedAt, + }); + + final int? id; + final GroupPermissionGroupDto group; + final GroupPermissionMenuDto menu; + final bool canCreate; + final bool canRead; + final bool canUpdate; + final bool canDelete; + final bool isActive; + final bool isDeleted; + final String? note; + final DateTime? createdAt; + final DateTime? updatedAt; + + factory GroupPermissionDto.fromJson(Map json) { + return GroupPermissionDto( + id: json['id'] as int?, + group: GroupPermissionGroupDto.fromJson( + (json['group'] as Map? ?? const {}), + ), + menu: GroupPermissionMenuDto.fromJson( + (json['menu'] as Map? ?? const {}), + ), + canCreate: (json['can_create'] as bool?) ?? false, + canRead: (json['can_read'] as bool?) ?? true, + canUpdate: (json['can_update'] as bool?) ?? false, + canDelete: (json['can_delete'] as bool?) ?? false, + isActive: (json['is_active'] as bool?) ?? true, + isDeleted: (json['is_deleted'] as bool?) ?? false, + note: json['note'] as String?, + createdAt: _parseDate(json['created_at']), + updatedAt: _parseDate(json['updated_at']), + ); + } + + GroupPermission toEntity() => GroupPermission( + id: id, + group: group.toEntity(), + menu: menu.toEntity(), + canCreate: canCreate, + canRead: canRead, + canUpdate: canUpdate, + canDelete: canDelete, + isActive: isActive, + isDeleted: isDeleted, + note: note, + createdAt: createdAt, + updatedAt: updatedAt, + ); + + static PaginatedResult parsePaginated( + Map? json, + ) { + final items = (json?['items'] as List? ?? []) + .whereType>() + .map(GroupPermissionDto.fromJson) + .map((dto) => dto.toEntity()) + .toList(); + return PaginatedResult( + items: items, + page: json?['page'] as int? ?? 1, + pageSize: json?['page_size'] as int? ?? items.length, + total: json?['total'] as int? ?? items.length, + ); + } +} + +class GroupPermissionGroupDto { + GroupPermissionGroupDto({required this.id, required this.groupName}); + + final int id; + final String groupName; + + factory GroupPermissionGroupDto.fromJson(Map json) { + return GroupPermissionGroupDto( + id: json['id'] as int? ?? json['group_id'] as int, + groupName: + json['group_name'] as String? ?? json['name'] as String? ?? '-', + ); + } + + GroupPermissionGroup toEntity() => + GroupPermissionGroup(id: id, groupName: groupName); +} + +class GroupPermissionMenuDto { + GroupPermissionMenuDto({required this.id, required this.menuName}); + + final int id; + final String menuName; + + factory GroupPermissionMenuDto.fromJson(Map json) { + return GroupPermissionMenuDto( + id: json['id'] as int? ?? json['menu_id'] as int, + menuName: json['menu_name'] as String? ?? json['name'] as String? ?? '-', + ); + } + + GroupPermissionMenu toEntity() => + GroupPermissionMenu(id: id, menuName: menuName); +} + +DateTime? _parseDate(Object? value) { + if (value == null) return null; + if (value is DateTime) return value; + if (value is String) return DateTime.tryParse(value); + return null; +} diff --git a/lib/features/masters/group_permission/data/repositories/group_permission_repository_remote.dart b/lib/features/masters/group_permission/data/repositories/group_permission_repository_remote.dart new file mode 100644 index 0000000..3430d74 --- /dev/null +++ b/lib/features/masters/group_permission/data/repositories/group_permission_repository_remote.dart @@ -0,0 +1,78 @@ +import 'package:dio/dio.dart'; +import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/core/network/api_client.dart'; + +import '../../domain/entities/group_permission.dart'; +import '../../domain/repositories/group_permission_repository.dart'; +import '../dtos/group_permission_dto.dart'; + +class GroupPermissionRepositoryRemote implements GroupPermissionRepository { + GroupPermissionRepositoryRemote({required ApiClient apiClient}) + : _api = apiClient; + + final ApiClient _api; + + static const _basePath = '/group-menu-permissions'; + + @override + Future> list({ + int page = 1, + int pageSize = 20, + int? groupId, + int? menuId, + bool? isActive, + bool includeDeleted = false, + }) async { + final response = await _api.get>( + _basePath, + query: { + 'page': page, + 'page_size': pageSize, + if (groupId != null) 'group_id': groupId, + if (menuId != null) 'menu_id': menuId, + if (isActive != null) 'is_active': isActive, + if (includeDeleted) 'include_deleted': true, + 'include': 'group,menu', + }, + options: Options(responseType: ResponseType.json), + ); + return GroupPermissionDto.parsePaginated(response.data ?? const {}); + } + + @override + Future create(GroupPermissionInput input) async { + final response = await _api.post>( + _basePath, + data: input.toPayload(), + options: Options(responseType: ResponseType.json), + ); + final data = (response.data?['data'] as Map?) ?? {}; + return GroupPermissionDto.fromJson(data).toEntity(); + } + + @override + Future update(int id, GroupPermissionInput input) async { + final response = await _api.patch>( + '$_basePath/$id', + data: input.toPayload(), + options: Options(responseType: ResponseType.json), + ); + final data = (response.data?['data'] as Map?) ?? {}; + return GroupPermissionDto.fromJson(data).toEntity(); + } + + @override + Future delete(int id) async { + await _api.delete('$_basePath/$id'); + } + + @override + Future restore(int id) async { + final response = await _api.post>( + '$_basePath/$id/restore', + options: Options(responseType: ResponseType.json), + ); + final data = (response.data?['data'] as Map?) ?? {}; + return GroupPermissionDto.fromJson(data).toEntity(); + } +} diff --git a/lib/features/masters/group_permission/domain/entities/group_permission.dart b/lib/features/masters/group_permission/domain/entities/group_permission.dart new file mode 100644 index 0000000..af008c0 --- /dev/null +++ b/lib/features/masters/group_permission/domain/entities/group_permission.dart @@ -0,0 +1,113 @@ +/// 그룹-메뉴 권한 엔티티 +/// +/// - 그룹과 메뉴별 CRUD 권한을 표현한다. +/// - presentation/data 레이어 세부 사항에 의존하지 않는다. +class GroupPermission { + GroupPermission({ + this.id, + required this.group, + required this.menu, + this.canCreate = false, + this.canRead = true, + this.canUpdate = false, + this.canDelete = false, + this.isActive = true, + this.isDeleted = false, + this.note, + this.createdAt, + this.updatedAt, + }); + + final int? id; + final GroupPermissionGroup group; + final GroupPermissionMenu menu; + final bool canCreate; + final bool canRead; + final bool canUpdate; + final bool canDelete; + final bool isActive; + final bool isDeleted; + final String? note; + final DateTime? createdAt; + final DateTime? updatedAt; + + GroupPermission copyWith({ + int? id, + GroupPermissionGroup? group, + GroupPermissionMenu? menu, + bool? canCreate, + bool? canRead, + bool? canUpdate, + bool? canDelete, + bool? isActive, + bool? isDeleted, + String? note, + DateTime? createdAt, + DateTime? updatedAt, + }) { + return GroupPermission( + id: id ?? this.id, + group: group ?? this.group, + menu: menu ?? this.menu, + canCreate: canCreate ?? this.canCreate, + canRead: canRead ?? this.canRead, + canUpdate: canUpdate ?? this.canUpdate, + canDelete: canDelete ?? this.canDelete, + isActive: isActive ?? this.isActive, + isDeleted: isDeleted ?? this.isDeleted, + note: note ?? this.note, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } +} + +class GroupPermissionGroup { + GroupPermissionGroup({required this.id, required this.groupName}); + + final int id; + final String groupName; +} + +class GroupPermissionMenu { + GroupPermissionMenu({required this.id, required this.menuName}); + + final int id; + final String menuName; +} + +/// 그룹 권한 생성/수정 입력 모델 +class GroupPermissionInput { + GroupPermissionInput({ + required this.groupId, + required this.menuId, + this.canCreate = false, + this.canRead = true, + this.canUpdate = false, + this.canDelete = false, + this.isActive = true, + this.note, + }); + + final int groupId; + final int menuId; + final bool canCreate; + final bool canRead; + final bool canUpdate; + final bool canDelete; + final bool isActive; + final String? note; + + Map toPayload() { + return { + 'group_id': groupId, + 'menu_id': menuId, + 'can_create': canCreate, + 'can_read': canRead, + 'can_update': canUpdate, + 'can_delete': canDelete, + 'is_active': isActive, + 'note': note, + }; + } +} diff --git a/lib/features/masters/group_permission/domain/repositories/group_permission_repository.dart b/lib/features/masters/group_permission/domain/repositories/group_permission_repository.dart new file mode 100644 index 0000000..8672e01 --- /dev/null +++ b/lib/features/masters/group_permission/domain/repositories/group_permission_repository.dart @@ -0,0 +1,22 @@ +import 'package:superport_v2/core/common/models/paginated_result.dart'; + +import '../entities/group_permission.dart'; + +abstract class GroupPermissionRepository { + Future> list({ + int page = 1, + int pageSize = 20, + int? groupId, + int? menuId, + bool? isActive, + bool includeDeleted = false, + }); + + Future create(GroupPermissionInput input); + + Future update(int id, GroupPermissionInput input); + + Future delete(int id); + + Future restore(int id); +} 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 new file mode 100644 index 0000000..44683e5 --- /dev/null +++ b/lib/features/masters/group_permission/presentation/controllers/group_permission_controller.dart @@ -0,0 +1,208 @@ +import 'package:flutter/foundation.dart'; +import 'package:superport_v2/core/common/models/paginated_result.dart'; + +import '../../../group/domain/entities/group.dart'; +import '../../../group/domain/repositories/group_repository.dart'; +import '../../../menu/domain/entities/menu.dart'; +import '../../../menu/domain/repositories/menu_repository.dart'; +import '../../domain/entities/group_permission.dart'; +import '../../domain/repositories/group_permission_repository.dart'; + +enum GroupPermissionStatusFilter { all, activeOnly, inactiveOnly } + +/// 그룹-메뉴 권한 화면용 컨트롤러 +/// +/// - 목록/필터 상태를 관리하고, CRUD/복구 동작을 래핑한다. +/// - 그룹/메뉴 선택을 위해 참조 데이터를 로드한다. +class GroupPermissionController extends ChangeNotifier { + GroupPermissionController({ + required GroupPermissionRepository permissionRepository, + required GroupRepository groupRepository, + required MenuRepository menuRepository, + }) : _permissionRepository = permissionRepository, + _groupRepository = groupRepository, + _menuRepository = menuRepository; + + final GroupPermissionRepository _permissionRepository; + final GroupRepository _groupRepository; + final MenuRepository _menuRepository; + + PaginatedResult? _result; + bool _isLoading = false; + bool _isSubmitting = false; + bool _isLoadingGroups = false; + bool _isLoadingMenus = false; + String? _errorMessage; + GroupPermissionStatusFilter _statusFilter = GroupPermissionStatusFilter.all; + int? _groupFilter; + int? _menuFilter; + bool _includeDeleted = false; + final List _groups = []; + final List _menus = []; + + PaginatedResult? get result => _result; + bool get isLoading => _isLoading; + bool get isSubmitting => _isSubmitting; + bool get isLoadingGroups => _isLoadingGroups; + bool get isLoadingMenus => _isLoadingMenus; + String? get errorMessage => _errorMessage; + GroupPermissionStatusFilter get statusFilter => _statusFilter; + int? get groupFilter => _groupFilter; + int? get menuFilter => _menuFilter; + bool get includeDeleted => _includeDeleted; + List get groups => List.unmodifiable(_groups); + List get menus => List.unmodifiable(_menus); + + Future loadGroups() async { + _isLoadingGroups = true; + notifyListeners(); + try { + final response = await _groupRepository.list(page: 1, pageSize: 200); + _groups + ..clear() + ..addAll(response.items); + } catch (e) { + _errorMessage = e.toString(); + } finally { + _isLoadingGroups = false; + notifyListeners(); + } + } + + Future loadMenus() async { + _isLoadingMenus = true; + notifyListeners(); + try { + final response = await _menuRepository.list( + page: 1, + pageSize: 200, + includeDeleted: false, + ); + _menus + ..clear() + ..addAll(response.items); + } catch (e) { + _errorMessage = e.toString(); + } finally { + _isLoadingMenus = false; + notifyListeners(); + } + } + + Future fetch({int page = 1}) async { + _isLoading = true; + _errorMessage = null; + notifyListeners(); + try { + final isActive = switch (_statusFilter) { + GroupPermissionStatusFilter.all => null, + GroupPermissionStatusFilter.activeOnly => true, + GroupPermissionStatusFilter.inactiveOnly => false, + }; + final response = await _permissionRepository.list( + page: page, + pageSize: _result?.pageSize ?? 20, + groupId: _groupFilter, + menuId: _menuFilter, + isActive: isActive, + includeDeleted: _includeDeleted, + ); + _result = response; + } catch (e) { + _errorMessage = e.toString(); + } finally { + _isLoading = false; + notifyListeners(); + } + } + + void updateGroupFilter(int? groupId) { + _groupFilter = groupId; + notifyListeners(); + } + + void updateMenuFilter(int? menuId) { + _menuFilter = menuId; + notifyListeners(); + } + + void updateStatusFilter(GroupPermissionStatusFilter filter) { + _statusFilter = filter; + notifyListeners(); + } + + void updateIncludeDeleted(bool value) { + _includeDeleted = value; + notifyListeners(); + } + + Future create(GroupPermissionInput input) async { + _setSubmitting(true); + try { + final created = await _permissionRepository.create(input); + await fetch(page: 1); + return created; + } catch (e) { + _errorMessage = e.toString(); + notifyListeners(); + return null; + } finally { + _setSubmitting(false); + } + } + + Future update(int id, GroupPermissionInput input) async { + _setSubmitting(true); + try { + final updated = await _permissionRepository.update(id, input); + await fetch(page: _result?.page ?? 1); + return updated; + } catch (e) { + _errorMessage = e.toString(); + notifyListeners(); + return null; + } finally { + _setSubmitting(false); + } + } + + Future delete(int id) async { + _setSubmitting(true); + try { + await _permissionRepository.delete(id); + await fetch(page: _result?.page ?? 1); + return true; + } catch (e) { + _errorMessage = e.toString(); + notifyListeners(); + return false; + } finally { + _setSubmitting(false); + } + } + + Future restore(int id) async { + _setSubmitting(true); + try { + final restored = await _permissionRepository.restore(id); + await fetch(page: _result?.page ?? 1); + return restored; + } catch (e) { + _errorMessage = e.toString(); + notifyListeners(); + return null; + } finally { + _setSubmitting(false); + } + } + + void clearError() { + _errorMessage = null; + notifyListeners(); + } + + void _setSubmitting(bool value) { + _isSubmitting = value; + notifyListeners(); + } +} 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 3ab4305..31e7e78 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 @@ -1,50 +1,966 @@ -import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import 'package:intl/intl.dart' as intl; +import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:superport_v2/core/constants/app_sections.dart'; +import 'package:superport_v2/widgets/app_layout.dart'; +import 'package:superport_v2/widgets/components/filter_bar.dart'; + +import '../../../../../core/config/environment.dart'; import '../../../../../widgets/spec_page.dart'; +import '../../../group/domain/entities/group.dart'; +import '../../../group/domain/repositories/group_repository.dart'; +import '../../../menu/domain/entities/menu.dart'; +import '../../../menu/domain/repositories/menu_repository.dart'; +import '../../domain/entities/group_permission.dart'; +import '../../domain/repositories/group_permission_repository.dart'; +import '../controllers/group_permission_controller.dart'; class GroupPermissionPage extends StatelessWidget { const GroupPermissionPage({super.key}); @override Widget build(BuildContext context) { - return const SpecPage( - title: '그룹 메뉴 권한 관리', - summary: '그룹별 메뉴 접근과 CRUD 권한을 설정합니다.', - sections: [ - SpecSection( - title: '입력 폼', - items: [ - '그룹 [Dropdown]', - '메뉴 [Dropdown]', - '생성권한 [Checkbox]', - '조회권한 [Checkbox]', - '수정권한 [Checkbox]', - '삭제권한 [Checkbox]', - '사용여부 [Switch]', - ], + final enabled = Environment.flag('FEATURE_GROUP_PERMISSIONS_ENABLED'); + if (!enabled) { + return SpecPage( + title: '그룹 권한 관리', + summary: '그룹과 메뉴별 CRUD 권한을 확인하고 수정합니다.', + trailing: ShadBadge( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: const [ + Icon(LucideIcons.info, size: 14), + SizedBox(width: 6), + Text('비활성화 (백엔드 준비 중)'), + ], + ), + ), ), - SpecSection(title: '수정 폼', items: ['그룹 [ReadOnly]', '메뉴 [ReadOnly]']), - SpecSection( - title: '테이블 리스트', - description: '1행 예시', - table: SpecTable( - columns: [ - '번호', - '그룹명', - '메뉴명', - '생성', - '조회', - '수정', - '삭제', - '사용여부', - '변경일시', + sections: const [ + SpecSection( + title: '입력 폼', + items: [ + '그룹 [Dropdown]', + '메뉴 [Dropdown]', + '생성권한 [Checkbox]', + '조회권한 [Checkbox]', + '수정권한 [Checkbox]', + '삭제권한 [Checkbox]', + '사용여부 [Switch]', + '비고 [Text]', ], - rows: [ - ['1', '관리자', '대시보드', 'Y', 'Y', 'Y', 'Y', 'Y', '2024-03-01 10:00'], + ), + SpecSection( + title: '수정 폼', + items: ['그룹 [ReadOnly]', '메뉴 [ReadOnly]', '생성일시 [ReadOnly]'], + ), + SpecSection( + title: '테이블 리스트', + description: '1행 예시', + table: SpecTable( + columns: [ + '번호', + '그룹명', + '메뉴명', + '생성', + '조회', + '수정', + '삭제', + '사용여부', + '비고', + '변경일시', + ], + rows: [ + [ + '1', + '관리자', + '대시보드', + 'Y', + 'Y', + 'Y', + 'Y', + 'Y', + '-', + '2024-03-01 10:00', + ], + ], + ), + ), + ], + ); + } + + return const _GroupPermissionEnabledPage(); + } +} + +class _GroupPermissionEnabledPage extends StatefulWidget { + const _GroupPermissionEnabledPage(); + + @override + State<_GroupPermissionEnabledPage> createState() => + _GroupPermissionEnabledPageState(); +} + +class _GroupPermissionEnabledPageState + extends State<_GroupPermissionEnabledPage> { + late final GroupPermissionController _controller; + final TextEditingController _searchController = TextEditingController(); + final FocusNode _searchFocus = FocusNode(); + final intl.DateFormat _dateFormat = intl.DateFormat('yyyy-MM-dd HH:mm'); + String? _lastError; + + @override + void initState() { + super.initState(); + _controller = GroupPermissionController( + permissionRepository: GetIt.I(), + groupRepository: GetIt.I(), + menuRepository: GetIt.I(), + )..addListener(_handleControllerUpdate); + WidgetsBinding.instance.addPostFrameCallback((_) async { + await _controller.loadGroups(); + await _controller.loadMenus(); + await _controller.fetch(); + }); + } + + void _handleControllerUpdate() { + final error = _controller.errorMessage; + if (error != null && error != _lastError && mounted) { + _lastError = error; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(error))); + _controller.clearError(); + } + } + + @override + void dispose() { + _controller.removeListener(_handleControllerUpdate); + _controller.dispose(); + _searchController.dispose(); + _searchFocus.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + + return AnimatedBuilder( + animation: _controller, + builder: (context, _) { + final result = _controller.result; + final permissions = result?.items ?? const []; + final totalCount = result?.total ?? 0; + final currentPage = result?.page ?? 1; + final totalPages = result == null || result.pageSize == 0 + ? 1 + : (result.total / result.pageSize).ceil().clamp(1, 9999); + final hasNext = result == null + ? false + : (result.page * result.pageSize) < result.total; + + final showReset = _searchController.text.isNotEmpty || + _controller.groupFilter != null || + _controller.menuFilter != null || + _controller.statusFilter != GroupPermissionStatusFilter.all || + _controller.includeDeleted; + + return AppLayout( + title: '그룹 권한 관리', + subtitle: '그룹별 메뉴 CRUD 권한을 체크박스로 관리합니다.', + breadcrumbs: const [ + AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath), + AppBreadcrumbItem(label: '마스터', path: '/masters/group-permissions'), + AppBreadcrumbItem(label: '그룹 권한'), + ], + actions: [ + ShadButton( + leading: const Icon(LucideIcons.plus, size: 16), + onPressed: _controller.isSubmitting + ? null + : () => _openPermissionForm(context), + child: const Text('신규 등록'), + ), + ], + toolbar: FilterBar( + children: [ + SizedBox( + width: 260, + child: ShadInput( + controller: _searchController, + focusNode: _searchFocus, + placeholder: const Text('그룹/메뉴/비고 검색'), + leading: const Icon(LucideIcons.search, size: 16), + onSubmitted: (_) => _applyFilters(), + ), + ), + SizedBox( + width: 220, + child: ShadSelect( + key: ValueKey(_controller.groupFilter), + initialValue: _controller.groupFilter, + placeholder: Text( + _controller.groups.isEmpty + ? '그룹 로딩중...' + : '그룹 전체', + ), + selectedOptionBuilder: (context, value) { + if (value == null) { + return Text( + _controller.groups.isEmpty + ? '그룹 로딩중...' + : '그룹 전체', + ); + } + final group = _controller.groups.firstWhere( + (g) => g.id == value, + orElse: () => Group(id: value, groupName: ''), + ); + return Text(group.groupName); + }, + onChanged: (value) { + _controller.updateGroupFilter(value); + }, + options: [ + const ShadOption( + value: null, + child: Text('그룹 전체'), + ), + ..._controller.groups.map( + (group) => ShadOption( + value: group.id, + child: Text(group.groupName), + ), + ), + ], + ), + ), + SizedBox( + width: 220, + child: ShadSelect( + key: ValueKey(_controller.menuFilter), + initialValue: _controller.menuFilter, + placeholder: Text( + _controller.menus.isEmpty + ? '메뉴 로딩중...' + : '메뉴 전체', + ), + selectedOptionBuilder: (context, value) { + if (value == null) { + return Text( + _controller.menus.isEmpty + ? '메뉴 로딩중...' + : '메뉴 전체', + ); + } + final menuItem = _controller.menus.firstWhere( + (m) => m.id == value, + orElse: () => MenuItem( + id: value, + menuCode: '', + menuName: '', + ), + ); + return Text(menuItem.menuName); + }, + onChanged: (value) { + _controller.updateMenuFilter(value); + }, + options: [ + const ShadOption( + value: null, + child: Text('메뉴 전체'), + ), + ..._controller.menus.map( + (menuItem) => ShadOption( + value: menuItem.id, + child: Text(menuItem.menuName), + ), + ), + ], + ), + ), + SizedBox( + width: 200, + child: ShadSelect( + key: ValueKey(_controller.statusFilter), + initialValue: _controller.statusFilter, + selectedOptionBuilder: (context, filter) => + Text(_statusLabel(filter)), + onChanged: (value) { + if (value == null) return; + _controller.updateStatusFilter(value); + }, + options: GroupPermissionStatusFilter.values + .map( + (filter) => ShadOption( + value: filter, + child: Text(_statusLabel(filter)), + ), + ) + .toList(), + ), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + ShadSwitch( + value: _controller.includeDeleted, + onChanged: (value) { + _controller.updateIncludeDeleted(value); + }, + ), + const SizedBox(width: 8), + const Text('삭제 포함'), + ], + ), + ShadButton.outline( + onPressed: _controller.isLoading ? null : _applyFilters, + child: const Text('검색 적용'), + ), + if (showReset) + ShadButton.ghost( + onPressed: _controller.isLoading + ? null + : () { + _searchController.clear(); + _searchFocus.requestFocus(); + _controller.updateGroupFilter(null); + _controller.updateMenuFilter(null); + _controller.updateIncludeDeleted(false); + _controller.fetch(page: 1); + }, + child: const Text('초기화'), + ), + ], + ), + child: ShadCard( + title: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('그룹 권한 목록', style: theme.textTheme.h3), + Text('$totalCount건', style: theme.textTheme.muted), + ], + ), + footer: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '페이지 $currentPage / $totalPages', + style: theme.textTheme.small, + ), + Row( + children: [ + ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: _controller.isLoading || currentPage <= 1 + ? null + : () => _controller.fetch(page: currentPage - 1), + child: const Text('이전'), + ), + const SizedBox(width: 8), + ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: _controller.isLoading || !hasNext + ? null + : () => _controller.fetch(page: currentPage + 1), + child: const Text('다음'), + ), + ], + ), + ], + ), + child: _controller.isLoading + ? const Padding( + padding: EdgeInsets.all(48), + child: Center(child: CircularProgressIndicator()), + ) + : permissions.isEmpty + ? Padding( + padding: const EdgeInsets.all(32), + child: Text( + '조건에 맞는 권한이 없습니다.', + style: theme.textTheme.muted, + ), + ) + : _PermissionTable( + permissions: permissions, + dateFormat: _dateFormat, + onEdit: _controller.isSubmitting + ? null + : (permission) => + _openPermissionForm(context, permission: permission), + onDelete: _controller.isSubmitting + ? null + : _confirmDelete, + onRestore: _controller.isSubmitting + ? null + : _restorePermission, + ), + ), + ); + }, + ); + } + + void _applyFilters() { + _controller.fetch(page: 1); + } + + String _statusLabel(GroupPermissionStatusFilter filter) { + switch (filter) { + case GroupPermissionStatusFilter.all: + return '전체(사용/미사용)'; + case GroupPermissionStatusFilter.activeOnly: + return '사용중'; + case GroupPermissionStatusFilter.inactiveOnly: + return '미사용'; + } + } + + Future _openPermissionForm( + BuildContext context, { + GroupPermission? permission, + }) async { + final isEdit = permission != null; + final permissionId = permission?.id; + if (isEdit && permissionId == null) { + _showSnack('ID 정보가 없어 수정할 수 없습니다.'); + return; + } + + final groupNotifier = ValueNotifier(permission?.group.id); + final menuNotifier = ValueNotifier(permission?.menu.id); + final createNotifier = ValueNotifier(permission?.canCreate ?? false); + final readNotifier = ValueNotifier(permission?.canRead ?? true); + final updateNotifier = ValueNotifier(permission?.canUpdate ?? false); + final deleteNotifier = ValueNotifier(permission?.canDelete ?? false); + final activeNotifier = ValueNotifier(permission?.isActive ?? true); + final noteController = TextEditingController(text: permission?.note ?? ''); + final saving = ValueNotifier(false); + final groupError = ValueNotifier(null); + final menuError = ValueNotifier(null); + + await showDialog( + context: context, + builder: (dialogContext) { + final theme = ShadTheme.of(dialogContext); + final materialTheme = Theme.of(dialogContext); + final navigator = Navigator.of(dialogContext); + return Dialog( + insetPadding: const EdgeInsets.all(24), + clipBehavior: Clip.antiAlias, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 600), + child: ShadCard( + title: Text( + isEdit ? '권한 수정' : '권한 등록', + style: theme.textTheme.h3, + ), + description: Text( + '그룹과 메뉴의 권한을 ${isEdit ? '수정' : '등록'}하세요.', + style: theme.textTheme.muted, + ), + footer: ValueListenableBuilder( + valueListenable: saving, + builder: (_, isSaving, __) { + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + ShadButton.ghost( + onPressed: isSaving ? null : () => navigator.pop(false), + child: const Text('취소'), + ), + const SizedBox(width: 12), + ShadButton( + onPressed: isSaving + ? null + : () async { + final groupId = groupNotifier.value; + final menuId = menuNotifier.value; + groupError.value = groupId == null + ? '그룹을 선택하세요.' + : null; + menuError.value = menuId == null + ? '메뉴를 선택하세요.' + : null; + if (groupError.value != null || + menuError.value != null) { + return; + } + + saving.value = true; + final input = GroupPermissionInput( + groupId: groupId!, + menuId: menuId!, + canCreate: createNotifier.value, + canRead: readNotifier.value, + canUpdate: updateNotifier.value, + canDelete: deleteNotifier.value, + isActive: activeNotifier.value, + note: noteController.text.trim().isEmpty + ? null + : noteController.text.trim(), + ); + final response = isEdit + ? await _controller.update( + permissionId!, + input, + ) + : await _controller.create(input); + saving.value = false; + if (response != null) { + if (!navigator.mounted) { + return; + } + if (mounted) { + _showSnack( + isEdit ? '권한을 수정했습니다.' : '권한을 등록했습니다.', + ); + } + navigator.pop(true); + } + }, + child: Text(isEdit ? '저장' : '등록'), + ), + ], + ); + }, + ), + child: SizedBox( + width: double.infinity, + child: SingleChildScrollView( + padding: const EdgeInsets.only(right: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + ValueListenableBuilder( + valueListenable: groupError, + builder: (_, errorText, __) { + return _FormField( + label: '그룹', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShadSelect( + initialValue: groupNotifier.value, + placeholder: const Text('그룹을 선택하세요'), + selectedOptionBuilder: (context, value) { + if (value == null) { + return const Text('그룹을 선택하세요'); + } + final groupId = value; + final group = _controller.groups.firstWhere( + (g) => g.id == groupId, + orElse: () => + Group(id: groupId, groupName: ''), + ); + return Text( + group.groupName.isEmpty + ? '그룹을 선택하세요' + : group.groupName, + ); + }, + onChanged: saving.value || isEdit + ? null + : (value) { + groupNotifier.value = value; + if (value != null) { + groupError.value = null; + } + }, + options: [ + ..._controller.groups.map( + (group) => ShadOption( + value: group.id, + child: Text(group.groupName), + ), + ), + ], + ), + if (errorText != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + errorText, + style: theme.textTheme.small.copyWith( + color: materialTheme.colorScheme.error, + ), + ), + ), + ], + ), + ); + }, + ), + const SizedBox(height: 16), + ValueListenableBuilder( + valueListenable: menuError, + builder: (_, errorText, __) { + return _FormField( + label: '메뉴', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShadSelect( + initialValue: menuNotifier.value, + placeholder: const Text('메뉴를 선택하세요'), + selectedOptionBuilder: (context, value) { + if (value == null) { + return const Text('메뉴를 선택하세요'); + } + final menuId = value; + final menu = _controller.menus.firstWhere( + (m) => m.id == menuId, + orElse: () => MenuItem( + id: menuId, + menuCode: '', + menuName: '', + ), + ); + return Text( + menu.menuName.isEmpty + ? '메뉴를 선택하세요' + : menu.menuName, + ); + }, + onChanged: saving.value || isEdit + ? null + : (value) { + menuNotifier.value = value; + if (value != null) { + menuError.value = null; + } + }, + options: [ + ..._controller.menus.map( + (menu) => ShadOption( + value: menu.id, + child: Text(menu.menuName), + ), + ), + ], + ), + if (errorText != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + errorText, + style: theme.textTheme.small.copyWith( + color: materialTheme.colorScheme.error, + ), + ), + ), + ], + ), + ); + }, + ), + const SizedBox(height: 20), + _PermissionToggleRow( + label: '생성권한', + notifier: createNotifier, + enabled: !saving.value, + ), + const SizedBox(height: 12), + _PermissionToggleRow( + label: '조회권한', + notifier: readNotifier, + enabled: !saving.value, + ), + const SizedBox(height: 12), + _PermissionToggleRow( + label: '수정권한', + notifier: updateNotifier, + enabled: !saving.value, + ), + const SizedBox(height: 12), + _PermissionToggleRow( + label: '삭제권한', + notifier: deleteNotifier, + enabled: !saving.value, + ), + const SizedBox(height: 16), + ValueListenableBuilder( + valueListenable: activeNotifier, + builder: (_, value, __) { + return _FormField( + label: '사용여부', + child: Row( + children: [ + ShadSwitch( + value: value, + onChanged: saving.value + ? null + : (next) => activeNotifier.value = next, + ), + const SizedBox(width: 8), + Text(value ? '사용' : '미사용'), + ], + ), + ); + }, + ), + const SizedBox(height: 16), + _FormField( + label: '비고', + child: ShadTextarea(controller: noteController), + ), + if (isEdit) ...[ + const SizedBox(height: 20), + Text( + '생성일시: ${_formatDateTime(permission.createdAt)}', + style: theme.textTheme.small, + ), + const SizedBox(height: 4), + Text( + '수정일시: ${_formatDateTime(permission.updatedAt)}', + style: theme.textTheme.small, + ), + ], + ], + ), + ), + ), + ), + ), + ); + }, + ); + + groupNotifier.dispose(); + menuNotifier.dispose(); + createNotifier.dispose(); + readNotifier.dispose(); + updateNotifier.dispose(); + deleteNotifier.dispose(); + activeNotifier.dispose(); + noteController.dispose(); + saving.dispose(); + groupError.dispose(); + menuError.dispose(); + } + + Future _confirmDelete(GroupPermission permission) async { + final confirmed = await showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: const Text('권한 삭제'), + content: Text( + '"${permission.group.groupName}" → "${permission.menu.menuName}" 권한을 삭제하시겠습니까?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: const Text('취소'), + ), + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: const Text('삭제'), + ), + ], + ); + }, + ); + + if (confirmed == true && permission.id != null) { + final success = await _controller.delete(permission.id!); + if (success && mounted) { + _showSnack('권한을 삭제했습니다.'); + } + } + } + + Future _restorePermission(GroupPermission permission) async { + if (permission.id == null) return; + final restored = await _controller.restore(permission.id!); + if (restored != null && mounted) { + _showSnack('권한을 복구했습니다.'); + } + } + + void _showSnack(String message) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } + + String _formatDateTime(DateTime? value) { + if (value == null) { + return '-'; + } + return _dateFormat.format(value.toLocal()); + } +} + +class _PermissionTable extends StatelessWidget { + const _PermissionTable({ + required this.permissions, + required this.dateFormat, + required this.onEdit, + required this.onDelete, + required this.onRestore, + }); + + final List permissions; + final intl.DateFormat dateFormat; + final void Function(GroupPermission permission)? onEdit; + final void Function(GroupPermission permission)? onDelete; + final void Function(GroupPermission permission)? onRestore; + + @override + Widget build(BuildContext context) { + final header = [ + 'ID', + '그룹명', + '메뉴명', + '생성', + '조회', + '수정', + '삭제', + '사용', + '삭제', + '비고', + '변경일시', + '동작', + ].map((text) => ShadTableCell.header(child: Text(text))).toList(); + + final rows = permissions.map((permission) { + final cells = [ + permission.id?.toString() ?? '-', + permission.group.groupName, + permission.menu.menuName, + permission.canCreate ? 'Y' : '-', + permission.canRead ? 'Y' : '-', + permission.canUpdate ? 'Y' : '-', + permission.canDelete ? 'Y' : '-', + permission.isActive ? 'Y' : 'N', + permission.isDeleted ? 'Y' : '-', + permission.note?.isEmpty ?? true ? '-' : permission.note!, + permission.updatedAt == null + ? '-' + : dateFormat.format(permission.updatedAt!.toLocal()), + ].map((text) => ShadTableCell(child: Text(text))).toList(); + + cells.add( + ShadTableCell( + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + ShadButton.ghost( + size: ShadButtonSize.sm, + onPressed: onEdit == null ? null : () => onEdit!(permission), + child: const Icon(LucideIcons.pencil, size: 16), + ), + const SizedBox(width: 8), + permission.isDeleted + ? ShadButton.ghost( + size: ShadButtonSize.sm, + onPressed: onRestore == null + ? null + : () => onRestore!(permission), + child: const Icon(LucideIcons.history, size: 16), + ) + : ShadButton.ghost( + size: ShadButtonSize.sm, + onPressed: onDelete == null + ? null + : () => onDelete!(permission), + child: const Icon(LucideIcons.trash2, size: 16), + ), ], ), ), + ); + + return cells; + }).toList(); + + return SizedBox( + height: 56.0 * (permissions.length + 1), + child: ShadTable.list( + header: header, + children: rows, + columnSpanExtent: (index) { + switch (index) { + case 1: + case 2: + return const FixedTableSpanExtent(180); + case 9: + return const FixedTableSpanExtent(220); + case 11: + return const FixedTableSpanExtent(160); + default: + return const FixedTableSpanExtent(110); + } + }, + ), + ); + } +} + +class _FormField extends StatelessWidget { + const _FormField({required this.label, required this.child}); + + final String label; + final Widget child; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: theme.textTheme.small), + const SizedBox(height: 6), + child, ], ); } } + +class _PermissionToggleRow extends StatelessWidget { + const _PermissionToggleRow({ + required this.label, + required this.notifier, + required this.enabled, + }); + + final String label; + final ValueNotifier notifier; + final bool enabled; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return ValueListenableBuilder( + valueListenable: notifier, + builder: (_, value, __) { + return _FormField( + label: label, + child: Row( + children: [ + ShadSwitch( + value: value, + onChanged: !enabled ? null : (next) => notifier.value = next, + ), + const SizedBox(width: 8), + Text(value ? '허용' : '차단', style: theme.textTheme.small), + ], + ), + ); + }, + ); + } +} diff --git a/lib/features/masters/menu/data/dtos/menu_dto.dart b/lib/features/masters/menu/data/dtos/menu_dto.dart new file mode 100644 index 0000000..2a2ee64 --- /dev/null +++ b/lib/features/masters/menu/data/dtos/menu_dto.dart @@ -0,0 +1,102 @@ +import 'package:superport_v2/core/common/models/paginated_result.dart'; + +import '../../domain/entities/menu.dart'; + +class MenuDto { + MenuDto({ + this.id, + required this.menuCode, + required this.menuName, + this.parent, + this.path, + this.displayOrder, + this.isActive = true, + this.isDeleted = false, + this.note, + this.createdAt, + this.updatedAt, + }); + + final int? id; + final String menuCode; + final String menuName; + final MenuSummaryDto? parent; + final String? path; + final int? displayOrder; + final bool isActive; + final bool isDeleted; + final String? note; + final DateTime? createdAt; + final DateTime? updatedAt; + + factory MenuDto.fromJson(Map json) { + return MenuDto( + id: json['id'] as int?, + menuCode: json['menu_code'] as String, + menuName: json['menu_name'] as String, + parent: json['parent_menu'] is Map + ? MenuSummaryDto.fromJson(json['parent_menu'] as Map) + : json['parent'] is Map + ? MenuSummaryDto.fromJson(json['parent'] as Map) + : null, + path: json['path'] as String?, + displayOrder: json['display_order'] as int?, + isActive: (json['is_active'] as bool?) ?? true, + isDeleted: (json['is_deleted'] as bool?) ?? false, + note: json['note'] as String?, + createdAt: _parseDate(json['created_at']), + updatedAt: _parseDate(json['updated_at']), + ); + } + + MenuItem toEntity() => MenuItem( + id: id, + menuCode: menuCode, + menuName: menuName, + parent: parent?.toEntity(), + path: path, + displayOrder: displayOrder, + isActive: isActive, + isDeleted: isDeleted, + note: note, + createdAt: createdAt, + updatedAt: updatedAt, + ); + + static PaginatedResult parsePaginated(Map? json) { + final items = (json?['items'] as List? ?? []) + .whereType>() + .map(MenuDto.fromJson) + .map((dto) => dto.toEntity()) + .toList(); + return PaginatedResult( + items: items, + page: json?['page'] as int? ?? 1, + pageSize: json?['page_size'] as int? ?? items.length, + total: json?['total'] as int? ?? items.length, + ); + } +} + +class MenuSummaryDto { + MenuSummaryDto({required this.id, required this.menuName}); + + final int id; + final String menuName; + + factory MenuSummaryDto.fromJson(Map json) { + return MenuSummaryDto( + id: json['id'] as int, + menuName: json['menu_name'] as String, + ); + } + + MenuSummary toEntity() => MenuSummary(id: id, menuName: menuName); +} + +DateTime? _parseDate(Object? value) { + if (value == null) return null; + if (value is DateTime) return value; + if (value is String) return DateTime.tryParse(value); + return null; +} diff --git a/lib/features/masters/menu/data/repositories/menu_repository_remote.dart b/lib/features/masters/menu/data/repositories/menu_repository_remote.dart new file mode 100644 index 0000000..5f1c372 --- /dev/null +++ b/lib/features/masters/menu/data/repositories/menu_repository_remote.dart @@ -0,0 +1,77 @@ +import 'package:dio/dio.dart'; +import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/core/network/api_client.dart'; + +import '../../domain/entities/menu.dart'; +import '../../domain/repositories/menu_repository.dart'; +import '../dtos/menu_dto.dart'; + +class MenuRepositoryRemote implements MenuRepository { + MenuRepositoryRemote({required ApiClient apiClient}) : _api = apiClient; + + final ApiClient _api; + + static const _basePath = '/menus'; + + @override + Future> list({ + int page = 1, + int pageSize = 20, + String? query, + int? parentId, + bool? isActive, + bool includeDeleted = false, + }) async { + final response = await _api.get>( + _basePath, + query: { + 'page': page, + 'page_size': pageSize, + if (query != null && query.isNotEmpty) 'q': query, + if (parentId != null) 'parent_id': parentId, + if (isActive != null) 'is_active': isActive, + if (includeDeleted) 'include_deleted': true, + 'include': 'parent', + }, + options: Options(responseType: ResponseType.json), + ); + return MenuDto.parsePaginated(response.data ?? const {}); + } + + @override + Future create(MenuInput input) async { + final response = await _api.post>( + _basePath, + data: input.toPayload(), + options: Options(responseType: ResponseType.json), + ); + final data = (response.data?['data'] as Map?) ?? {}; + return MenuDto.fromJson(data).toEntity(); + } + + @override + Future update(int id, MenuInput input) async { + final response = await _api.patch>( + '$_basePath/$id', + data: input.toPayload(), + options: Options(responseType: ResponseType.json), + ); + final data = (response.data?['data'] as Map?) ?? {}; + return MenuDto.fromJson(data).toEntity(); + } + + @override + Future delete(int id) async { + await _api.delete('$_basePath/$id'); + } + + @override + Future restore(int id) async { + final response = await _api.post>( + '$_basePath/$id/restore', + options: Options(responseType: ResponseType.json), + ); + final data = (response.data?['data'] as Map?) ?? {}; + return MenuDto.fromJson(data).toEntity(); + } +} diff --git a/lib/features/masters/menu/domain/entities/menu.dart b/lib/features/masters/menu/domain/entities/menu.dart new file mode 100644 index 0000000..ee0d8ea --- /dev/null +++ b/lib/features/masters/menu/domain/entities/menu.dart @@ -0,0 +1,119 @@ +/// 메뉴 엔티티 +/// +/// - 계층 구조를 표현하기 위해 상위 메뉴 정보를 포함한다. +/// - presentation/data 레이어 세부 구현에 의존하지 않는다. +class MenuItem { + MenuItem({ + this.id, + required this.menuCode, + required this.menuName, + this.parent, + this.path, + this.displayOrder, + this.isActive = true, + this.isDeleted = false, + this.note, + this.createdAt, + this.updatedAt, + }); + + /// PK (null 이면 신규 생성) + final int? id; + + /// 메뉴 코드 (고유) + final String menuCode; + + /// 메뉴명 + final String menuName; + + /// 상위 메뉴 정보 + final MenuSummary? parent; + + /// 라우트 경로 + final String? path; + + /// 표시 순서 + final int? displayOrder; + + /// 사용 여부 + final bool isActive; + + /// 소프트 삭제 여부 + final bool isDeleted; + + /// 비고 + final String? note; + + /// 생성/수정 일시 + final DateTime? createdAt; + final DateTime? updatedAt; + + MenuItem copyWith({ + int? id, + String? menuCode, + String? menuName, + MenuSummary? parent, + String? path, + int? displayOrder, + bool? isActive, + bool? isDeleted, + String? note, + DateTime? createdAt, + DateTime? updatedAt, + }) { + return MenuItem( + id: id ?? this.id, + menuCode: menuCode ?? this.menuCode, + menuName: menuName ?? this.menuName, + parent: parent ?? this.parent, + path: path ?? this.path, + displayOrder: displayOrder ?? this.displayOrder, + isActive: isActive ?? this.isActive, + isDeleted: isDeleted ?? this.isDeleted, + note: note ?? this.note, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } +} + +/// 상위 메뉴 요약 정보 +class MenuSummary { + MenuSummary({required this.id, required this.menuName}); + + final int id; + final String menuName; +} + +/// 메뉴 생성/수정 입력 모델 +class MenuInput { + MenuInput({ + required this.menuCode, + required this.menuName, + this.parentMenuId, + this.path, + this.displayOrder, + this.isActive = true, + this.note, + }); + + final String menuCode; + final String menuName; + final int? parentMenuId; + final String? path; + final int? displayOrder; + final bool isActive; + final String? note; + + Map toPayload() { + return { + 'menu_code': menuCode, + 'menu_name': menuName, + 'parent_menu_id': parentMenuId, + 'path': path, + 'display_order': displayOrder, + 'is_active': isActive, + 'note': note, + }; + } +} diff --git a/lib/features/masters/menu/domain/repositories/menu_repository.dart b/lib/features/masters/menu/domain/repositories/menu_repository.dart new file mode 100644 index 0000000..5048285 --- /dev/null +++ b/lib/features/masters/menu/domain/repositories/menu_repository.dart @@ -0,0 +1,27 @@ +import 'package:superport_v2/core/common/models/paginated_result.dart'; + +import '../entities/menu.dart'; + +abstract class MenuRepository { + /// 메뉴 목록 조회 + Future> list({ + int page = 1, + int pageSize = 20, + String? query, + int? parentId, + bool? isActive, + bool includeDeleted = false, + }); + + /// 메뉴 신규 등록 + Future create(MenuInput input); + + /// 메뉴 수정 + Future update(int id, MenuInput input); + + /// 메뉴 삭제(소프트) + Future delete(int id); + + /// 메뉴 복구 + Future restore(int id); +} diff --git a/lib/features/masters/menu/presentation/controllers/menu_controller.dart b/lib/features/masters/menu/presentation/controllers/menu_controller.dart new file mode 100644 index 0000000..611e28c --- /dev/null +++ b/lib/features/masters/menu/presentation/controllers/menu_controller.dart @@ -0,0 +1,179 @@ +import 'package:flutter/foundation.dart'; +import 'package:superport_v2/core/common/models/paginated_result.dart'; + +import '../../domain/entities/menu.dart'; +import '../../domain/repositories/menu_repository.dart'; + +enum MenuStatusFilter { all, activeOnly, inactiveOnly } + +/// 메뉴 마스터 상태 컨트롤러 +/// +/// - 목록, 필터, 페이지 상태를 관리한다. +/// - CRUD 및 복구 요청을 처리한다. +class MenuController extends ChangeNotifier { + MenuController({required MenuRepository repository}) + : _repository = repository; + + final MenuRepository _repository; + + PaginatedResult? _result; + bool _isLoading = false; + bool _isSubmitting = false; + bool _isLoadingParents = false; + String _query = ''; + int? _parentFilter; + MenuStatusFilter _statusFilter = MenuStatusFilter.all; + bool _includeDeleted = false; + String? _errorMessage; + List _parents = const []; + + PaginatedResult? get result => _result; + bool get isLoading => _isLoading; + bool get isSubmitting => _isSubmitting; + bool get isLoadingParents => _isLoadingParents; + String get query => _query; + int? get parentFilter => _parentFilter; + MenuStatusFilter get statusFilter => _statusFilter; + bool get includeDeleted => _includeDeleted; + String? get errorMessage => _errorMessage; + List get parents => _parents; + + Future loadParents() async { + _isLoadingParents = true; + notifyListeners(); + try { + final response = await _repository.list( + page: 1, + pageSize: 200, + includeDeleted: false, + ); + _parents = response.items; + } catch (e) { + _errorMessage = e.toString(); + } finally { + _isLoadingParents = false; + notifyListeners(); + } + } + + Future fetch({int page = 1}) async { + _isLoading = true; + _errorMessage = null; + notifyListeners(); + try { + final isActive = switch (_statusFilter) { + MenuStatusFilter.all => null, + MenuStatusFilter.activeOnly => true, + MenuStatusFilter.inactiveOnly => false, + }; + final response = await _repository.list( + page: page, + pageSize: _result?.pageSize ?? 20, + query: _query.isEmpty ? null : _query, + parentId: _parentFilter, + isActive: isActive, + includeDeleted: _includeDeleted, + ); + _result = response; + } catch (e) { + _errorMessage = e.toString(); + } finally { + _isLoading = false; + notifyListeners(); + } + } + + void updateQuery(String value) { + _query = value; + notifyListeners(); + } + + void updateParentFilter(int? parentId) { + _parentFilter = parentId; + notifyListeners(); + } + + void updateStatusFilter(MenuStatusFilter filter) { + _statusFilter = filter; + notifyListeners(); + } + + void updateIncludeDeleted(bool value) { + _includeDeleted = value; + notifyListeners(); + } + + Future create(MenuInput input) async { + _setSubmitting(true); + try { + final created = await _repository.create(input); + await fetch(page: 1); + await loadParents(); + return created; + } catch (e) { + _errorMessage = e.toString(); + notifyListeners(); + return null; + } finally { + _setSubmitting(false); + } + } + + Future update(int id, MenuInput input) async { + _setSubmitting(true); + try { + final updated = await _repository.update(id, input); + await fetch(page: _result?.page ?? 1); + await loadParents(); + return updated; + } catch (e) { + _errorMessage = e.toString(); + notifyListeners(); + return null; + } finally { + _setSubmitting(false); + } + } + + Future delete(int id) async { + _setSubmitting(true); + try { + await _repository.delete(id); + await fetch(page: _result?.page ?? 1); + await loadParents(); + return true; + } catch (e) { + _errorMessage = e.toString(); + notifyListeners(); + return false; + } finally { + _setSubmitting(false); + } + } + + Future restore(int id) async { + _setSubmitting(true); + try { + final restored = await _repository.restore(id); + await fetch(page: _result?.page ?? 1); + await loadParents(); + return restored; + } catch (e) { + _errorMessage = e.toString(); + notifyListeners(); + return null; + } finally { + _setSubmitting(false); + } + } + + void clearError() { + _errorMessage = null; + notifyListeners(); + } + + void _setSubmitting(bool value) { + _isSubmitting = value; + notifyListeners(); + } +} diff --git a/lib/features/masters/menu/presentation/pages/menu_page.dart b/lib/features/masters/menu/presentation/pages/menu_page.dart index 2be2d54..a201494 100644 --- a/lib/features/masters/menu/presentation/pages/menu_page.dart +++ b/lib/features/masters/menu/presentation/pages/menu_page.dart @@ -1,51 +1,893 @@ -import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:superport_v2/core/constants/app_sections.dart'; +import 'package:superport_v2/widgets/app_layout.dart'; +import 'package:superport_v2/widgets/components/filter_bar.dart'; + +import '../../../../../core/config/environment.dart'; import '../../../../../widgets/spec_page.dart'; +import '../../domain/entities/menu.dart'; +import '../../domain/repositories/menu_repository.dart'; +import '../controllers/menu_controller.dart' as menu; class MenuPage extends StatelessWidget { const MenuPage({super.key}); @override Widget build(BuildContext context) { - return const SpecPage( - title: '메뉴 관리', - summary: '메뉴 계층, 경로, 노출 순서를 구성합니다.', - sections: [ - SpecSection( - title: '입력 폼', - items: [ - '메뉴코드 [Text]', - '메뉴명 [Text]', - '상위메뉴 [Dropdown]', - '경로 [Text]', - '표시순서 [Number]', - '사용여부 [Switch]', - '비고 [Text]', - ], - ), - SpecSection( - title: '수정 폼', - items: ['메뉴코드 [ReadOnly]', '생성일시 [ReadOnly]'], - ), - SpecSection( - title: '테이블 리스트', - description: '1행 예시', - table: SpecTable( - columns: ['번호', '메뉴코드', '메뉴명', '상위메뉴', '경로', '사용여부', '비고', '변경일시'], - rows: [ - [ - '1', - 'MN-001', - '대시보드', - '-', - '/dashboard', - 'Y', - '-', - '2024-03-01 10:00', + final enabled = Environment.flag('FEATURE_MENUS_ENABLED'); + if (!enabled) { + return SpecPage( + title: '메뉴 관리', + summary: '메뉴 코드, 트리 구조, 사용여부를 관리합니다.', + trailing: ShadBadge( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(LucideIcons.info, size: 14), + const SizedBox(width: 6), + const Text('비활성화 (백엔드 준비 중)'), ], + ), + ), + ), + sections: const [ + SpecSection( + title: '입력 폼', + items: [ + '메뉴코드 [Text]', + '메뉴명 [Text]', + '상위메뉴 [Dropdown]', + '경로 [Text]', + '표시순서 [Number]', + '사용여부 [Switch]', + '비고 [Text]', + ], + ), + SpecSection( + title: '수정 폼', + items: ['메뉴코드 [ReadOnly]', '생성일시 [ReadOnly]'], + ), + SpecSection( + title: '테이블 리스트', + description: '1행 예시', + table: SpecTable( + columns: [ + '번호', + '메뉴코드', + '메뉴명', + '상위메뉴', + '경로', + '사용여부', + '비고', + '변경일시', + ], + rows: [ + [ + '1', + 'MENU001', + '대시보드', + '-', + '/dashboard', + 'Y', + '-', + '2024-03-01 10:00', + ], + ], + ), + ), + ], + ); + } + + return const _MenuEnabledPage(); + } +} + +class _MenuEnabledPage extends StatefulWidget { + const _MenuEnabledPage(); + + @override + State<_MenuEnabledPage> createState() => _MenuEnabledPageState(); +} + +class _MenuEnabledPageState extends State<_MenuEnabledPage> { + late final menu.MenuController _controller; + final TextEditingController _searchController = TextEditingController(); + final FocusNode _searchFocus = FocusNode(); + final DateFormat _dateFormat = DateFormat('yyyy-MM-dd HH:mm'); + String? _lastError; + + @override + void initState() { + super.initState(); + _controller = menu.MenuController(repository: GetIt.I()) + ..addListener(_handleControllerUpdate); + WidgetsBinding.instance.addPostFrameCallback((_) async { + await _controller.loadParents(); + await _controller.fetch(); + }); + } + + void _handleControllerUpdate() { + final error = _controller.errorMessage; + if (error != null && error != _lastError && mounted) { + _lastError = error; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(error))); + _controller.clearError(); + } + } + + @override + void dispose() { + _controller.removeListener(_handleControllerUpdate); + _controller.dispose(); + _searchController.dispose(); + _searchFocus.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + + return AnimatedBuilder( + animation: _controller, + builder: (context, _) { + final result = _controller.result; + final menus = result?.items ?? const []; + final totalCount = result?.total ?? 0; + final currentPage = result?.page ?? 1; + final totalPages = result == null || result.pageSize == 0 + ? 1 + : (result.total / result.pageSize).ceil().clamp(1, 9999); + final hasNext = result == null + ? false + : (result.page * result.pageSize) < result.total; + + final showReset = _searchController.text.isNotEmpty || + _controller.parentFilter != null || + _controller.statusFilter != menu.MenuStatusFilter.all || + _controller.includeDeleted; + + return AppLayout( + title: '메뉴 관리', + subtitle: '메뉴 트리와 경로, 사용 상태를 관리합니다.', + breadcrumbs: const [ + AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath), + AppBreadcrumbItem(label: '마스터', path: '/masters/menus'), + AppBreadcrumbItem(label: '메뉴'), + ], + actions: [ + ShadButton( + leading: const Icon(LucideIcons.plus, size: 16), + onPressed: + _controller.isSubmitting ? null : () => _openMenuForm(context), + child: const Text('신규 등록'), + ), + ], + toolbar: FilterBar( + children: [ + SizedBox( + width: 260, + child: ShadInput( + controller: _searchController, + focusNode: _searchFocus, + placeholder: const Text('메뉴코드, 메뉴명 검색'), + leading: const Icon(LucideIcons.search, size: 16), + onSubmitted: (_) => _applyFilters(), + ), + ), + SizedBox( + width: 220, + child: ShadSelect( + key: ValueKey(_controller.parentFilter), + initialValue: _controller.parentFilter, + placeholder: Text( + _controller.isLoadingParents ? '상위 로딩중...' : '상위 전체', + ), + selectedOptionBuilder: (context, value) { + if (value == null) { + return Text( + _controller.isLoadingParents + ? '상위 로딩중...' + : '상위 전체', + ); + } + final target = _controller.parents.firstWhere( + (menuItem) => menuItem.id == value, + orElse: () => MenuItem( + id: value, + menuCode: '', + menuName: '', + ), + ); + final label = target.menuName.isEmpty + ? '상위 전체' + : target.menuName; + return Text(label); + }, + onChanged: _controller.isLoadingParents + ? null + : (value) { + _controller.updateParentFilter(value); + _controller.fetch(page: 1); + }, + options: [ + const ShadOption( + value: null, + child: Text('상위 전체'), + ), + ..._controller.parents.map( + (menuItem) => ShadOption( + value: menuItem.id, + child: Text(menuItem.menuName), + ), + ), + ], + ), + ), + SizedBox( + width: 200, + child: ShadSelect( + key: ValueKey(_controller.statusFilter), + initialValue: _controller.statusFilter, + selectedOptionBuilder: (context, filter) => + Text(_statusLabel(filter)), + onChanged: (value) { + if (value == null) return; + _controller.updateStatusFilter(value); + _controller.fetch(page: 1); + }, + options: menu.MenuStatusFilter.values + .map( + (filter) => ShadOption( + value: filter, + child: Text(_statusLabel(filter)), + ), + ) + .toList(), + ), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + ShadSwitch( + value: _controller.includeDeleted, + onChanged: (value) { + _controller.updateIncludeDeleted(value); + _controller.fetch(page: 1); + }, + ), + const SizedBox(width: 8), + const Text('삭제 포함'), + ], + ), + ShadButton.outline( + onPressed: _controller.isLoading ? null : _applyFilters, + child: const Text('검색 적용'), + ), + if (showReset) + ShadButton.ghost( + onPressed: _controller.isLoading + ? null + : () { + _searchController.clear(); + _searchFocus.requestFocus(); + _controller.updateQuery(''); + _controller.updateParentFilter(null); + _controller.updateStatusFilter( + menu.MenuStatusFilter.all, + ); + _controller.updateIncludeDeleted(false); + _controller.fetch(page: 1); + }, + child: const Text('초기화'), + ), + ], + ), + child: ShadCard( + title: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('메뉴 목록', style: theme.textTheme.h3), + Text('$totalCount건', style: theme.textTheme.muted), + ], + ), + footer: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '페이지 $currentPage / $totalPages', + style: theme.textTheme.small, + ), + Row( + children: [ + ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: _controller.isLoading || currentPage <= 1 + ? null + : () => _controller.fetch(page: currentPage - 1), + child: const Text('이전'), + ), + const SizedBox(width: 8), + ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: _controller.isLoading || !hasNext + ? null + : () => _controller.fetch(page: currentPage + 1), + child: const Text('다음'), + ), + ], + ), + ], + ), + child: _controller.isLoading + ? const Padding( + padding: EdgeInsets.all(48), + child: Center(child: CircularProgressIndicator()), + ) + : menus.isEmpty + ? Padding( + padding: const EdgeInsets.all(32), + child: Text( + '조건에 맞는 메뉴가 없습니다.', + style: theme.textTheme.muted, + ), + ) + : _MenuTable( + menus: menus, + dateFormat: _dateFormat, + onEdit: _controller.isSubmitting + ? null + : (menuItem) => + _openMenuForm(context, menu: menuItem), + onDelete: _controller.isSubmitting + ? null + : _confirmDelete, + onRestore: _controller.isSubmitting + ? null + : _restoreMenu, + ), + ), + ); + }, + ); + } + + void _applyFilters() { + _controller.updateQuery(_searchController.text.trim()); + _controller.fetch(page: 1); + } + + String _statusLabel(menu.MenuStatusFilter filter) { + switch (filter) { + case menu.MenuStatusFilter.all: + return '전체(사용/미사용)'; + case menu.MenuStatusFilter.activeOnly: + return '사용중'; + case menu.MenuStatusFilter.inactiveOnly: + return '미사용'; + } + } + + Future _openMenuForm(BuildContext context, {MenuItem? menu}) async { + final existingMenu = menu; + final isEdit = existingMenu != null; + final menuId = existingMenu?.id; + if (isEdit && menuId == null) { + _showSnack('ID 정보가 없어 수정할 수 없습니다.'); + return; + } + + final codeController = TextEditingController( + text: existingMenu?.menuCode ?? '', + ); + final nameController = TextEditingController( + text: existingMenu?.menuName ?? '', + ); + final pathController = TextEditingController( + text: existingMenu?.path ?? '', + ); + final orderController = TextEditingController( + text: existingMenu?.displayOrder?.toString() ?? '', + ); + final noteController = TextEditingController( + text: existingMenu?.note ?? '', + ); + final parentNotifier = ValueNotifier(existingMenu?.parent?.id); + final isActiveNotifier = ValueNotifier( + existingMenu?.isActive ?? true, + ); + final saving = ValueNotifier(false); + final codeError = ValueNotifier(null); + final nameError = ValueNotifier(null); + final orderError = ValueNotifier(null); + + await showDialog( + context: context, + builder: (dialogContext) { + final theme = ShadTheme.of(dialogContext); + final materialTheme = Theme.of(dialogContext); + final navigator = Navigator.of(dialogContext); + return Dialog( + insetPadding: const EdgeInsets.all(24), + clipBehavior: Clip.antiAlias, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560), + child: ShadCard( + title: Text( + isEdit ? '메뉴 수정' : '메뉴 등록', + style: theme.textTheme.h3, + ), + description: Text( + '메뉴 정보를 ${isEdit ? '수정' : '입력'}하세요.', + style: theme.textTheme.muted, + ), + footer: ValueListenableBuilder( + valueListenable: saving, + builder: (_, isSaving, __) { + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + ShadButton.ghost( + onPressed: isSaving ? null : () => navigator.pop(false), + child: const Text('취소'), + ), + const SizedBox(width: 12), + ShadButton( + onPressed: isSaving + ? null + : () async { + final code = codeController.text.trim(); + final name = nameController.text.trim(); + final path = pathController.text.trim(); + final orderText = orderController.text.trim(); + final note = noteController.text.trim(); + + codeError.value = code.isEmpty + ? '메뉴코드를 입력하세요.' + : null; + nameError.value = name.isEmpty + ? '메뉴명을 입력하세요.' + : null; + + int? orderValue; + if (orderText.isNotEmpty) { + orderValue = int.tryParse(orderText); + if (orderValue == null) { + orderError.value = '표시순서는 숫자여야 합니다.'; + } else { + orderError.value = null; + } + } else { + orderError.value = null; + } + + if (codeError.value != null || + nameError.value != null || + orderError.value != null) { + return; + } + + saving.value = true; + final input = MenuInput( + menuCode: code, + menuName: name, + parentMenuId: parentNotifier.value, + path: path.isEmpty ? null : path, + displayOrder: orderValue, + isActive: isActiveNotifier.value, + note: note.isEmpty ? null : note, + ); + final response = isEdit + ? await _controller.update(menuId!, input) + : await _controller.create(input); + saving.value = false; + if (response != null) { + if (!navigator.mounted) { + return; + } + if (mounted) { + _showSnack( + isEdit ? '메뉴를 수정했습니다.' : '메뉴를 등록했습니다.', + ); + } + navigator.pop(true); + } + }, + child: Text(isEdit ? '저장' : '등록'), + ), + ], + ); + }, + ), + child: SizedBox( + width: double.infinity, + child: SingleChildScrollView( + padding: const EdgeInsets.only(right: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + ValueListenableBuilder( + valueListenable: codeError, + builder: (_, errorText, __) { + return _FormField( + label: '메뉴코드', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShadInput( + controller: codeController, + readOnly: isEdit, + onChanged: (_) { + if (codeController.text.trim().isNotEmpty) { + codeError.value = null; + } + }, + ), + if (errorText != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + errorText, + style: theme.textTheme.small.copyWith( + color: materialTheme.colorScheme.error, + ), + ), + ), + ], + ), + ); + }, + ), + const SizedBox(height: 16), + ValueListenableBuilder( + valueListenable: nameError, + builder: (_, errorText, __) { + return _FormField( + label: '메뉴명', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShadInput( + controller: nameController, + onChanged: (_) { + if (nameController.text.trim().isNotEmpty) { + nameError.value = null; + } + }, + ), + if (errorText != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + errorText, + style: theme.textTheme.small.copyWith( + color: materialTheme.colorScheme.error, + ), + ), + ), + ], + ), + ); + }, + ), + const SizedBox(height: 16), + ValueListenableBuilder( + valueListenable: parentNotifier, + builder: (_, value, __) { + return _FormField( + label: '상위메뉴', + child: ShadSelect( + initialValue: value, + placeholder: const Text('최상위'), + selectedOptionBuilder: (context, selected) { + if (selected == null) { + return const Text('최상위'); + } + final target = _controller.parents.firstWhere( + (item) => item.id == selected, + orElse: () => MenuItem( + id: selected, + menuCode: '', + menuName: '', + ), + ); + final label = target.menuName.isEmpty + ? '최상위' + : target.menuName; + return Text(label); + }, + onChanged: saving.value + ? null + : (next) => parentNotifier.value = next, + options: [ + const ShadOption( + value: null, + child: Text('최상위'), + ), + ..._controller.parents + .where((item) => item.id != menuId) + .map( + (menuItem) => ShadOption( + value: menuItem.id, + child: Text(menuItem.menuName), + ), + ), + ], + ), + ); + }, + ), + const SizedBox(height: 16), + _FormField( + label: '경로', + child: ShadInput(controller: pathController), + ), + const SizedBox(height: 16), + ValueListenableBuilder( + valueListenable: orderError, + builder: (_, errorText, __) { + return _FormField( + label: '표시순서', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShadInput( + controller: orderController, + keyboardType: TextInputType.number, + ), + if (errorText != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + errorText, + style: theme.textTheme.small.copyWith( + color: materialTheme.colorScheme.error, + ), + ), + ), + ], + ), + ); + }, + ), + const SizedBox(height: 16), + ValueListenableBuilder( + valueListenable: isActiveNotifier, + builder: (_, value, __) { + return _FormField( + label: '사용여부', + child: Row( + children: [ + ShadSwitch( + value: value, + onChanged: saving.value + ? null + : (next) => isActiveNotifier.value = next, + ), + const SizedBox(width: 8), + Text(value ? '사용' : '미사용'), + ], + ), + ); + }, + ), + const SizedBox(height: 16), + _FormField( + label: '비고', + child: ShadTextarea(controller: noteController), + ), + if (isEdit) ...[ + const SizedBox(height: 20), + Text( + '생성일시: ${_formatDateTime(existingMenu.createdAt)}', + style: theme.textTheme.small, + ), + const SizedBox(height: 4), + Text( + '수정일시: ${_formatDateTime(existingMenu.updatedAt)}', + style: theme.textTheme.small, + ), + ], + ], + ), + ), + ), + ), + ), + ); + }, + ); + + codeController.dispose(); + nameController.dispose(); + pathController.dispose(); + orderController.dispose(); + noteController.dispose(); + parentNotifier.dispose(); + isActiveNotifier.dispose(); + saving.dispose(); + codeError.dispose(); + nameError.dispose(); + orderError.dispose(); + } + + Future _confirmDelete(MenuItem menu) async { + final confirmed = await showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: const Text('메뉴 삭제'), + content: Text('"${menu.menuName}" 메뉴를 삭제하시겠습니까?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: const Text('취소'), + ), + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: const Text('삭제'), + ), + ], + ); + }, + ); + + if (confirmed == true && menu.id != null) { + final success = await _controller.delete(menu.id!); + if (success && mounted) { + _showSnack('메뉴를 삭제했습니다.'); + } + } + } + + Future _restoreMenu(MenuItem menu) async { + if (menu.id == null) return; + final restored = await _controller.restore(menu.id!); + if (restored != null && mounted) { + _showSnack('메뉴를 복구했습니다.'); + } + } + + void _showSnack(String message) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } + + String _formatDateTime(DateTime? value) { + if (value == null) { + return '-'; + } + return _dateFormat.format(value.toLocal()); + } +} + +class _MenuTable extends StatelessWidget { + const _MenuTable({ + required this.menus, + required this.onEdit, + required this.onDelete, + required this.onRestore, + required this.dateFormat, + }); + + final List menus; + final void Function(MenuItem menu)? onEdit; + final void Function(MenuItem menu)? onDelete; + final void Function(MenuItem menu)? onRestore; + final DateFormat dateFormat; + + @override + Widget build(BuildContext context) { + final header = [ + 'ID', + '메뉴코드', + '메뉴명', + '상위메뉴', + '경로', + '사용', + '삭제', + '비고', + '변경일시', + '동작', + ].map((text) => ShadTableCell.header(child: Text(text))).toList(); + + final rows = menus.map((item) { + final cells = [ + item.id?.toString() ?? '-', + item.menuCode, + item.menuName, + item.parent?.menuName ?? '-', + item.path?.isEmpty ?? true ? '-' : item.path!, + item.isActive ? 'Y' : 'N', + item.isDeleted ? 'Y' : '-', + item.note?.isEmpty ?? true ? '-' : item.note!, + item.updatedAt == null + ? '-' + : dateFormat.format(item.updatedAt!.toLocal()), + ].map((text) => ShadTableCell(child: Text(text))).toList(); + + cells.add( + ShadTableCell( + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + ShadButton.ghost( + size: ShadButtonSize.sm, + onPressed: onEdit == null ? null : () => onEdit!(item), + child: const Icon(LucideIcons.pencil, size: 16), + ), + const SizedBox(width: 8), + item.isDeleted + ? ShadButton.ghost( + size: ShadButtonSize.sm, + onPressed: onRestore == null + ? null + : () => onRestore!(item), + child: const Icon(LucideIcons.history, size: 16), + ) + : ShadButton.ghost( + size: ShadButtonSize.sm, + onPressed: onDelete == null + ? null + : () => onDelete!(item), + child: const Icon(LucideIcons.trash2, size: 16), + ), ], ), ), + ); + return cells; + }).toList(); + + return SizedBox( + height: 56.0 * (menus.length + 1), + child: ShadTable.list( + header: header, + children: rows, + columnSpanExtent: (index) { + switch (index) { + case 4: + return const FixedTableSpanExtent(200); + case 7: + return const FixedTableSpanExtent(200); + case 9: + return const FixedTableSpanExtent(160); + default: + return const FixedTableSpanExtent(120); + } + }, + ), + ); + } +} + +class _FormField extends StatelessWidget { + const _FormField({required this.label, required this.child}); + + final String label; + final Widget child; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: theme.textTheme.small), + const SizedBox(height: 6), + child, ], ); } diff --git a/lib/features/masters/product/presentation/pages/product_page.dart b/lib/features/masters/product/presentation/pages/product_page.dart index 1224b19..c9c28d9 100644 --- a/lib/features/masters/product/presentation/pages/product_page.dart +++ b/lib/features/masters/product/presentation/pages/product_page.dart @@ -2,6 +2,10 @@ import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:superport_v2/core/constants/app_sections.dart'; +import 'package:superport_v2/widgets/app_layout.dart'; +import 'package:superport_v2/widgets/components/filter_bar.dart'; + import '../../../../../core/config/environment.dart'; import '../../../../../widgets/spec_page.dart'; import '../../../uom/domain/entities/uom.dart'; @@ -134,223 +138,188 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> { ? false : (result.page * result.pageSize) < result.total; - return SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + final showReset = _searchController.text.isNotEmpty || + _controller.vendorFilter != null || + _controller.uomFilter != null || + _controller.statusFilter != ProductStatusFilter.all; + + return AppLayout( + title: '장비 모델(제품) 관리', + subtitle: '제품코드, 제조사, 단위 정보를 관리합니다.', + breadcrumbs: const [ + AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath), + AppBreadcrumbItem(label: '마스터', path: '/masters/products'), + AppBreadcrumbItem(label: '제품'), + ], + actions: [ + ShadButton( + leading: const Icon(LucideIcons.plus, size: 16), + onPressed: _controller.isSubmitting + ? null + : () => _openProductForm(context), + child: const Text('신규 등록'), + ), + ], + toolbar: FilterBar( children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('장비 모델(제품) 관리', style: theme.textTheme.h2), - const SizedBox(height: 6), - Text( - '제품코드, 제조사, 단위 정보를 관리합니다.', - style: theme.textTheme.muted, - ), - ], - ), - ), - const SizedBox(width: 16), - ShadButton( - leading: const Icon(LucideIcons.plus, size: 16), - onPressed: _controller.isSubmitting - ? null - : () => _openProductForm(context), - child: const Text('신규 등록'), - ), - ], + SizedBox( + width: 260, + child: ShadInput( + controller: _searchController, + focusNode: _searchFocus, + placeholder: const Text('제품코드, 제품명 검색'), + leading: const Icon(LucideIcons.search, size: 16), + onSubmitted: (_) => _applyFilters(), + ), ), - const SizedBox(height: 24), - ShadCard( - title: Text('검색 및 필터', style: theme.textTheme.h3), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Wrap( - spacing: 16, - runSpacing: 16, - children: [ - SizedBox( - width: 260, - child: ShadInput( - controller: _searchController, - focusNode: _searchFocus, - placeholder: const Text('제품코드, 제품명 검색'), - leading: const Icon(LucideIcons.search, size: 16), - onSubmitted: (_) => _applyFilters(), - ), - ), - SizedBox( - width: 220, - child: ShadSelect( - key: ValueKey(_controller.vendorFilter), - initialValue: _controller.vendorFilter, - placeholder: const Text('제조사 전체'), - selectedOptionBuilder: (context, value) { - if (value == null) { - return const Text('제조사 전체'); - } - final vendor = _controller.vendorOptions - .firstWhere( - (v) => v.id == value, - orElse: () => Vendor( - id: value, - vendorCode: '', - vendorName: '', - ), - ); - return Text(vendor.vendorName); - }, - onChanged: (value) { - _controller.updateVendorFilter(value); - }, - options: [ - const ShadOption( - value: null, - child: Text('제조사 전체'), - ), - ..._controller.vendorOptions.map( - (vendor) => ShadOption( - value: vendor.id, - child: Text(vendor.vendorName), - ), - ), - ], - ), - ), - SizedBox( - width: 220, - child: ShadSelect( - key: ValueKey(_controller.uomFilter), - initialValue: _controller.uomFilter, - placeholder: const Text('단위 전체'), - selectedOptionBuilder: (context, value) { - if (value == null) { - return const Text('단위 전체'); - } - final uom = _controller.uomOptions.firstWhere( - (u) => u.id == value, - orElse: () => Uom(id: value, uomName: ''), - ); - return Text(uom.uomName); - }, - onChanged: (value) { - _controller.updateUomFilter(value); - }, - options: [ - const ShadOption( - value: null, - child: Text('단위 전체'), - ), - ..._controller.uomOptions.map( - (uom) => ShadOption( - value: uom.id, - child: Text(uom.uomName), - ), - ), - ], - ), - ), - SizedBox( - width: 200, - child: ShadSelect( - key: ValueKey(_controller.statusFilter), - initialValue: _controller.statusFilter, - selectedOptionBuilder: (context, filter) => - Text(_statusLabel(filter)), - onChanged: (value) { - if (value == null) return; - _controller.updateStatusFilter(value); - }, - options: ProductStatusFilter.values - .map( - (filter) => ShadOption( - value: filter, - child: Text(_statusLabel(filter)), - ), - ) - .toList(), - ), - ), - ShadButton.outline( - onPressed: _controller.isLoading - ? null - : _applyFilters, - child: const Text('검색 적용'), - ), - if (_searchController.text.isNotEmpty || - _controller.vendorFilter != null || - _controller.uomFilter != null || - _controller.statusFilter != ProductStatusFilter.all) - ShadButton.ghost( - onPressed: _controller.isLoading - ? null - : () { - _searchController.clear(); - _searchFocus.requestFocus(); - _controller.updateQuery(''); - _controller.updateVendorFilter(null); - _controller.updateUomFilter(null); - _controller.updateStatusFilter( - ProductStatusFilter.all, - ); - _controller.fetch(page: 1); - }, - child: const Text('초기화'), - ), - ], + SizedBox( + width: 220, + child: ShadSelect( + key: ValueKey(_controller.vendorFilter), + initialValue: _controller.vendorFilter, + placeholder: const Text('제조사 전체'), + selectedOptionBuilder: (context, value) { + if (value == null) { + return const Text('제조사 전체'); + } + final vendor = _controller.vendorOptions.firstWhere( + (v) => v.id == value, + orElse: () => Vendor(id: value, vendorCode: '', vendorName: ''), + ); + return Text(vendor.vendorName); + }, + onChanged: (value) => _controller.updateVendorFilter(value), + options: [ + const ShadOption( + value: null, + child: Text('제조사 전체'), + ), + ..._controller.vendorOptions.map( + (vendor) => ShadOption( + value: vendor.id, + child: Text(vendor.vendorName), + ), ), ], ), ), - const SizedBox(height: 24), - ShadCard( - title: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text('제품 목록', style: theme.textTheme.h3), - Text('$totalCount건', style: theme.textTheme.muted), - ], - ), - footer: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - '페이지 $currentPage / $totalPages', - style: theme.textTheme.small, + SizedBox( + width: 220, + child: ShadSelect( + key: ValueKey(_controller.uomFilter), + initialValue: _controller.uomFilter, + placeholder: const Text('단위 전체'), + selectedOptionBuilder: (context, value) { + if (value == null) { + return const Text('단위 전체'); + } + final uom = _controller.uomOptions.firstWhere( + (u) => u.id == value, + orElse: () => Uom(id: value, uomName: ''), + ); + return Text(uom.uomName); + }, + onChanged: (value) => _controller.updateUomFilter(value), + options: [ + const ShadOption( + value: null, + child: Text('단위 전체'), ), - Row( - children: [ - ShadButton.outline( - size: ShadButtonSize.sm, - onPressed: _controller.isLoading || currentPage <= 1 - ? null - : () => _controller.fetch(page: currentPage - 1), - child: const Text('이전'), - ), - const SizedBox(width: 8), - ShadButton.outline( - size: ShadButtonSize.sm, - onPressed: _controller.isLoading || !hasNext - ? null - : () => _controller.fetch(page: currentPage + 1), - child: const Text('다음'), - ), - ], + ..._controller.uomOptions.map( + (uom) => ShadOption( + value: uom.id, + child: Text(uom.uomName), + ), ), ], ), - child: _controller.isLoading - ? const Padding( - padding: EdgeInsets.all(48), - child: Center(child: CircularProgressIndicator()), + ), + SizedBox( + width: 200, + child: ShadSelect( + key: ValueKey(_controller.statusFilter), + initialValue: _controller.statusFilter, + selectedOptionBuilder: (context, filter) => + Text(_statusLabel(filter)), + onChanged: (value) { + if (value == null) return; + _controller.updateStatusFilter(value); + }, + options: ProductStatusFilter.values + .map( + (filter) => ShadOption( + value: filter, + child: Text(_statusLabel(filter)), + ), ) - : products.isEmpty + .toList(), + ), + ), + ShadButton.outline( + onPressed: _controller.isLoading ? null : _applyFilters, + child: const Text('검색 적용'), + ), + if (showReset) + ShadButton.ghost( + onPressed: _controller.isLoading + ? null + : () { + _searchController.clear(); + _searchFocus.requestFocus(); + _controller.updateQuery(''); + _controller.updateVendorFilter(null); + _controller.updateUomFilter(null); + _controller.updateStatusFilter( + ProductStatusFilter.all, + ); + _controller.fetch(page: 1); + }, + child: const Text('초기화'), + ), + ], + ), + child: ShadCard( + title: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('제품 목록', style: theme.textTheme.h3), + Text('$totalCount건', style: theme.textTheme.muted), + ], + ), + footer: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '페이지 $currentPage / $totalPages', + style: theme.textTheme.small, + ), + Row( + children: [ + ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: _controller.isLoading || currentPage <= 1 + ? null + : () => _controller.fetch(page: currentPage - 1), + child: const Text('이전'), + ), + const SizedBox(width: 8), + ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: _controller.isLoading || !hasNext + ? null + : () => _controller.fetch(page: currentPage + 1), + child: const Text('다음'), + ), + ], + ), + ], + ), + child: _controller.isLoading + ? const Padding( + padding: EdgeInsets.all(48), + child: Center(child: CircularProgressIndicator()), + ) + : products.isEmpty ? Padding( padding: const EdgeInsets.all(32), child: Text( @@ -372,8 +341,6 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> { ? null : _restoreProduct, ), - ), - ], ), ); }, diff --git a/lib/features/masters/user/presentation/pages/user_page.dart b/lib/features/masters/user/presentation/pages/user_page.dart index 73fcac9..dab1a49 100644 --- a/lib/features/masters/user/presentation/pages/user_page.dart +++ b/lib/features/masters/user/presentation/pages/user_page.dart @@ -2,6 +2,10 @@ import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:superport_v2/core/constants/app_sections.dart'; +import 'package:superport_v2/widgets/app_layout.dart'; +import 'package:superport_v2/widgets/components/filter_bar.dart'; + import '../../../../../core/config/environment.dart'; import '../../../../../widgets/spec_page.dart'; import '../../../group/domain/entities/group.dart'; @@ -143,188 +147,162 @@ class _UserEnabledPageState extends State<_UserEnabledPage> { ? false : (result.page * result.pageSize) < result.total; - return SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + final showReset = _searchController.text.isNotEmpty || + _controller.groupFilter != null || + _controller.statusFilter != UserStatusFilter.all; + + return AppLayout( + title: '사용자(사원) 관리', + subtitle: '사번 기반 계정과 그룹, 사용 상태를 관리합니다.', + breadcrumbs: const [ + AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath), + AppBreadcrumbItem(label: '마스터', path: '/masters/users'), + AppBreadcrumbItem(label: '사용자'), + ], + actions: [ + ShadButton( + leading: const Icon(LucideIcons.plus, size: 16), + onPressed: + _controller.isSubmitting ? null : () => _openUserForm(context), + child: const Text('신규 등록'), + ), + ], + toolbar: FilterBar( children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('사용자(사원) 관리', style: theme.textTheme.h2), - const SizedBox(height: 6), - Text( - '사번 기반 계정과 그룹, 사용 상태를 관리합니다.', - style: theme.textTheme.muted, - ), - ], - ), - ), - const SizedBox(width: 16), - ShadButton( - onPressed: _controller.isSubmitting - ? null - : () => _openUserForm(context), - child: const Text('신규 등록'), - ), - ], + SizedBox( + width: 260, + child: ShadInput( + controller: _searchController, + focusNode: _searchFocus, + placeholder: const Text('사번, 성명, 이메일 검색'), + leading: const Icon(LucideIcons.search, size: 16), + onSubmitted: (_) => _applyFilters(), + ), ), - const SizedBox(height: 24), - ShadCard( - title: Text('검색 및 필터', style: theme.textTheme.h3), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Wrap( - spacing: 16, - runSpacing: 16, - children: [ - SizedBox( - width: 260, - child: ShadInput( - controller: _searchController, - focusNode: _searchFocus, - placeholder: const Text('사번, 성명, 이메일 검색'), - leading: const Icon(LucideIcons.search, size: 16), - onSubmitted: (_) => _applyFilters(), - ), - ), - SizedBox( - width: 220, - child: ShadSelect( - key: ValueKey(_controller.groupFilter), - initialValue: _controller.groupFilter, - placeholder: Text( - _groupsLoaded ? '그룹 전체' : '그룹 로딩중...', - ), - selectedOptionBuilder: (context, value) { - if (value == null) { - return Text( - _groupsLoaded ? '그룹 전체' : '그룹 로딩중...', - ); - } - final group = _controller.groups.firstWhere( - (g) => g.id == value, - orElse: () => Group(id: value, groupName: ''), - ); - return Text(group.groupName); - }, - onChanged: _controller.isLoadingGroups - ? null - : (value) { - _controller.updateGroupFilter(value); - }, - options: [ - const ShadOption( - value: null, - child: Text('그룹 전체'), - ), - ..._controller.groups.map( - (group) => ShadOption( - value: group.id, - child: Text(group.groupName), - ), - ), - ], - ), - ), - SizedBox( - width: 200, - child: ShadSelect( - key: ValueKey(_controller.statusFilter), - initialValue: _controller.statusFilter, - selectedOptionBuilder: (context, filter) => - Text(_statusLabel(filter)), - onChanged: (value) { - if (value == null) return; - _controller.updateStatusFilter(value); - }, - options: UserStatusFilter.values - .map( - (filter) => ShadOption( - value: filter, - child: Text(_statusLabel(filter)), - ), - ) - .toList(), - ), - ), - ShadButton.outline( - onPressed: _controller.isLoading - ? null - : _applyFilters, - child: const Text('검색 적용'), - ), - if (_searchController.text.isNotEmpty || - _controller.groupFilter != null || - _controller.statusFilter != UserStatusFilter.all) - ShadButton.ghost( - onPressed: _controller.isLoading - ? null - : () { - _searchController.clear(); - _searchFocus.requestFocus(); - _controller.updateQuery(''); - _controller.updateGroupFilter(null); - _controller.updateStatusFilter( - UserStatusFilter.all, - ); - _controller.fetch(page: 1); - }, - child: const Text('초기화'), - ), - ], + SizedBox( + width: 220, + child: ShadSelect( + key: ValueKey(_controller.groupFilter), + initialValue: _controller.groupFilter, + placeholder: Text( + _groupsLoaded ? '그룹 전체' : '그룹 로딩중...', + ), + selectedOptionBuilder: (context, value) { + if (value == null) { + return Text( + _groupsLoaded ? '그룹 전체' : '그룹 로딩중...', + ); + } + final group = _controller.groups.firstWhere( + (g) => g.id == value, + orElse: () => Group(id: value, groupName: ''), + ); + return Text(group.groupName); + }, + onChanged: _controller.isLoadingGroups + ? null + : (value) { + _controller.updateGroupFilter(value); + }, + options: [ + const ShadOption( + value: null, + child: Text('그룹 전체'), + ), + ..._controller.groups.map( + (group) => ShadOption( + value: group.id, + child: Text(group.groupName), + ), ), ], ), ), - const SizedBox(height: 24), - ShadCard( - title: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text('사용자 목록', style: theme.textTheme.h3), - Text('$totalCount건', style: theme.textTheme.muted), - ], - ), - footer: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - '페이지 $currentPage / $totalPages', - style: theme.textTheme.small, - ), - Row( - children: [ - ShadButton.outline( - size: ShadButtonSize.sm, - onPressed: _controller.isLoading || currentPage <= 1 - ? null - : () => _controller.fetch(page: currentPage - 1), - child: const Text('이전'), + SizedBox( + width: 200, + child: ShadSelect( + key: ValueKey(_controller.statusFilter), + initialValue: _controller.statusFilter, + selectedOptionBuilder: (context, filter) => + Text(_statusLabel(filter)), + onChanged: (value) { + if (value == null) return; + _controller.updateStatusFilter(value); + }, + options: UserStatusFilter.values + .map( + (filter) => ShadOption( + value: filter, + child: Text(_statusLabel(filter)), ), - const SizedBox(width: 8), - ShadButton.outline( - size: ShadButtonSize.sm, - onPressed: _controller.isLoading || !hasNext - ? null - : () => _controller.fetch(page: currentPage + 1), - child: const Text('다음'), - ), - ], - ), - ], - ), - child: _controller.isLoading - ? const Padding( - padding: EdgeInsets.all(48), - child: Center(child: CircularProgressIndicator()), ) - : users.isEmpty + .toList(), + ), + ), + ShadButton.outline( + onPressed: _controller.isLoading ? null : _applyFilters, + child: const Text('검색 적용'), + ), + if (showReset) + ShadButton.ghost( + onPressed: _controller.isLoading + ? null + : () { + _searchController.clear(); + _searchFocus.requestFocus(); + _controller.updateQuery(''); + _controller.updateGroupFilter(null); + _controller.updateStatusFilter( + UserStatusFilter.all, + ); + _controller.fetch(page: 1); + }, + child: const Text('초기화'), + ), + ], + ), + child: ShadCard( + title: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('사용자 목록', style: theme.textTheme.h3), + Text('$totalCount건', style: theme.textTheme.muted), + ], + ), + footer: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '페이지 $currentPage / $totalPages', + style: theme.textTheme.small, + ), + Row( + children: [ + ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: _controller.isLoading || currentPage <= 1 + ? null + : () => _controller.fetch(page: currentPage - 1), + child: const Text('이전'), + ), + const SizedBox(width: 8), + ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: _controller.isLoading || !hasNext + ? null + : () => _controller.fetch(page: currentPage + 1), + child: const Text('다음'), + ), + ], + ), + ], + ), + child: _controller.isLoading + ? const Padding( + padding: EdgeInsets.all(48), + child: Center(child: CircularProgressIndicator()), + ) + : users.isEmpty ? Padding( padding: const EdgeInsets.all(32), child: Text( @@ -344,8 +322,6 @@ class _UserEnabledPageState extends State<_UserEnabledPage> { ? null : _restoreUser, ), - ), - ], ), ); }, diff --git a/lib/features/masters/vendor/presentation/pages/vendor_page.dart b/lib/features/masters/vendor/presentation/pages/vendor_page.dart index 4619b71..a08378b 100644 --- a/lib/features/masters/vendor/presentation/pages/vendor_page.dart +++ b/lib/features/masters/vendor/presentation/pages/vendor_page.dart @@ -2,6 +2,10 @@ import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:superport_v2/core/constants/app_sections.dart'; +import 'package:superport_v2/widgets/app_layout.dart'; +import 'package:superport_v2/widgets/components/filter_bar.dart'; + import '../../../../../core/config/environment.dart'; import '../../../../../widgets/spec_page.dart'; import '../../../vendor/domain/entities/vendor.dart'; @@ -118,150 +122,122 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> { ? false : (result.page * result.pageSize) < result.total; - return SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + return AppLayout( + title: '제조사(벤더) 관리', + subtitle: '벤더코드, 명칭, 사용여부, 삭제 상태를 관리합니다.', + breadcrumbs: const [ + AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath), + AppBreadcrumbItem(label: '마스터', path: '/masters/vendors'), + AppBreadcrumbItem(label: '벤더'), + ], + actions: [ + ShadButton( + leading: const Icon(LucideIcons.plus, size: 16), + onPressed: _controller.isSubmitting + ? null + : () => _openVendorForm(context), + child: const Text('신규 등록'), + ), + ], + toolbar: FilterBar( children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('제조사(벤더) 관리', style: theme.textTheme.h2), - const SizedBox(height: 6), - Text( - '벤더코드, 명칭, 사용여부, 삭제 상태를 관리합니다.', - style: theme.textTheme.muted, - ), - ], - ), - ), - const SizedBox(width: 16), - ShadButton( - leading: const Icon(LucideIcons.plus, size: 16), - onPressed: _controller.isSubmitting - ? null - : () => _openVendorForm(context), - child: const Text('신규 등록'), - ), - ], - ), - const SizedBox(height: 24), - ShadCard( - title: Text('검색 및 필터', style: theme.textTheme.h3), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Wrap( - spacing: 16, - runSpacing: 16, - children: [ - SizedBox( - width: 280, - child: ShadInput( - controller: _searchController, - focusNode: _searchFocusNode, - placeholder: const Text('벤더코드, 벤더명 검색'), - leading: const Icon(LucideIcons.search, size: 16), - onSubmitted: (_) => _applyFilters(), - ), - ), - SizedBox( - width: 220, - child: ShadSelect( - key: ValueKey(_controller.statusFilter), - initialValue: _controller.statusFilter, - selectedOptionBuilder: (context, value) => - Text(_statusLabel(value)), - onChanged: (value) { - if (value != null) { - _controller.updateStatusFilter(value); - _controller.fetch(page: 1); - } - }, - options: VendorStatusFilter.values - .map( - (filter) => ShadOption( - value: filter, - child: Text(_statusLabel(filter)), - ), - ) - .toList(), - ), - ), - ShadButton.outline( - onPressed: _controller.isLoading - ? null - : _applyFilters, - child: const Text('검색 적용'), - ), - if (_searchController.text.isNotEmpty || - _controller.statusFilter != VendorStatusFilter.all) - ShadButton.ghost( - onPressed: _controller.isLoading - ? null - : () { - _searchController.clear(); - _searchFocusNode.requestFocus(); - _controller.updateQuery(''); - _controller.updateStatusFilter( - VendorStatusFilter.all, - ); - _controller.fetch(page: 1); - }, - child: const Text('초기화'), - ), - ], - ), - ], + SizedBox( + width: 280, + child: ShadInput( + controller: _searchController, + focusNode: _searchFocusNode, + placeholder: const Text('벤더코드, 벤더명 검색'), + leading: const Icon(LucideIcons.search, size: 16), + onSubmitted: (_) => _applyFilters(), ), ), - const SizedBox(height: 24), - ShadCard( - title: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text('벤더 목록', style: theme.textTheme.h3), - Text('$totalCount건', style: theme.textTheme.muted), - ], - ), - footer: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - '페이지 $currentPage / $totalPages', - style: theme.textTheme.small, - ), - Row( - children: [ - ShadButton.outline( - size: ShadButtonSize.sm, - onPressed: _controller.isLoading || currentPage <= 1 - ? null - : () => _controller.fetch(page: currentPage - 1), - child: const Text('이전'), + SizedBox( + width: 220, + child: ShadSelect( + key: ValueKey(_controller.statusFilter), + initialValue: _controller.statusFilter, + selectedOptionBuilder: (context, value) => + Text(_statusLabel(value)), + onChanged: (value) { + if (value != null) { + _controller.updateStatusFilter(value); + _controller.fetch(page: 1); + } + }, + options: VendorStatusFilter.values + .map( + (filter) => ShadOption( + value: filter, + child: Text(_statusLabel(filter)), ), - const SizedBox(width: 8), - ShadButton.outline( - size: ShadButtonSize.sm, - onPressed: _controller.isLoading || !hasNext - ? null - : () => _controller.fetch(page: currentPage + 1), - child: const Text('다음'), - ), - ], - ), - ], - ), - child: _controller.isLoading - ? const Padding( - padding: EdgeInsets.all(48), - child: Center(child: CircularProgressIndicator()), ) - : vendors.isEmpty + .toList(), + ), + ), + ShadButton.outline( + onPressed: _controller.isLoading ? null : _applyFilters, + child: const Text('검색 적용'), + ), + if (_searchController.text.isNotEmpty || + _controller.statusFilter != VendorStatusFilter.all) + ShadButton.ghost( + onPressed: _controller.isLoading + ? null + : () { + _searchController.clear(); + _searchFocusNode.requestFocus(); + _controller.updateQuery(''); + _controller.updateStatusFilter( + VendorStatusFilter.all, + ); + _controller.fetch(page: 1); + }, + child: const Text('초기화'), + ), + ], + ), + child: ShadCard( + title: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('벤더 목록', style: theme.textTheme.h3), + Text('$totalCount건', style: theme.textTheme.muted), + ], + ), + footer: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '페이지 $currentPage / $totalPages', + style: theme.textTheme.small, + ), + Row( + children: [ + ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: _controller.isLoading || currentPage <= 1 + ? null + : () => _controller.fetch(page: currentPage - 1), + child: const Text('이전'), + ), + const SizedBox(width: 8), + ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: _controller.isLoading || !hasNext + ? null + : () => _controller.fetch(page: currentPage + 1), + child: const Text('다음'), + ), + ], + ), + ], + ), + child: _controller.isLoading + ? const Padding( + padding: EdgeInsets.all(48), + child: Center(child: CircularProgressIndicator()), + ) + : vendors.isEmpty ? Padding( padding: const EdgeInsets.all(32), child: Text( @@ -283,8 +259,6 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> { : _restoreVendor, dateFormat: _dateFormat, ), - ), - ], ), ); }, diff --git a/lib/features/masters/warehouse/presentation/pages/warehouse_page.dart b/lib/features/masters/warehouse/presentation/pages/warehouse_page.dart index 281fbf8..b8e8f8e 100644 --- a/lib/features/masters/warehouse/presentation/pages/warehouse_page.dart +++ b/lib/features/masters/warehouse/presentation/pages/warehouse_page.dart @@ -2,6 +2,10 @@ import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:superport_v2/core/constants/app_sections.dart'; +import 'package:superport_v2/widgets/app_layout.dart'; +import 'package:superport_v2/widgets/components/filter_bar.dart'; + import '../../../../../core/config/environment.dart'; import '../../../../../widgets/spec_page.dart'; import '../../domain/entities/warehouse.dart'; @@ -133,149 +137,122 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> { ? false : (result.page * result.pageSize) < result.total; - return SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + final showReset = _searchController.text.isNotEmpty || + _controller.statusFilter != WarehouseStatusFilter.all; + + return AppLayout( + title: '입고지(창고) 관리', + subtitle: '창고 코드, 주소, 사용여부를 관리합니다.', + breadcrumbs: const [ + AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath), + AppBreadcrumbItem(label: '마스터', path: '/masters/warehouses'), + AppBreadcrumbItem(label: '창고'), + ], + actions: [ + ShadButton( + leading: const Icon(LucideIcons.plus, size: 16), + onPressed: _controller.isSubmitting + ? null + : () => _openWarehouseForm(context), + child: const Text('신규 등록'), + ), + ], + toolbar: FilterBar( children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('입고지(창고) 관리', style: theme.textTheme.h2), - const SizedBox(height: 6), - Text( - '창고 코드, 주소, 사용여부를 관리합니다.', - style: theme.textTheme.muted, - ), - ], - ), - ), - const SizedBox(width: 16), - ShadButton( - leading: const Icon(LucideIcons.plus, size: 16), - onPressed: _controller.isSubmitting - ? null - : () => _openWarehouseForm(context), - child: const Text('신규 등록'), - ), - ], - ), - const SizedBox(height: 24), - ShadCard( - title: Text('검색 및 필터', style: theme.textTheme.h3), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Wrap( - spacing: 16, - runSpacing: 16, - children: [ - SizedBox( - width: 260, - child: ShadInput( - controller: _searchController, - focusNode: _searchFocus, - placeholder: const Text('창고코드, 창고명 검색'), - leading: const Icon(LucideIcons.search, size: 16), - onSubmitted: (_) => _applyFilters(), - ), - ), - SizedBox( - width: 200, - child: ShadSelect( - key: ValueKey(_controller.statusFilter), - initialValue: _controller.statusFilter, - selectedOptionBuilder: (context, filter) => - Text(_statusLabel(filter)), - onChanged: (value) { - if (value == null) return; - _controller.updateStatusFilter(value); - }, - options: WarehouseStatusFilter.values - .map( - (filter) => ShadOption( - value: filter, - child: Text(_statusLabel(filter)), - ), - ) - .toList(), - ), - ), - ShadButton.outline( - onPressed: _controller.isLoading - ? null - : _applyFilters, - child: const Text('검색 적용'), - ), - if (_searchController.text.isNotEmpty || - _controller.statusFilter != - WarehouseStatusFilter.all) - ShadButton.ghost( - onPressed: _controller.isLoading - ? null - : () { - _searchController.clear(); - _searchFocus.requestFocus(); - _controller.updateQuery(''); - _controller.updateStatusFilter( - WarehouseStatusFilter.all, - ); - _controller.fetch(page: 1); - }, - child: const Text('초기화'), - ), - ], - ), - ], + SizedBox( + width: 260, + child: ShadInput( + controller: _searchController, + focusNode: _searchFocus, + placeholder: const Text('창고코드, 창고명 검색'), + leading: const Icon(LucideIcons.search, size: 16), + onSubmitted: (_) => _applyFilters(), ), ), - const SizedBox(height: 24), - ShadCard( - title: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text('창고 목록', style: theme.textTheme.h3), - Text('$totalCount건', style: theme.textTheme.muted), - ], - ), - footer: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - '페이지 $currentPage / $totalPages', - style: theme.textTheme.small, - ), - Row( - children: [ - ShadButton.outline( - size: ShadButtonSize.sm, - onPressed: _controller.isLoading || currentPage <= 1 - ? null - : () => _controller.fetch(page: currentPage - 1), - child: const Text('이전'), + SizedBox( + width: 200, + child: ShadSelect( + key: ValueKey(_controller.statusFilter), + initialValue: _controller.statusFilter, + selectedOptionBuilder: (context, filter) => + Text(_statusLabel(filter)), + onChanged: (value) { + if (value == null) return; + _controller.updateStatusFilter(value); + }, + options: WarehouseStatusFilter.values + .map( + (filter) => ShadOption( + value: filter, + child: Text(_statusLabel(filter)), ), - const SizedBox(width: 8), - ShadButton.outline( - size: ShadButtonSize.sm, - onPressed: _controller.isLoading || !hasNext - ? null - : () => _controller.fetch(page: currentPage + 1), - child: const Text('다음'), - ), - ], - ), - ], - ), - child: _controller.isLoading - ? const Padding( - padding: EdgeInsets.all(48), - child: Center(child: CircularProgressIndicator()), ) - : warehouses.isEmpty + .toList(), + ), + ), + ShadButton.outline( + onPressed: _controller.isLoading ? null : _applyFilters, + child: const Text('검색 적용'), + ), + if (showReset) + ShadButton.ghost( + onPressed: _controller.isLoading + ? null + : () { + _searchController.clear(); + _searchFocus.requestFocus(); + _controller.updateQuery(''); + _controller.updateStatusFilter( + WarehouseStatusFilter.all, + ); + _controller.fetch(page: 1); + }, + child: const Text('초기화'), + ), + ], + ), + child: ShadCard( + title: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('창고 목록', style: theme.textTheme.h3), + Text('$totalCount건', style: theme.textTheme.muted), + ], + ), + footer: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '페이지 $currentPage / $totalPages', + style: theme.textTheme.small, + ), + Row( + children: [ + ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: _controller.isLoading || currentPage <= 1 + ? null + : () => _controller.fetch(page: currentPage - 1), + child: const Text('이전'), + ), + const SizedBox(width: 8), + ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: _controller.isLoading || !hasNext + ? null + : () => _controller.fetch(page: currentPage + 1), + child: const Text('다음'), + ), + ], + ), + ], + ), + child: _controller.isLoading + ? const Padding( + padding: EdgeInsets.all(48), + child: Center(child: CircularProgressIndicator()), + ) + : warehouses.isEmpty ? Padding( padding: const EdgeInsets.all(32), child: Text( @@ -288,10 +265,8 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> { dateFormat: _dateFormat, onEdit: _controller.isSubmitting ? null - : (warehouse) => _openWarehouseForm( - context, - warehouse: warehouse, - ), + : (warehouse) => + _openWarehouseForm(context, warehouse: warehouse), onDelete: _controller.isSubmitting ? null : _confirmDelete, @@ -299,8 +274,6 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> { ? null : _restoreWarehouse, ), - ), - ], ), ); }, diff --git a/lib/features/reporting/presentation/pages/reporting_page.dart b/lib/features/reporting/presentation/pages/reporting_page.dart index cbfcb5b..2100fb2 100644 --- a/lib/features/reporting/presentation/pages/reporting_page.dart +++ b/lib/features/reporting/presentation/pages/reporting_page.dart @@ -1,30 +1,71 @@ -import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; -import '../../../../widgets/spec_page.dart'; +import '../../../../core/constants/app_sections.dart'; +import '../../../../widgets/app_layout.dart'; +import '../../../../widgets/components/coming_soon_card.dart'; +import '../../../../widgets/components/filter_bar.dart'; class ReportingPage extends StatelessWidget { const ReportingPage({super.key}); @override Widget build(BuildContext context) { - return const SpecPage( + return AppLayout( title: '보고서', - summary: '기간, 유형, 창고, 상태 조건으로 보고서를 조회하고 내보냅니다.', - sections: [ - SpecSection( - title: '조건 입력', - items: [ - '기간 [Date Range]', - '유형 [Dropdown]', - '창고 [Dropdown]', - '상태 [Dropdown]', - ], + subtitle: '기간, 유형, 창고 조건을 선택해 통합 보고서를 내려받을 수 있도록 준비 중입니다.', + breadcrumbs: const [ + AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath), + AppBreadcrumbItem(label: '보고', path: '/reports'), + AppBreadcrumbItem(label: '보고서'), + ], + actions: [ + ShadButton( + onPressed: null, + leading: const Icon(LucideIcons.fileDown, size: 16), + child: const Text('XLSX 다운로드'), ), - SpecSection( - title: '출력 옵션', - items: ['XLSX 다운로드 [Button]', 'PDF 다운로드 [Button]'], + ShadButton.outline( + onPressed: null, + leading: const Icon(LucideIcons.fileText, size: 16), + child: const Text('PDF 다운로드'), ), ], + toolbar: FilterBar( + children: [ + ShadButton.outline( + onPressed: null, + leading: const Icon(LucideIcons.calendar, size: 16), + child: const Text('기간 선택 (준비중)'), + ), + ShadButton.outline( + onPressed: null, + leading: const Icon(LucideIcons.layers, size: 16), + child: const Text('유형 선택 (준비중)'), + ), + ShadButton.outline( + onPressed: null, + leading: const Icon(LucideIcons.warehouse, size: 16), + child: const Text('창고 선택 (준비중)'), + ), + ShadButton.outline( + onPressed: null, + leading: const Icon(LucideIcons.badgeCheck, size: 16), + child: const Text('상태 선택 (준비중)'), + ), + const ShadBadge( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: Text('API 스펙 정리 후 필터가 활성화됩니다.'), + ), + ), + ], + ), + child: const ComingSoonCard( + title: '보고서 화면 구현 준비 중', + description: '입·출고/결재 데이터를 조건별로 조회하고 다운로드할 수 있는 UI를 설계 중입니다.', + items: ['조건별 보고서 템플릿 매핑', '다운로드 진행 상태 표시 및 실패 처리', '즐겨찾는 조건 저장/불러오기'], + ), ); } } diff --git a/lib/injection_container.dart b/lib/injection_container.dart index a6de128..b52dba8 100644 --- a/lib/injection_container.dart +++ b/lib/injection_container.dart @@ -9,6 +9,10 @@ import 'features/masters/customer/data/repositories/customer_repository_remote.d import 'features/masters/customer/domain/repositories/customer_repository.dart'; import 'features/masters/group/data/repositories/group_repository_remote.dart'; import 'features/masters/group/domain/repositories/group_repository.dart'; +import 'features/masters/menu/data/repositories/menu_repository_remote.dart'; +import 'features/masters/menu/domain/repositories/menu_repository.dart'; +import 'features/masters/group_permission/data/repositories/group_permission_repository_remote.dart'; +import 'features/masters/group_permission/domain/repositories/group_permission_repository.dart'; import 'features/masters/product/data/repositories/product_repository_remote.dart'; import 'features/masters/product/domain/repositories/product_repository.dart'; import 'features/masters/user/data/repositories/user_repository_remote.dart'; @@ -19,6 +23,10 @@ import 'features/masters/warehouse/data/repositories/warehouse_repository_remote import 'features/masters/warehouse/domain/repositories/warehouse_repository.dart'; import 'features/masters/uom/data/repositories/uom_repository_remote.dart'; import 'features/masters/uom/domain/repositories/uom_repository.dart'; +import 'features/approvals/data/repositories/approval_repository_remote.dart'; +import 'features/approvals/data/repositories/approval_template_repository_remote.dart'; +import 'features/approvals/domain/repositories/approval_repository.dart'; +import 'features/approvals/domain/repositories/approval_template_repository.dart'; /// 전역 DI 컨테이너 final GetIt sl = GetIt.instance; @@ -77,4 +85,20 @@ Future initInjection({ sl.registerLazySingleton( () => UserRepositoryRemote(apiClient: sl()), ); + + sl.registerLazySingleton( + () => MenuRepositoryRemote(apiClient: sl()), + ); + + sl.registerLazySingleton( + () => GroupPermissionRepositoryRemote(apiClient: sl()), + ); + + sl.registerLazySingleton( + () => ApprovalRepositoryRemote(apiClient: sl()), + ); + + sl.registerLazySingleton( + () => ApprovalTemplateRepositoryRemote(apiClient: sl()), + ); } diff --git a/lib/widgets/app_layout.dart b/lib/widgets/app_layout.dart new file mode 100644 index 0000000..5e5e59c --- /dev/null +++ b/lib/widgets/app_layout.dart @@ -0,0 +1,133 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import 'components/page_header.dart'; + +/// 앱 공통 레이아웃: 브레드크럼/헤더/툴바/본문을 일관되게 배치한다. +class AppLayout extends StatelessWidget { + const AppLayout({ + super.key, + required this.title, + this.subtitle, + this.breadcrumbs = const [], + this.actions, + this.toolbar, + required this.child, + }); + + final String title; + final String? subtitle; + final List breadcrumbs; + final List? actions; + final Widget? toolbar; + final Widget child; + + @override + Widget build(BuildContext context) { + return SelectionArea( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (breadcrumbs.isNotEmpty) ...[ + _BreadcrumbBar(items: breadcrumbs), + const SizedBox(height: 16), + ], + PageHeader( + title: title, + subtitle: subtitle, + actions: actions, + ), + if (toolbar != null) ...[ + const SizedBox(height: 16), + toolbar!, + ], + const SizedBox(height: 24), + child, + ], + ), + ), + ); + } +} + +class AppBreadcrumbItem { + const AppBreadcrumbItem({ + required this.label, + this.path, + this.onTap, + }); + + final String label; + final String? path; + final VoidCallback? onTap; + + void navigate(BuildContext context) { + if (path != null && path!.isNotEmpty) { + context.go(path!); + return; + } + onTap?.call(); + } +} + +class _BreadcrumbBar extends StatelessWidget { + const _BreadcrumbBar({required this.items}); + + final List items; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + final colorScheme = theme.colorScheme; + + return Wrap( + spacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + for (int index = 0; index < items.length; index++) ...[ + if (index != 0) + Icon( + LucideIcons.chevronRight, + size: 14, + color: colorScheme.mutedForeground, + ), + _BreadcrumbChip(item: items[index], isLast: index == items.length - 1), + ], + ], + ); + } +} + +class _BreadcrumbChip extends StatelessWidget { + const _BreadcrumbChip({required this.item, required this.isLast}); + + final AppBreadcrumbItem item; + final bool isLast; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + final label = Text( + item.label, + style: theme.textTheme.small.copyWith( + color: isLast ? theme.colorScheme.foreground : theme.colorScheme.mutedForeground, + ), + ); + + if (isLast || (item.path == null && item.onTap == null)) { + return label; + } + + return InkWell( + onTap: () => item.navigate(context), + borderRadius: BorderRadius.circular(6), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + child: label, + ), + ); + } +} diff --git a/lib/widgets/components/coming_soon_card.dart b/lib/widgets/components/coming_soon_card.dart new file mode 100644 index 0000000..6b27666 --- /dev/null +++ b/lib/widgets/components/coming_soon_card.dart @@ -0,0 +1,49 @@ +import 'package:flutter/widgets.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +/// 구축 예정인 화면에 안내 메시지를 제공하는 카드 위젯. +class ComingSoonCard extends StatelessWidget { + const ComingSoonCard({ + super.key, + required this.title, + required this.description, + this.items = const [], + }); + + final String title; + final String description; + final List items; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + + return ShadCard( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: theme.textTheme.h3), + const SizedBox(height: 12), + Text(description, style: theme.textTheme.p), + if (items.isNotEmpty) ...[ + const SizedBox(height: 16), + for (final item in items) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('• '), + Expanded(child: Text(item, style: theme.textTheme.p)), + ], + ), + ), + ], + ], + ), + ), + ); + } +} diff --git a/lib/widgets/components/empty_state.dart b/lib/widgets/components/empty_state.dart new file mode 100644 index 0000000..9c2404d --- /dev/null +++ b/lib/widgets/components/empty_state.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +class EmptyState extends StatelessWidget { + const EmptyState({super.key, required this.message, this.icon}); + + final String message; + final IconData? icon; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) + Icon(icon, size: 48, color: theme.colorScheme.mutedForeground), + if (icon != null) const SizedBox(height: 16), + Text(message, style: theme.textTheme.muted), + ], + ), + ); + } +} diff --git a/lib/widgets/components/filter_bar.dart b/lib/widgets/components/filter_bar.dart new file mode 100644 index 0000000..a93c653 --- /dev/null +++ b/lib/widgets/components/filter_bar.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +/// 검색/필터 영역을 위한 공통 래퍼. +class FilterBar extends StatelessWidget { + const FilterBar({super.key, required this.children}); + + final List children; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return ShadCard( + title: Text('검색 및 필터', style: theme.textTheme.h3), + child: Align( + alignment: Alignment.centerLeft, + child: Wrap( + spacing: 16, + runSpacing: 16, + children: children, + ), + ), + ); + } +} diff --git a/lib/widgets/components/page_header.dart b/lib/widgets/components/page_header.dart new file mode 100644 index 0000000..d7b220c --- /dev/null +++ b/lib/widgets/components/page_header.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +/// 페이지 상단 타이틀/설명/액션을 일관되게 출력하는 헤더. +class PageHeader extends StatelessWidget { + const PageHeader({ + super.key, + required this.title, + this.subtitle, + this.leading, + this.actions, + this.trailing, + }); + + final String title; + final String? subtitle; + final Widget? leading; + final List? actions; + final Widget? trailing; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (leading != null) ...[ + leading!, + const SizedBox(width: 16), + ], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: theme.textTheme.h2), + if (subtitle != null) ...[ + const SizedBox(height: 6), + Text(subtitle!, style: theme.textTheme.muted), + ], + ], + ), + ), + if (actions != null && actions!.isNotEmpty) ...[ + Wrap( + spacing: 12, + runSpacing: 12, + children: actions!, + ), + ], + if (trailing != null) ...[ + const SizedBox(width: 16), + trailing!, + ], + ], + ); + } +} diff --git a/lib/widgets/components/responsive.dart b/lib/widgets/components/responsive.dart new file mode 100644 index 0000000..4b4523c --- /dev/null +++ b/lib/widgets/components/responsive.dart @@ -0,0 +1,6 @@ +const double desktopBreakpoint = 1200; +const double tabletBreakpoint = 960; + +bool isDesktop(double width) => width >= desktopBreakpoint; +bool isTablet(double width) => width >= tabletBreakpoint && width < desktopBreakpoint; +bool isMobile(double width) => width < tabletBreakpoint; diff --git a/lib/widgets/components/superport_dialog.dart b/lib/widgets/components/superport_dialog.dart new file mode 100644 index 0000000..617dd2e --- /dev/null +++ b/lib/widgets/components/superport_dialog.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +/// 공통 모달 다이얼로그. +Future showSuperportDialog({ + required BuildContext context, + required String title, + String? description, + required Widget body, + List? actions, + bool barrierDismissible = true, +}) { + return showDialog( + context: context, + barrierDismissible: barrierDismissible, + builder: (dialogContext) { + final theme = ShadTheme.of(dialogContext); + return Dialog( + insetPadding: const EdgeInsets.all(24), + clipBehavior: Clip.antiAlias, + child: ShadCard( + title: Text(title, style: theme.textTheme.h3), + description: description == null + ? null + : Text(description, style: theme.textTheme.muted), + footer: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: actions ?? [ + ShadButton.ghost( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('닫기'), + ), + ], + ), + child: body, + ), + ); + }, + ); +} diff --git a/lib/widgets/components/superport_table.dart b/lib/widgets/components/superport_table.dart new file mode 100644 index 0000000..bb15e64 --- /dev/null +++ b/lib/widgets/components/superport_table.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +/// ShadTable.list를 감싼 공통 테이블 래퍼. +class SuperportTable extends StatelessWidget { + const SuperportTable({ + super.key, + required this.columns, + required this.rows, + this.columnSpanExtent, + this.rowHeight = 56, + this.onRowTap, + this.emptyLabel = '데이터가 없습니다.', + }); + + final List columns; + final List> rows; + final TableSpanExtent? Function(int index)? columnSpanExtent; + final double rowHeight; + final void Function(int index)? onRowTap; + final String emptyLabel; + + @override + Widget build(BuildContext context) { + if (rows.isEmpty) { + final theme = ShadTheme.of(context); + return Padding( + padding: const EdgeInsets.all(32), + child: Center( + child: Text(emptyLabel, style: theme.textTheme.muted), + ), + ); + } + + final tableRows = [ + for (final row in rows) + row + .map( + (cell) => cell is ShadTableCell ? cell : ShadTableCell(child: cell), + ) + .toList(), + ]; + + return ShadTable.list( + header: columns + .map( + (cell) => cell is ShadTableCell + ? cell + : ShadTableCell.header(child: cell), + ) + .toList(), + columnSpanExtent: columnSpanExtent, + rowSpanExtent: (_) => FixedTableSpanExtent(rowHeight), + onRowTap: onRowTap, + children: tableRows, + ); + } +} diff --git a/test/features/approvals/presentation/controllers/approval_controller_test.dart b/test/features/approvals/presentation/controllers/approval_controller_test.dart new file mode 100644 index 0000000..ae7cf8f --- /dev/null +++ b/test/features/approvals/presentation/controllers/approval_controller_test.dart @@ -0,0 +1,504 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/features/approvals/domain/entities/approval.dart'; +import 'package:superport_v2/features/approvals/domain/entities/approval_template.dart'; +import 'package:superport_v2/features/approvals/domain/repositories/approval_repository.dart'; +import 'package:superport_v2/features/approvals/domain/repositories/approval_template_repository.dart'; +import 'package:superport_v2/features/approvals/presentation/controllers/approval_controller.dart'; + +class _MockApprovalRepository extends Mock implements ApprovalRepository {} + +class _FakeApprovalInput extends Fake implements ApprovalInput {} + +class _FakeStepActionInput extends Fake implements ApprovalStepActionInput {} + +class _MockApprovalTemplateRepository extends Mock + implements ApprovalTemplateRepository {} + +class _FakeStepAssignmentInput extends Fake + implements ApprovalStepAssignmentInput {} + +void main() { + late ApprovalController controller; + late _MockApprovalRepository repository; + late _MockApprovalTemplateRepository templateRepository; + + final sampleStep = ApprovalStep( + id: 11, + stepOrder: 1, + approver: ApprovalApprover(id: 21, employeeNo: 'E001', name: '최승인'), + status: ApprovalStatus(id: 1, name: '대기'), + assignedAt: DateTime(2024, 4, 1, 9), + ); + + final sampleApproval = Approval( + id: 1, + approvalNo: 'AP-24001', + transactionNo: 'TRX-001', + status: ApprovalStatus(id: 1, name: '대기'), + currentStep: sampleStep, + requester: ApprovalRequester(id: 31, employeeNo: 'EMP001', name: '김상신'), + requestedAt: DateTime(2024, 4, 1, 9), + note: '긴급 결재', + steps: [sampleStep], + histories: const [], + ); + + PaginatedResult createResult(List items) { + return PaginatedResult( + items: items, + page: 1, + pageSize: 20, + total: items.length, + ); + } + + setUpAll(() { + registerFallbackValue(_FakeApprovalInput()); + registerFallbackValue(_FakeStepActionInput()); + registerFallbackValue(_FakeStepAssignmentInput()); + }); + + setUp(() { + repository = _MockApprovalRepository(); + templateRepository = _MockApprovalTemplateRepository(); + controller = ApprovalController( + approvalRepository: repository, + templateRepository: templateRepository, + ); + }); + + group('fetch', () { + setUp(() { + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + status: any(named: 'status'), + from: any(named: 'from'), + to: any(named: 'to'), + includeHistories: any(named: 'includeHistories'), + includeSteps: any(named: 'includeSteps'), + ), + ).thenAnswer((_) async => createResult([sampleApproval])); + }); + + test('목록을 조회한다', () async { + await controller.fetch(); + + expect(controller.result?.items, isNotEmpty); + expect(controller.errorMessage, isNull); + }); + + test('필터 전달을 검증한다', () async { + controller.updateQuery('TRX'); + controller.updateStatusFilter(ApprovalStatusFilter.approved); + final from = DateTime(2024, 4, 1); + final to = DateTime(2024, 4, 30); + controller.updateDateRange(from, to); + + await controller.fetch(page: 3); + + verify( + () => repository.list( + page: 3, + pageSize: 20, + query: 'TRX', + status: 'approved', + from: from, + to: to, + includeHistories: false, + includeSteps: false, + ), + ).called(1); + }); + + test('에러 발생 시 errorMessage 설정', () async { + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + status: any(named: 'status'), + from: any(named: 'from'), + to: any(named: 'to'), + includeHistories: any(named: 'includeHistories'), + includeSteps: any(named: 'includeSteps'), + ), + ).thenThrow(Exception('fail')); + + await controller.fetch(); + + expect(controller.errorMessage, isNotNull); + }); + }); + + group('selectApproval', () { + test('상세를 조회하고 저장한다', () async { + when( + () => repository.fetchDetail( + any(), + includeSteps: any(named: 'includeSteps'), + includeHistories: any(named: 'includeHistories'), + ), + ).thenAnswer((_) async => sampleApproval); + + await controller.selectApproval(1); + + expect(controller.selected, isNotNull); + verify( + () => repository.fetchDetail( + 1, + includeSteps: true, + includeHistories: true, + ), + ).called(1); + }); + + test('에러 발생 시 errorMessage 설정', () async { + when( + () => repository.fetchDetail( + any(), + includeSteps: any(named: 'includeSteps'), + includeHistories: any(named: 'includeHistories'), + ), + ).thenThrow(Exception('detail fail')); + + await controller.selectApproval(1); + + expect(controller.errorMessage, isNotNull); + }); + }); + + group('loadActionOptions', () { + test('행위 목록을 불러오고 캐시한다', () async { + when( + () => repository.listActions(activeOnly: any(named: 'activeOnly')), + ).thenAnswer((_) async => [ApprovalAction(id: 1, name: 'approve')]); + + await controller.loadActionOptions(force: true); + + expect(controller.hasActionOptions, isTrue); + expect(controller.actionOptions.length, 1); + + clearInteractions(repository); + + await controller.loadActionOptions(); + + verifyNever( + () => repository.listActions(activeOnly: any(named: 'activeOnly')), + ); + }); + + test('에러 발생 시 errorMessage 설정', () async { + when( + () => repository.listActions(activeOnly: any(named: 'activeOnly')), + ).thenThrow(Exception('actions fail')); + + await controller.loadActionOptions(force: true); + + expect(controller.errorMessage, isNotNull); + }); + }); + + group('loadTemplates', () { + test('템플릿 목록을 불러오고 캐시한다', () async { + when( + () => templateRepository.list(activeOnly: any(named: 'activeOnly')), + ).thenAnswer( + (_) async => [ + ApprovalTemplate(id: 1, code: 'TEMP', name: '기본 템플릿', isActive: true), + ], + ); + + await controller.loadTemplates(force: true); + + expect(controller.templates.length, 1); + expect(controller.isLoadingTemplates, isFalse); + + clearInteractions(templateRepository); + + await controller.loadTemplates(); + + verifyNever( + () => templateRepository.list(activeOnly: any(named: 'activeOnly')), + ); + }); + + test('에러 발생 시 errorMessage 설정', () async { + when( + () => templateRepository.list(activeOnly: any(named: 'activeOnly')), + ).thenThrow(Exception('template fail')); + + await controller.loadTemplates(force: true); + + expect(controller.errorMessage, isNotNull); + expect(controller.isLoadingTemplates, isFalse); + }); + }); + + group('performStepAction', () { + late ApprovalStep updatedStep; + late Approval updatedApproval; + + setUp(() { + when( + () => repository.listActions(activeOnly: any(named: 'activeOnly')), + ).thenAnswer( + (_) async => [ + ApprovalAction(id: 1, name: 'approve'), + ApprovalAction(id: 2, name: 'reject'), + ApprovalAction(id: 3, name: 'comment'), + ], + ); + + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + status: any(named: 'status'), + from: any(named: 'from'), + to: any(named: 'to'), + includeHistories: any(named: 'includeHistories'), + includeSteps: any(named: 'includeSteps'), + ), + ).thenAnswer((_) async => createResult([sampleApproval])); + + when( + () => repository.fetchDetail( + any(), + includeSteps: any(named: 'includeSteps'), + includeHistories: any(named: 'includeHistories'), + ), + ).thenAnswer((_) async => sampleApproval); + + updatedStep = ApprovalStep( + id: 11, + stepOrder: 1, + approver: sampleStep.approver, + status: ApprovalStatus(id: 2, name: '승인'), + assignedAt: sampleStep.assignedAt, + decidedAt: DateTime(2024, 4, 1, 9, 30), + note: '승인 완료', + ); + + updatedApproval = sampleApproval.copyWith( + status: ApprovalStatus(id: 2, name: '승인'), + currentStep: updatedStep, + steps: [updatedStep], + ); + }); + + test('성공 시 상세와 목록을 갱신한다', () async { + when( + () => repository.performStepAction(any()), + ).thenAnswer((_) async => updatedApproval); + + await controller.loadActionOptions(force: true); + await controller.fetch(); + await controller.selectApproval(sampleApproval.id!); + + final success = await controller.performStepAction( + step: sampleStep, + type: ApprovalStepActionType.approve, + ); + + expect(success, isTrue); + expect(controller.selected?.status.name, '승인'); + expect(controller.result?.items.first.status.name, '승인'); + expect(controller.isPerformingAction, isFalse); + verify(() => repository.performStepAction(any())).called(1); + }); + + test('예외 발생 시 errorMessage 설정', () async { + when( + () => repository.performStepAction(any()), + ).thenThrow(Exception('action fail')); + + await controller.loadActionOptions(force: true); + await controller.fetch(); + await controller.selectApproval(sampleApproval.id!); + + final success = await controller.performStepAction( + step: sampleStep, + type: ApprovalStepActionType.approve, + ); + + expect(success, isFalse); + expect(controller.errorMessage, isNotNull); + expect(controller.isPerformingAction, isFalse); + }); + + test('행위를 찾지 못하면 요청하지 않는다', () async { + when( + () => repository.listActions(activeOnly: any(named: 'activeOnly')), + ).thenAnswer((_) async => [ApprovalAction(id: 9, name: 'other')]); + + await controller.loadActionOptions(force: true); + await controller.fetch(); + await controller.selectApproval(sampleApproval.id!); + + final success = await controller.performStepAction( + step: sampleStep, + type: ApprovalStepActionType.approve, + ); + + expect(success, isFalse); + expect(controller.errorMessage, isNotNull); + verifyNever(() => repository.performStepAction(any())); + }); + }); + + group('applyTemplate', () { + late ApprovalTemplate template; + late Approval updatedApproval; + + setUp(() { + template = ApprovalTemplate( + id: 901, + code: 'TEMP-001', + name: '입고 1단계', + isActive: true, + steps: [ + ApprovalTemplateStep( + stepOrder: 1, + approver: ApprovalTemplateApprover( + id: 21, + employeeNo: 'E001', + name: '최승인', + ), + ), + ], + ); + + when( + () => templateRepository.fetchDetail( + any(), + includeSteps: any(named: 'includeSteps'), + ), + ).thenAnswer((_) async => template); + + when( + () => templateRepository.list(activeOnly: any(named: 'activeOnly')), + ).thenAnswer((_) async => [template]); + + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + status: any(named: 'status'), + from: any(named: 'from'), + to: any(named: 'to'), + includeHistories: any(named: 'includeHistories'), + includeSteps: any(named: 'includeSteps'), + ), + ).thenAnswer((_) async => createResult([sampleApproval])); + + when( + () => repository.fetchDetail( + any(), + includeSteps: any(named: 'includeSteps'), + includeHistories: any(named: 'includeHistories'), + ), + ).thenAnswer((_) async => sampleApproval); + + updatedApproval = sampleApproval.copyWith( + steps: [ + ApprovalStep( + id: 11, + stepOrder: 1, + approver: ApprovalApprover(id: 21, employeeNo: 'E001', name: '최승인'), + status: ApprovalStatus(id: 1, name: '대기'), + assignedAt: DateTime(2024, 4, 1, 9), + ), + ], + ); + }); + + test('성공 시 결재 상세를 갱신한다', () async { + when( + () => repository.assignSteps(any()), + ).thenAnswer((_) async => updatedApproval); + + await controller.loadTemplates(force: true); + await controller.fetch(); + await controller.selectApproval(sampleApproval.id!); + + final success = await controller.applyTemplate(template.id); + + expect(success, isTrue); + expect(controller.selected?.steps.length, 1); + verify(() => repository.assignSteps(any())).called(1); + }); + + test('템플릿 단계가 없으면 실패한다', () async { + when( + () => templateRepository.fetchDetail( + any(), + includeSteps: any(named: 'includeSteps'), + ), + ).thenAnswer( + (_) async => ApprovalTemplate( + id: template.id, + code: template.code, + name: template.name, + description: template.description, + isActive: template.isActive, + createdBy: template.createdBy, + createdAt: template.createdAt, + updatedAt: template.updatedAt, + steps: const [], + ), + ); + + await controller.selectApproval(sampleApproval.id!); + + final success = await controller.applyTemplate(template.id); + + expect(success, isFalse); + expect(controller.errorMessage, isNotNull); + }); + + test('예외 발생 시 errorMessage 설정', () async { + when( + () => templateRepository.fetchDetail( + any(), + includeSteps: any(named: 'includeSteps'), + ), + ).thenThrow(Exception('template detail fail')); + + await controller.selectApproval(sampleApproval.id!); + + final success = await controller.applyTemplate(template.id); + + expect(success, isFalse); + expect(controller.errorMessage, isNotNull); + }); + + test('선택된 결재가 없으면 적용하지 않는다', () async { + final success = await controller.applyTemplate(template.id); + + expect(success, isFalse); + expect(controller.errorMessage, isNotNull); + verifyNever(() => repository.assignSteps(any())); + }); + }); + + test('필터 초기화', () { + controller.updateQuery('abc'); + controller.updateStatusFilter(ApprovalStatusFilter.rejected); + controller.updateDateRange(DateTime(2024, 1, 1), DateTime(2024, 1, 31)); + + controller.clearFilters(); + + expect(controller.query, isEmpty); + expect(controller.statusFilter, ApprovalStatusFilter.all); + expect(controller.fromDate, isNull); + expect(controller.toDate, isNull); + }); +} diff --git a/test/features/approvals/presentation/pages/approval_page_test.dart b/test/features/approvals/presentation/pages/approval_page_test.dart new file mode 100644 index 0000000..965b866 --- /dev/null +++ b/test/features/approvals/presentation/pages/approval_page_test.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import 'package:superport_v2/features/approvals/domain/entities/approval.dart'; +import 'package:superport_v2/features/approvals/domain/repositories/approval_repository.dart'; +import 'package:superport_v2/features/approvals/presentation/pages/approval_page.dart'; + +class _MockApprovalRepository extends Mock implements ApprovalRepository {} + +class _FakeApprovalInput extends Fake implements ApprovalInput {} + +Widget _buildApp(Widget child) { + return MaterialApp( + home: ShadTheme( + data: ShadThemeData( + colorScheme: const ShadSlateColorScheme.light(), + brightness: Brightness.light, + ), + child: Scaffold(body: child), + ), + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() { + registerFallbackValue(_FakeApprovalInput()); + }); + + tearDown(() async { + await GetIt.I.reset(); + dotenv.clean(); + }); + + testWidgets('플래그 Off 시 스펙 화면', (tester) async { + dotenv.testLoad(fileInput: 'FEATURE_APPROVALS_ENABLED=false\n'); + + await tester.pumpWidget(_buildApp(const ApprovalPage())); + await tester.pump(); + + expect(find.text('결재 관리'), findsOneWidget); + expect(find.text('비활성화 (백엔드 준비 중)'), findsOneWidget); + }); + + group('플래그 On', () { + late _MockApprovalRepository repository; + + setUp(() { + dotenv.testLoad(fileInput: 'FEATURE_APPROVALS_ENABLED=true\n'); + repository = _MockApprovalRepository(); + GetIt.I.registerLazySingleton(() => repository); + }); + + }); +} diff --git a/test/features/masters/group/presentation/controllers/group_controller_test.dart b/test/features/masters/group/presentation/controllers/group_controller_test.dart new file mode 100644 index 0000000..3a5ce2e --- /dev/null +++ b/test/features/masters/group/presentation/controllers/group_controller_test.dart @@ -0,0 +1,193 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/features/masters/group/domain/entities/group.dart'; +import 'package:superport_v2/features/masters/group/domain/repositories/group_repository.dart'; +import 'package:superport_v2/features/masters/group/presentation/controllers/group_controller.dart'; + +class _MockGroupRepository extends Mock implements GroupRepository {} + +class _FakeGroupInput extends Fake implements GroupInput {} + +void main() { + late GroupController controller; + late _MockGroupRepository repository; + + final sampleGroup = Group( + id: 1, + groupName: '관리자', + description: '전체 권한', + isDefault: true, + isActive: true, + isDeleted: false, + ); + + PaginatedResult createResult({List? items}) { + final list = items ?? [sampleGroup]; + return PaginatedResult( + items: list, + page: 1, + pageSize: 20, + total: list.length, + ); + } + + setUpAll(() { + registerFallbackValue(_FakeGroupInput()); + }); + + setUp(() { + repository = _MockGroupRepository(); + controller = GroupController(repository: repository); + }); + + group('fetch', () { + test('정상 조회 시 결과가 갱신된다', () async { + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + isDefault: any(named: 'isDefault'), + isActive: any(named: 'isActive'), + ), + ).thenAnswer((_) async => createResult()); + + await controller.fetch(); + + expect(controller.isLoading, isFalse); + expect(controller.errorMessage, isNull); + expect(controller.result?.items, isNotEmpty); + verify( + () => repository.list( + page: 1, + pageSize: 20, + query: null, + isDefault: null, + isActive: null, + ), + ).called(1); + }); + + test('에러 발생 시 errorMessage에 저장한다', () async { + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + isDefault: any(named: 'isDefault'), + isActive: any(named: 'isActive'), + ), + ).thenThrow(Exception('fail')); + + await controller.fetch(); + + expect(controller.isLoading, isFalse); + expect(controller.errorMessage, isNotNull); + }); + + test('필터 상태를 파라미터에 반영한다', () async { + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + isDefault: any(named: 'isDefault'), + isActive: any(named: 'isActive'), + ), + ).thenAnswer((_) async => createResult()); + + controller.updateQuery('admin'); + controller.updateDefaultFilter(GroupDefaultFilter.defaultOnly); + controller.updateStatusFilter(GroupStatusFilter.inactiveOnly); + + await controller.fetch(); + + verify( + () => repository.list( + page: 1, + pageSize: 20, + query: 'admin', + isDefault: true, + isActive: false, + ), + ).called(1); + }); + }); + + test('필터 업데이트 메서드', () { + controller.updateQuery('abc'); + controller.updateDefaultFilter(GroupDefaultFilter.nonDefault); + controller.updateStatusFilter(GroupStatusFilter.activeOnly); + + expect(controller.query, 'abc'); + expect(controller.defaultFilter, GroupDefaultFilter.nonDefault); + expect(controller.statusFilter, GroupStatusFilter.activeOnly); + }); + + group('mutations', () { + setUp(() { + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + isDefault: any(named: 'isDefault'), + isActive: any(named: 'isActive'), + ), + ).thenAnswer((_) async => createResult()); + }); + + final input = GroupInput(groupName: '관리자'); + + test('create 성공 시 목록 재조회', () async { + when(() => repository.create(any())).thenAnswer((_) async => sampleGroup); + + final created = await controller.create(input); + + expect(created, isNotNull); + verify(() => repository.create(any())).called(1); + verify( + () => repository.list( + page: 1, + pageSize: 20, + query: null, + isDefault: null, + isActive: null, + ), + ).called(greaterThanOrEqualTo(1)); + }); + + test('update 성공 시 현재 페이지 유지', () async { + when( + () => repository.update(any(), any()), + ).thenAnswer((_) async => sampleGroup); + + final updated = await controller.update(1, input); + + expect(updated, isNotNull); + verify(() => repository.update(1, any())).called(1); + }); + + test('delete 성공 시 true 반환', () async { + when(() => repository.delete(any())).thenAnswer((_) async {}); + + final deleted = await controller.delete(1); + + expect(deleted, isTrue); + verify(() => repository.delete(1)).called(1); + }); + + test('restore 성공 시 그룹 반환', () async { + when( + () => repository.restore(any()), + ).thenAnswer((_) async => sampleGroup); + + final restored = await controller.restore(1); + + expect(restored, isNotNull); + verify(() => repository.restore(1)).called(1); + }); + }); +} diff --git a/test/features/masters/group/presentation/pages/group_page_test.dart b/test/features/masters/group/presentation/pages/group_page_test.dart new file mode 100644 index 0000000..2ce9fcc --- /dev/null +++ b/test/features/masters/group/presentation/pages/group_page_test.dart @@ -0,0 +1,192 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/features/masters/group/domain/entities/group.dart'; +import 'package:superport_v2/features/masters/group/domain/repositories/group_repository.dart'; +import 'package:superport_v2/features/masters/group/presentation/pages/group_page.dart'; + +class _MockGroupRepository extends Mock implements GroupRepository {} + +class _FakeGroupInput extends Fake implements GroupInput {} + +Widget _buildApp(Widget child) { + return MaterialApp( + home: ShadTheme( + data: ShadThemeData( + colorScheme: const ShadSlateColorScheme.light(), + brightness: Brightness.light, + ), + child: Scaffold(body: child), + ), + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() { + registerFallbackValue(_FakeGroupInput()); + }); + + tearDown(() async { + await GetIt.I.reset(); + dotenv.clean(); + }); + + testWidgets('플래그 Off 시 스펙 페이지 표시', (tester) async { + dotenv.testLoad(fileInput: 'FEATURE_GROUPS_ENABLED=false\n'); + + await tester.pumpWidget(_buildApp(const GroupPage())); + await tester.pump(); + + expect(find.text('그룹 관리'), findsOneWidget); + expect(find.text('비활성화 (백엔드 준비 중)'), findsOneWidget); + }); + + group('플래그 On', () { + late _MockGroupRepository repository; + + setUp(() { + dotenv.testLoad(fileInput: 'FEATURE_GROUPS_ENABLED=true\n'); + repository = _MockGroupRepository(); + GetIt.I.registerLazySingleton(() => repository); + }); + + testWidgets('목록을 조회하여 표에 렌더링한다', (tester) async { + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + isDefault: any(named: 'isDefault'), + isActive: any(named: 'isActive'), + ), + ).thenAnswer( + (_) async => PaginatedResult( + items: [ + Group( + id: 1, + groupName: '관리자', + description: '전체 권한', + isDefault: true, + ), + ], + page: 1, + pageSize: 20, + total: 1, + ), + ); + + await tester.pumpWidget(_buildApp(const GroupPage())); + await tester.pumpAndSettle(); + + expect(find.text('관리자'), findsOneWidget); + verify( + () => repository.list( + page: 1, + pageSize: 20, + query: null, + isDefault: null, + isActive: null, + ), + ).called(1); + }); + + testWidgets('신규 등록 폼 검증 오류를 노출한다', (tester) async { + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + isDefault: any(named: 'isDefault'), + isActive: any(named: 'isActive'), + ), + ).thenAnswer( + (_) async => PaginatedResult( + items: const [], + page: 1, + pageSize: 20, + total: 0, + ), + ); + + await tester.pumpWidget(_buildApp(const GroupPage())); + await tester.pumpAndSettle(); + + await tester.tap(find.text('신규 등록')); + await tester.pumpAndSettle(); + await tester.tap(find.text('등록')); + await tester.pump(); + + expect(find.text('그룹명을 입력하세요.'), findsOneWidget); + }); + + testWidgets('신규 등록 성공 시 목록이 갱신된다', (tester) async { + var listCallCount = 0; + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + isDefault: any(named: 'isDefault'), + isActive: any(named: 'isActive'), + ), + ).thenAnswer((_) async { + listCallCount += 1; + if (listCallCount == 1) { + return PaginatedResult( + items: const [], + page: 1, + pageSize: 20, + total: 0, + ); + } + return PaginatedResult( + items: [Group(id: 2, groupName: '운영팀', description: '운영 담당')], + page: 1, + pageSize: 20, + total: 1, + ); + }); + + GroupInput? capturedInput; + when(() => repository.create(any())).thenAnswer((invocation) async { + capturedInput = invocation.positionalArguments.first as GroupInput; + return Group( + id: 2, + groupName: capturedInput!.groupName, + description: capturedInput!.description, + ); + }); + + await tester.pumpWidget(_buildApp(const GroupPage())); + await tester.pumpAndSettle(); + + await tester.tap(find.text('신규 등록')); + await tester.pumpAndSettle(); + + final dialog = find.byType(Dialog); + final editableTexts = find.descendant( + of: dialog, + matching: find.byType(EditableText), + ); + + await tester.enterText(editableTexts.at(0), '운영팀'); + await tester.enterText(editableTexts.at(1), '운영 담당'); + + await tester.tap(find.text('등록')); + await tester.pumpAndSettle(); + + expect(capturedInput, isNotNull); + expect(capturedInput?.groupName, '운영팀'); + expect(find.byType(Dialog), findsNothing); + expect(find.text('운영팀'), findsOneWidget); + verify(() => repository.create(any())).called(1); + }); + }); +} diff --git a/test/features/masters/group_permission/presentation/controllers/group_permission_controller_test.dart b/test/features/masters/group_permission/presentation/controllers/group_permission_controller_test.dart new file mode 100644 index 0000000..6324e84 --- /dev/null +++ b/test/features/masters/group_permission/presentation/controllers/group_permission_controller_test.dart @@ -0,0 +1,240 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/features/masters/group/domain/entities/group.dart'; +import 'package:superport_v2/features/masters/group/domain/repositories/group_repository.dart'; +import 'package:superport_v2/features/masters/group_permission/domain/entities/group_permission.dart'; +import 'package:superport_v2/features/masters/group_permission/domain/repositories/group_permission_repository.dart'; +import 'package:superport_v2/features/masters/group_permission/presentation/controllers/group_permission_controller.dart'; +import 'package:superport_v2/features/masters/menu/domain/entities/menu.dart'; +import 'package:superport_v2/features/masters/menu/domain/repositories/menu_repository.dart'; + +class _MockPermissionRepository extends Mock + implements GroupPermissionRepository {} + +class _MockGroupRepository extends Mock implements GroupRepository {} + +class _MockMenuRepository extends Mock implements MenuRepository {} + +class _FakeGroupPermissionInput extends Fake implements GroupPermissionInput {} + +void main() { + late GroupPermissionController controller; + late _MockPermissionRepository permissionRepository; + late _MockGroupRepository groupRepository; + late _MockMenuRepository menuRepository; + + final samplePermission = GroupPermission( + id: 1, + group: GroupPermissionGroup(id: 1, groupName: '관리자'), + menu: GroupPermissionMenu(id: 10, menuName: '대시보드'), + canCreate: true, + canRead: true, + canUpdate: false, + canDelete: false, + ); + + PaginatedResult createResult(List items) { + return PaginatedResult( + items: items, + page: 1, + pageSize: 20, + total: items.length, + ); + } + + setUpAll(() { + registerFallbackValue(_FakeGroupPermissionInput()); + }); + + setUp(() { + permissionRepository = _MockPermissionRepository(); + groupRepository = _MockGroupRepository(); + menuRepository = _MockMenuRepository(); + controller = GroupPermissionController( + permissionRepository: permissionRepository, + groupRepository: groupRepository, + menuRepository: menuRepository, + ); + }); + + group('loadGroups/loadMenus', () { + test('그룹 목록을 로드한다', () async { + when( + () => groupRepository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + isDefault: any(named: 'isDefault'), + isActive: any(named: 'isActive'), + ), + ).thenAnswer( + (_) async => PaginatedResult( + items: [Group(id: 1, groupName: '관리자')], + page: 1, + pageSize: 20, + total: 1, + ), + ); + + await controller.loadGroups(); + + expect(controller.groups, isNotEmpty); + }); + + test('메뉴 목록을 로드한다', () async { + when( + () => menuRepository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + parentId: any(named: 'parentId'), + isActive: any(named: 'isActive'), + includeDeleted: any(named: 'includeDeleted'), + ), + ).thenAnswer( + (_) async => PaginatedResult( + items: [MenuItem(id: 10, menuCode: 'MENU001', menuName: '대시보드')], + page: 1, + pageSize: 20, + total: 1, + ), + ); + + await controller.loadMenus(); + + expect(controller.menus, isNotEmpty); + }); + }); + + group('fetch', () { + setUp(() { + when( + () => permissionRepository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + groupId: any(named: 'groupId'), + menuId: any(named: 'menuId'), + isActive: any(named: 'isActive'), + includeDeleted: any(named: 'includeDeleted'), + ), + ).thenAnswer((_) async => createResult([samplePermission])); + }); + + test('정상 조회 시 데이터를 보관한다', () async { + await controller.fetch(); + + expect(controller.result?.items, isNotEmpty); + expect(controller.errorMessage, isNull); + }); + + test('필터 값을 전달한다', () async { + controller.updateGroupFilter(2); + controller.updateMenuFilter(5); + controller.updateStatusFilter(GroupPermissionStatusFilter.inactiveOnly); + controller.updateIncludeDeleted(true); + + await controller.fetch(page: 3); + + verify( + () => permissionRepository.list( + page: 3, + pageSize: 20, + groupId: 2, + menuId: 5, + isActive: false, + includeDeleted: true, + ), + ).called(1); + }); + + test('에러 발생 시 errorMessage에 저장', () async { + when( + () => permissionRepository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + groupId: any(named: 'groupId'), + menuId: any(named: 'menuId'), + isActive: any(named: 'isActive'), + includeDeleted: any(named: 'includeDeleted'), + ), + ).thenThrow(Exception('fail')); + + await controller.fetch(); + + expect(controller.errorMessage, isNotNull); + }); + }); + + test('필터 업데이트 메서드', () { + controller.updateGroupFilter(3); + controller.updateMenuFilter(7); + controller.updateStatusFilter(GroupPermissionStatusFilter.activeOnly); + controller.updateIncludeDeleted(true); + + expect(controller.groupFilter, 3); + expect(controller.menuFilter, 7); + expect(controller.statusFilter, GroupPermissionStatusFilter.activeOnly); + expect(controller.includeDeleted, isTrue); + }); + + group('mutations', () { + setUp(() { + when( + () => permissionRepository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + groupId: any(named: 'groupId'), + menuId: any(named: 'menuId'), + isActive: any(named: 'isActive'), + includeDeleted: any(named: 'includeDeleted'), + ), + ).thenAnswer((_) async => createResult([samplePermission])); + }); + + final input = GroupPermissionInput(groupId: 1, menuId: 2); + + test('create 성공', () async { + when( + () => permissionRepository.create(any()), + ).thenAnswer((_) async => samplePermission); + + final created = await controller.create(input); + + expect(created, isNotNull); + verify(() => permissionRepository.create(any())).called(1); + }); + + test('update 성공', () async { + when( + () => permissionRepository.update(any(), any()), + ).thenAnswer((_) async => samplePermission); + + final updated = await controller.update(1, input); + + expect(updated, isNotNull); + verify(() => permissionRepository.update(1, any())).called(1); + }); + + test('delete 성공', () async { + when(() => permissionRepository.delete(any())).thenAnswer((_) async {}); + + final result = await controller.delete(1); + + expect(result, isTrue); + verify(() => permissionRepository.delete(1)).called(1); + }); + + test('restore 성공', () async { + when( + () => permissionRepository.restore(any()), + ).thenAnswer((_) async => samplePermission); + + final restored = await controller.restore(1); + + expect(restored, isNotNull); + verify(() => permissionRepository.restore(1)).called(1); + }); + }); +} diff --git a/test/features/masters/group_permission/presentation/pages/group_permission_page_test.dart b/test/features/masters/group_permission/presentation/pages/group_permission_page_test.dart new file mode 100644 index 0000000..36fc5b2 --- /dev/null +++ b/test/features/masters/group_permission/presentation/pages/group_permission_page_test.dart @@ -0,0 +1,285 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/features/masters/group/domain/entities/group.dart'; +import 'package:superport_v2/features/masters/group/domain/repositories/group_repository.dart'; +import 'package:superport_v2/features/masters/group_permission/domain/entities/group_permission.dart'; +import 'package:superport_v2/features/masters/group_permission/domain/repositories/group_permission_repository.dart'; +import 'package:superport_v2/features/masters/group_permission/presentation/pages/group_permission_page.dart'; +import 'package:superport_v2/features/masters/menu/domain/entities/menu.dart'; +import 'package:superport_v2/features/masters/menu/domain/repositories/menu_repository.dart'; + +class _MockPermissionRepository extends Mock + implements GroupPermissionRepository {} + +class _MockGroupRepository extends Mock implements GroupRepository {} + +class _MockMenuRepository extends Mock implements MenuRepository {} + +class _FakeGroupPermissionInput extends Fake implements GroupPermissionInput {} + +Widget _buildApp(Widget child) { + return MaterialApp( + home: ShadTheme( + data: ShadThemeData( + colorScheme: const ShadSlateColorScheme.light(), + brightness: Brightness.light, + ), + child: Scaffold(body: child), + ), + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() { + registerFallbackValue(_FakeGroupPermissionInput()); + }); + + tearDown(() async { + await GetIt.I.reset(); + dotenv.clean(); + }); + + testWidgets('플래그 Off 시 스펙 화면 표시', (tester) async { + dotenv.testLoad(fileInput: 'FEATURE_GROUP_PERMISSIONS_ENABLED=false\n'); + + await tester.pumpWidget(_buildApp(const GroupPermissionPage())); + await tester.pump(); + + expect(find.text('그룹 권한 관리'), findsOneWidget); + expect(find.text('비활성화 (백엔드 준비 중)'), findsOneWidget); + }); + + group('플래그 On', () { + late _MockPermissionRepository permissionRepository; + late _MockGroupRepository groupRepository; + late _MockMenuRepository menuRepository; + + setUp(() { + dotenv.testLoad(fileInput: 'FEATURE_GROUP_PERMISSIONS_ENABLED=true\n'); + permissionRepository = _MockPermissionRepository(); + groupRepository = _MockGroupRepository(); + menuRepository = _MockMenuRepository(); + GetIt.I.registerLazySingleton( + () => permissionRepository, + ); + GetIt.I.registerLazySingleton(() => groupRepository); + GetIt.I.registerLazySingleton(() => menuRepository); + + when( + () => groupRepository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + isDefault: any(named: 'isDefault'), + isActive: any(named: 'isActive'), + ), + ).thenAnswer( + (_) async => PaginatedResult( + items: [Group(id: 1, groupName: '관리자')], + page: 1, + pageSize: 20, + total: 1, + ), + ); + + when( + () => menuRepository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + parentId: any(named: 'parentId'), + isActive: any(named: 'isActive'), + includeDeleted: any(named: 'includeDeleted'), + ), + ).thenAnswer( + (_) async => PaginatedResult( + items: [MenuItem(id: 10, menuCode: 'MENU001', menuName: '대시보드')], + page: 1, + pageSize: 20, + total: 1, + ), + ); + }); + + testWidgets('목록을 조회해 테이블에 렌더링한다', (tester) async { + when( + () => permissionRepository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + groupId: any(named: 'groupId'), + menuId: any(named: 'menuId'), + isActive: any(named: 'isActive'), + includeDeleted: any(named: 'includeDeleted'), + ), + ).thenAnswer( + (_) async => PaginatedResult( + items: [ + GroupPermission( + id: 1, + group: GroupPermissionGroup(id: 1, groupName: '관리자'), + menu: GroupPermissionMenu(id: 10, menuName: '대시보드'), + canCreate: true, + canRead: true, + ), + ], + page: 1, + pageSize: 20, + total: 1, + ), + ); + + await tester.pumpWidget(_buildApp(const GroupPermissionPage())); + await tester.pumpAndSettle(); + + expect(find.text('대시보드'), findsOneWidget); + expect(find.text('관리자'), findsOneWidget); + }); + + testWidgets('신규 등록 폼 검증 메시지를 표시한다', (tester) async { + when( + () => permissionRepository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + groupId: any(named: 'groupId'), + menuId: any(named: 'menuId'), + isActive: any(named: 'isActive'), + includeDeleted: any(named: 'includeDeleted'), + ), + ).thenAnswer( + (_) async => PaginatedResult( + items: const [], + page: 1, + pageSize: 20, + total: 0, + ), + ); + + await tester.pumpWidget(_buildApp(const GroupPermissionPage())); + await tester.pumpAndSettle(); + + await tester.tap(find.text('신규 등록')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('등록')); + await tester.pump(); + + expect(find.text('그룹을 선택하세요.'), findsOneWidget); + expect(find.text('메뉴를 선택하세요.'), findsOneWidget); + }); + + testWidgets('신규 등록 성공 시 repository.create 호출', (tester) async { + var listCall = 0; + when( + () => permissionRepository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + groupId: any(named: 'groupId'), + menuId: any(named: 'menuId'), + isActive: any(named: 'isActive'), + includeDeleted: any(named: 'includeDeleted'), + ), + ).thenAnswer((_) async { + listCall += 1; + if (listCall == 1) { + return PaginatedResult( + items: const [], + page: 1, + pageSize: 20, + total: 0, + ); + } + return PaginatedResult( + items: [ + GroupPermission( + id: 5, + group: GroupPermissionGroup(id: 1, groupName: '관리자'), + menu: GroupPermissionMenu(id: 10, menuName: '대시보드'), + canCreate: true, + canRead: true, + ), + ], + page: 1, + pageSize: 20, + total: 1, + ); + }); + + GroupPermissionInput? capturedInput; + when(() => permissionRepository.create(any())).thenAnswer(( + invocation, + ) async { + capturedInput = + invocation.positionalArguments.first as GroupPermissionInput; + return GroupPermission( + id: 5, + group: GroupPermissionGroup( + id: capturedInput!.groupId, + groupName: '관리자', + ), + menu: GroupPermissionMenu( + id: capturedInput!.menuId, + menuName: '대시보드', + ), + canCreate: capturedInput!.canCreate, + canRead: capturedInput!.canRead, + ); + }); + + await tester.pumpWidget(_buildApp(const GroupPermissionPage())); + await tester.pumpAndSettle(); + + await tester.tap(find.text('신규 등록')); + await tester.pumpAndSettle(); + + final dialog = find.byType(Dialog); + final selects = find.descendant( + of: dialog, + matching: find.byType(ShadSelect), + ); + + // 그룹 선택 + await tester.tap(selects.at(0)); + await tester.pumpAndSettle(); + await tester.tap(find.text('관리자').last); + await tester.pumpAndSettle(); + + // 메뉴 선택 + await tester.tap(selects.at(1)); + await tester.pumpAndSettle(); + await tester.tap(find.text('대시보드').last); + await tester.pumpAndSettle(); + + // 권한 체크 (생성, 수정, 삭제 on) + final switches = find.descendant( + of: dialog, + matching: find.byType(ShadSwitch), + ); + await tester.tap(switches.at(0)); + await tester.pump(); + await tester.tap(switches.at(2)); + await tester.pump(); + await tester.tap(switches.at(3)); + await tester.pump(); + + await tester.tap(find.text('등록')); + await tester.pumpAndSettle(); + + expect(capturedInput, isNotNull); + expect(capturedInput?.groupId, 1); + expect(capturedInput?.menuId, 10); + expect(capturedInput?.canCreate, isTrue); + expect(capturedInput?.canUpdate, isTrue); + expect(find.byType(Dialog), findsNothing); + expect(find.text('대시보드'), findsOneWidget); + verify(() => permissionRepository.create(any())).called(1); + }); + }); +} diff --git a/test/features/masters/menu/presentation/controllers/menu_controller_test.dart b/test/features/masters/menu/presentation/controllers/menu_controller_test.dart new file mode 100644 index 0000000..1b37c5e --- /dev/null +++ b/test/features/masters/menu/presentation/controllers/menu_controller_test.dart @@ -0,0 +1,207 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/features/masters/menu/domain/entities/menu.dart'; +import 'package:superport_v2/features/masters/menu/domain/repositories/menu_repository.dart'; +import 'package:superport_v2/features/masters/menu/presentation/controllers/menu_controller.dart'; + +class _MockMenuRepository extends Mock implements MenuRepository {} + +class _FakeMenuInput extends Fake implements MenuInput {} + +void main() { + late MenuController controller; + late _MockMenuRepository repository; + + final sampleMenu = MenuItem( + id: 1, + menuCode: 'MENU001', + menuName: '대시보드', + isActive: true, + isDeleted: false, + ); + + PaginatedResult createResult({List? items}) { + final list = items ?? [sampleMenu]; + return PaginatedResult( + items: list, + page: 1, + pageSize: 20, + total: list.length, + ); + } + + setUpAll(() { + registerFallbackValue(_FakeMenuInput()); + }); + + setUp(() { + repository = _MockMenuRepository(); + controller = MenuController(repository: repository); + }); + + group('loadParents', () { + test('상위 메뉴를 로드한다', () async { + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + parentId: any(named: 'parentId'), + isActive: any(named: 'isActive'), + includeDeleted: any(named: 'includeDeleted'), + ), + ).thenAnswer((_) async => createResult()); + + await controller.loadParents(); + + expect(controller.parents, isNotEmpty); + verify( + () => repository.list( + page: 1, + pageSize: 200, + query: null, + parentId: null, + isActive: null, + includeDeleted: false, + ), + ).called(1); + }); + }); + + group('fetch', () { + test('정상 조회 시 결과 저장', () async { + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + parentId: any(named: 'parentId'), + isActive: any(named: 'isActive'), + includeDeleted: any(named: 'includeDeleted'), + ), + ).thenAnswer((_) async => createResult()); + + await controller.fetch(); + + expect(controller.result?.items, isNotEmpty); + expect(controller.errorMessage, isNull); + }); + + test('필터 조건 적용', () async { + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + parentId: any(named: 'parentId'), + isActive: any(named: 'isActive'), + includeDeleted: any(named: 'includeDeleted'), + ), + ).thenAnswer((_) async => createResult()); + + controller.updateQuery('dash'); + controller.updateParentFilter(10); + controller.updateStatusFilter(MenuStatusFilter.activeOnly); + controller.updateIncludeDeleted(true); + + await controller.fetch(page: 2); + + verify( + () => repository.list( + page: 2, + pageSize: 20, + query: 'dash', + parentId: 10, + isActive: true, + includeDeleted: true, + ), + ).called(1); + }); + + test('에러 시 errorMessage 세팅', () async { + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + parentId: any(named: 'parentId'), + isActive: any(named: 'isActive'), + includeDeleted: any(named: 'includeDeleted'), + ), + ).thenThrow(Exception('fail')); + + await controller.fetch(); + + expect(controller.errorMessage, isNotNull); + }); + }); + + test('필터 업데이트', () { + controller.updateQuery('code'); + controller.updateParentFilter(1); + controller.updateStatusFilter(MenuStatusFilter.inactiveOnly); + controller.updateIncludeDeleted(true); + + expect(controller.query, 'code'); + expect(controller.parentFilter, 1); + expect(controller.statusFilter, MenuStatusFilter.inactiveOnly); + expect(controller.includeDeleted, isTrue); + }); + + group('mutations', () { + setUp(() { + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + parentId: any(named: 'parentId'), + isActive: any(named: 'isActive'), + includeDeleted: any(named: 'includeDeleted'), + ), + ).thenAnswer((_) async => createResult()); + }); + + final input = MenuInput(menuCode: 'MENU001', menuName: '대시보드'); + + test('create 성공', () async { + when(() => repository.create(any())).thenAnswer((_) async => sampleMenu); + + final created = await controller.create(input); + + expect(created, isNotNull); + verify(() => repository.create(any())).called(1); + }); + + test('update 성공', () async { + when( + () => repository.update(any(), any()), + ).thenAnswer((_) async => sampleMenu); + + final updated = await controller.update(1, input); + + expect(updated, isNotNull); + verify(() => repository.update(1, any())).called(1); + }); + + test('delete 성공', () async { + when(() => repository.delete(any())).thenAnswer((_) async {}); + + final success = await controller.delete(1); + + expect(success, isTrue); + verify(() => repository.delete(1)).called(1); + }); + + test('restore 성공', () async { + when(() => repository.restore(any())).thenAnswer((_) async => sampleMenu); + + final restored = await controller.restore(1); + + expect(restored, isNotNull); + verify(() => repository.restore(1)).called(1); + }); + }); +} diff --git a/test/features/masters/menu/presentation/pages/menu_page_test.dart b/test/features/masters/menu/presentation/pages/menu_page_test.dart new file mode 100644 index 0000000..d621367 --- /dev/null +++ b/test/features/masters/menu/presentation/pages/menu_page_test.dart @@ -0,0 +1,181 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/features/masters/menu/domain/entities/menu.dart'; +import 'package:superport_v2/features/masters/menu/domain/repositories/menu_repository.dart'; +import 'package:superport_v2/features/masters/menu/presentation/pages/menu_page.dart'; + +class _MockMenuRepository extends Mock implements MenuRepository {} + +class _FakeMenuInput extends Fake implements MenuInput {} + +Widget _buildApp(Widget child) { + return MaterialApp( + home: ShadTheme( + data: ShadThemeData( + colorScheme: const ShadSlateColorScheme.light(), + brightness: Brightness.light, + ), + child: Scaffold(body: child), + ), + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() { + registerFallbackValue(_FakeMenuInput()); + }); + + tearDown(() async { + await GetIt.I.reset(); + dotenv.clean(); + }); + + testWidgets('플래그 Off 시 스펙 화면 노출', (tester) async { + dotenv.testLoad(fileInput: 'FEATURE_MENUS_ENABLED=false\n'); + + await tester.pumpWidget(_buildApp(const MenuPage())); + await tester.pump(); + + expect(find.text('메뉴 관리'), findsOneWidget); + expect(find.text('비활성화 (백엔드 준비 중)'), findsOneWidget); + }); + + group('플래그 On', () { + late _MockMenuRepository repository; + + setUp(() { + dotenv.testLoad(fileInput: 'FEATURE_MENUS_ENABLED=true\n'); + repository = _MockMenuRepository(); + GetIt.I.registerLazySingleton(() => repository); + }); + + testWidgets('목록을 조회해 테이블을 렌더링한다', (tester) async { + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + parentId: any(named: 'parentId'), + isActive: any(named: 'isActive'), + includeDeleted: any(named: 'includeDeleted'), + ), + ).thenAnswer( + (_) async => PaginatedResult( + items: [MenuItem(id: 1, menuCode: 'MENU001', menuName: '대시보드')], + page: 1, + pageSize: 20, + total: 1, + ), + ); + + await tester.pumpWidget(_buildApp(const MenuPage())); + await tester.pumpAndSettle(); + + expect(find.text('MENU001'), findsOneWidget); + }); + + testWidgets('신규 등록 폼 검증 에러 표시', (tester) async { + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + parentId: any(named: 'parentId'), + isActive: any(named: 'isActive'), + includeDeleted: any(named: 'includeDeleted'), + ), + ).thenAnswer( + (_) async => PaginatedResult( + items: const [], + page: 1, + pageSize: 20, + total: 0, + ), + ); + + await tester.pumpWidget(_buildApp(const MenuPage())); + await tester.pumpAndSettle(); + + await tester.tap(find.text('신규 등록')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('등록')); + await tester.pump(); + + expect(find.text('메뉴코드를 입력하세요.'), findsOneWidget); + expect(find.text('메뉴명을 입력하세요.'), findsOneWidget); + }); + + testWidgets('신규 등록 성공 시 repository.create 호출', (tester) async { + var listCall = 0; + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + parentId: any(named: 'parentId'), + isActive: any(named: 'isActive'), + includeDeleted: any(named: 'includeDeleted'), + ), + ).thenAnswer((_) async { + listCall += 1; + if (listCall == 1) { + return PaginatedResult( + items: const [], + page: 1, + pageSize: 20, + total: 0, + ); + } + return PaginatedResult( + items: [MenuItem(id: 10, menuCode: 'MENU010', menuName: '신규 메뉴')], + page: 1, + pageSize: 20, + total: 1, + ); + }); + + MenuInput? capturedInput; + when(() => repository.create(any())).thenAnswer((invocation) async { + capturedInput = invocation.positionalArguments.first as MenuInput; + return MenuItem( + id: 10, + menuCode: capturedInput!.menuCode, + menuName: capturedInput!.menuName, + ); + }); + + await tester.pumpWidget(_buildApp(const MenuPage())); + await tester.pumpAndSettle(); + + await tester.tap(find.text('신규 등록')); + await tester.pumpAndSettle(); + + final dialog = find.byType(Dialog); + final editableTexts = find.descendant( + of: dialog, + matching: find.byType(EditableText), + ); + + await tester.enterText(editableTexts.at(0), 'MENU010'); + await tester.enterText(editableTexts.at(1), '신규 메뉴'); + + await tester.tap(find.text('등록')); + await tester.pumpAndSettle(); + + expect(capturedInput, isNotNull); + expect(capturedInput?.menuCode, 'MENU010'); + expect(find.byType(Dialog), findsNothing); + expect(find.text('MENU010'), findsOneWidget); + verify(() => repository.create(any())).called(1); + }); + }); +} diff --git a/test/features/masters/user/presentation/controllers/user_controller_test.dart b/test/features/masters/user/presentation/controllers/user_controller_test.dart index 8002700..07d27b5 100644 --- a/test/features/masters/user/presentation/controllers/user_controller_test.dart +++ b/test/features/masters/user/presentation/controllers/user_controller_test.dart @@ -56,6 +56,7 @@ void main() { page: any(named: 'page'), pageSize: any(named: 'pageSize'), query: any(named: 'query'), + isDefault: any(named: 'isDefault'), isActive: any(named: 'isActive'), ), ).thenAnswer( @@ -79,6 +80,7 @@ void main() { page: any(named: 'page'), pageSize: any(named: 'pageSize'), query: any(named: 'query'), + isDefault: any(named: 'isDefault'), isActive: any(named: 'isActive'), ), ).thenAnswer( diff --git a/test/features/masters/user/presentation/pages/user_page_test.dart b/test/features/masters/user/presentation/pages/user_page_test.dart index c5f727a..7a89280 100644 --- a/test/features/masters/user/presentation/pages/user_page_test.dart +++ b/test/features/masters/user/presentation/pages/user_page_test.dart @@ -68,6 +68,7 @@ void main() { page: any(named: 'page'), pageSize: any(named: 'pageSize'), query: any(named: 'query'), + isDefault: any(named: 'isDefault'), isActive: any(named: 'isActive'), ), ).thenAnswer(