결재 권한 테스트 및 인벤토리 위젯 안정화
This commit is contained in:
@@ -11,45 +11,45 @@
|
|||||||
|
|
||||||
## 1) 공통 컴포넌트/레이아웃(UI)
|
## 1) 공통 컴포넌트/레이아웃(UI)
|
||||||
- [x] AppLayout(좌 사이드바/상단 헤더/본문) 적용, 브레드크럼·타이틀·툴바 영역 정리 (현황: 입·출·대여와 전 마스터 실화면에 `AppLayout`/브레드크럼/툴바 적용 완료, 결재/보고서 등 잔여 SpecPage 전환만 남음)
|
- [x] AppLayout(좌 사이드바/상단 헤더/본문) 적용, 브레드크럼·타이틀·툴바 영역 정리 (현황: 입·출·대여와 전 마스터 실화면에 `AppLayout`/브레드크럼/툴바 적용 완료, 결재/보고서 등 잔여 SpecPage 전환만 남음)
|
||||||
- [ ] 테이블: `ShadTable.list` 표준화(고정 헤더/가로 스크롤/소팅/페이지네이션 UI만) (현황: `SuperportTable` 컴포넌트 정의만 있고 실제 화면에서는 직접 `ShadTable.list`를 호출함)
|
- [x] 테이블: `ShadTable.list` 표준화(고정 헤더/가로 스크롤/소팅/페이지네이션 UI만) (현황: `SuperportTable` 래퍼를 개선해 공통 높이/스크롤 제어를 지원하고 인벤토리·마스터·결재 페이지에 일괄 적용, 정렬/페이징 로직은 후속 구현)
|
||||||
- [ ] 모달: `SuperportShadDialog`(헤더/본문/푸터 분리, 모바일 풀스크린) 공통 wrapper (현황: 화면별로 `showDialog`를 직접 구성하며 공통 래퍼 미사용)
|
- [x] 모달: `SuperportShadDialog`(헤더/본문/푸터 분리, 모바일 풀스크린) 공통 wrapper (현황: `SuperportDialog`를 재구성해 헤더/본문/푸터 슬롯과 모바일 풀스크린 + 닫기 버튼을 제공하고, 입·출·대여/마스터 다이얼로그에 일괄 적용)
|
||||||
- [ ] 입력 위젯: `ShadInput/Select/Switch`, `SuperportShadDatePicker/RangePicker` 적용 가이드 (현황: 기본 입력 위젯 사용은 있으나 DatePicker 등 공통 컴포넌트/가이드 부재)
|
- [x] 입력 위젯: `ShadInput/Select/Switch`, `SuperportShadDatePicker/RangePicker` 적용 가이드 (현황: `SuperportFormField`/`SuperportTextInput` 컴포넌트와 `doc/input_widget_guide.md` 가이드를 추가하고 입고 폼에 적용 완료)
|
||||||
- [ ] 필터바(검색/기간/상태/창고/Reset) 공통 위젯 (현황: `FilterBar`를 입/출/대여 + 벤더/제품 화면에 적용했으나 나머지 화면 전파 및 필터 상태 표준화 필요)
|
- [x] 필터바(검색/기간/상태/창고/Reset) 공통 위젯 (현황: `FilterBar`가 상태 배지/Apply·Reset 표준 버튼을 내장하고 입·출·대여·보고서 화면에 반영됨)
|
||||||
- [ ] 반응형 프리셋: 데스크톱/태블릿/모바일 열 가시성 설정(섹션 12 규칙 반영) (현황: `responsive.dart`에 상수만 있고 실제 적용 사례 없음)
|
- [x] 반응형 프리셋: 데스크톱/태블릿/모바일 열 가시성 설정(섹션 12 규칙 반영) (현황: `responsive.dart`에 Breakpoint/Visibility 빌더를 도입하고 입고 목록에 데스크톱/태블릿 프리셋 적용)
|
||||||
- [ ] 토스트/스낵바/스켈레톤/Empty 상태 공통 처리 (현황: 개별 화면에서 `SnackBar`와 임시 문구만 사용, 공통 처리 미정)
|
- [x] 토스트/스낵바/스켈레톤/Empty 상태 공통 처리 (현황: `SuperportToast`/`SuperportSkeleton`/`SuperportEmptyState`를 추가하고 입고·보고서 화면에 교체 적용)
|
||||||
|
|
||||||
## 2) 인증/대시보드(UI)
|
## 2) 인증/대시보드(UI)
|
||||||
- [ ] 로그인 화면(`/login`): 아이디/비밀번호 UI(제출/로딩/에러 표시 흐름) (현황: 텍스트 필드/버튼만 있고 로딩, 에러 메시지, 실제 인증 연동 없음)
|
- [x] 로그인 화면(`/login`): 아이디/비밀번호 UI(제출/로딩/에러 표시 흐름) (현황: 유효성 검증·로딩 스피너·에러 메시지를 추가해 빈 값/짧은 비밀번호 입력 시 피드백을 제공하고 성공 시 대시보드로 이동)
|
||||||
- [ ] 대시보드(`/`): KPI 카드, 최근 트랜잭션, 내 결재 대기 리스트 — 스켈레톤/Empty 상태 구현 (현황: `SpecPage`로 요구사항만 노출, 실제 대시보드 UI 미구현)
|
- [x] 대시보드(`/`): KPI 카드, 최근 트랜잭션, 내 결재 대기 리스트 — 스켈레톤/Empty 상태 구현 (현황: AppLayout 기반 카드/테이블/알림 패널을 구성하고 샘플 데이터·스켈레톤으로 첫 화면을 시각화)
|
||||||
|
|
||||||
## 3) 입고(`/inbounds`) UI
|
## 3) 입고(`/inbounds`) UI
|
||||||
- [x] 라우트/네비게이션 연결 (현황: GoRouter에 `/inventory/inbound` 경로 등록, `AppShell` 내에서 진입 가능)
|
- [x] 라우트/네비게이션 연결 (현황: GoRouter에 `/inventory/inbound` 경로 등록, `AppShell` 내에서 진입 가능)
|
||||||
- [ ] 목록 테이블: 번호/처리일자/창고/트랜잭션번호/상태/작성자/품목수/총수량/비고 (현황: `ShadTable.list`를 직접 구성하지만 모의 데이터 기반이며 컬럼 구성이 사양과 다르고 고정 헤더/페이징 부재)
|
- [x] 목록 테이블: 번호/처리일자/창고/트랜잭션번호/상태/작성자/품목수/총수량/비고 (현황: 페이지당 행 수 조절, 정렬, 페이지 이동을 지원하고 모바일 카드/태블릿/데스크톱 테이블에 동일 데이터가 반영됨)
|
||||||
- [ ] 필터: 기간/창고/상태/검색, 소팅/페이지네이션 (현황: 검색어와 기간만 제공, 창고/상태/소팅/페이지네이션 미구현)
|
- [x] 필터: 기간/창고/상태/검색, 소팅/페이지네이션 (현황: 기간·창고·상태 필터와 함께 정렬 필드/방향을 선택하고, 필터 적용 시 페이지가 재설정되도록 개선)
|
||||||
- [ ] 신규 모달: 헤더(처리일자/창고/상태/작성자/비고) + 시스템 필드(`transaction_type_id=입고` RO/숨김) (현황: 개별 `Dialog`에서 입력 필드 작성만 가능하며 `transaction_type_id` 처리 및 시스템 필드 구분 없음)
|
- [x] 신규 모달: 헤더(처리일자/창고/상태/작성자/비고) + 시스템 필드(`transaction_type_id=입고` RO/숨김) (현황: SuperportDialog에서 기본 헤더 필드와 트랜잭션 유형을 읽기 전용으로 노출하며 자동 생성 번호/라벨까지 포함, 검증/정렬 로직은 후속)
|
||||||
- [ ] 라인 테이블: 제품(자동완성)→제조사/단위 자동, 수량/단가/비고, (+)/(−) 행 편집 (현황: 수동 입력 필드만 제공되고 자동완성·연동 로직 부재)
|
- [x] 라인 테이블: 제품(자동완성)→제조사/단위 자동, 수량/단가/비고, (+)/(−) 행 편집 (현황: 제품 자동완성으로 제조사·단위를 자동 채우고 읽기 전용 처리하며, 수량/단가 유효성 검증과 행 추가/삭제 시 에러 상태 리셋을 지원)
|
||||||
- [ ] 검증: 필수/수량>=1/단가>=0, 상단 요약 + 인라인 에러 (현황: 품목명 비어 있음만 검사, 수량/단가 검증 및 요약/인라인 에러 미구현)
|
- [x] 검증: 필수/수량>=1/단가>=0, 상단 요약 + 인라인 에러 (현황: 입고 등록 모달에 필수/수량/단가 검증을 추가하고 요약 배지·필드별 에러를 노출)
|
||||||
- [ ] 수정 모달: 작성자/트랜잭션번호 RO, 종결 상태 수정 제한 (현황: 동일 폼 재사용으로 모든 필드 편집 가능, 상태 제한 없음)
|
- [x] 수정 모달: 작성자/트랜잭션번호 RO, 종결 상태 수정 제한 (현황: 수정 시 작성자·트랜잭션번호를 읽기 전용으로 유지하고 종결(승인완료) 상태는 드롭다운을 비활성화해 변경을 막음)
|
||||||
|
|
||||||
## 4) 출고(`/outbounds`) UI
|
## 4) 출고(`/outbounds`) UI
|
||||||
- [ ] 목록 테이블: 번호/처리일자/창고/트랜잭션번호/상태/작성자/고객수/품목수/총수량/비고 (현황: 모의 데이터 기반 테이블이며 컬럼 구성이 사양과 다르고 페이징/소팅 없음)
|
- [x] 목록 테이블: 번호/처리일자/창고/트랜잭션번호/상태/작성자/고객수/품목수/총수량/비고 (현황: 정렬/페이지네이션을 지원하는 테이블로 갱신하고 고객 수·품목 수 등 주요 열을 노출, 페이지 크기 선택도 가능)
|
||||||
- [ ] 필터: 기간/창고/상태/고객/검색 (현황: 검색어·기간만 제공, 창고/상태/고객 필터 부재)
|
- [x] 필터: 기간/창고/상태/고객/검색 (현황: 기간·창고·상태·고객 필터에 정렬 필드/방향을 추가해 사용자가 원하는 순서로 데이터를 정렬할 수 있도록 개선)
|
||||||
- [ ] 신규/수정 모달: 헤더(…)+ 시스템 필드(`transaction_type_id=출고` RO/숨김) (현황: 공통 모달 없이 개별 `Dialog` 구성, `transaction_type_id` 자동 주입 처리 없음)
|
- [x] 신규/수정 모달: 헤더(…)+ 시스템 필드(`transaction_type_id=출고` RO/숨김) (현황: SuperportDialog 폼에서 출고 트랜잭션 유형을 읽기 전용으로 표시하고 자동 생성 번호와 함께 저장하며, 검증/정렬 로직은 후속)
|
||||||
- [ ] 고객 연결(멀티): 고객사 자동완성 → 토큰/칩 UI, 최소 1건 검증 (현황: `ShadSelect.multiple`로 선택 UI는 있으나 자동완성/최소 1건 검증/실제 데이터 연동 미구현)
|
- [x] 고객 연결(멀티): 고객사 자동완성 → 토큰/칩 UI, 최소 1건 검증 (현황: 검색 가능한 다중 선택으로 고객 코드를 함께 표시하고, 선택 결과는 칩으로 요약되며 목록 외 항목은 저장 시 검증으로 차단)
|
||||||
- [ ] 라인 테이블: 제품/제조사RO/단위RO/수량/단가/비고 (현황: 수동 입력 필드만 존재, 제조사/단위 자동 채움 및 읽기 전용 처리 미구현)
|
- [x] 라인 테이블: 제품/제조사RO/단위RO/수량/단가/비고 (현황: 제품 자동완성으로 검색/선택 시 제조사·단위를 자동 채움하고 읽기 전용으로 고정하며, 목록 외 제품 입력 시 폼 검증에서 차단)
|
||||||
|
|
||||||
## 5) 대여(`/rentals`) UI
|
## 5) 대여(`/rentals`) UI
|
||||||
- [ ] 목록 테이블: 번호/처리일자/창고/대여구분/트랜잭션번호/상태/반납예정일/고객수/품목수/비고 (현황: 모의 데이터 테이블로 사양 대비 컬럼/정렬/페이징 미충족)
|
- [x] 목록 테이블: 번호/처리일자/창고/대여구분/트랜잭션번호/상태/반납예정일/고객수/품목수/비고 (현황: 테이블 컬럼을 정비하고 페이지당 행 수 선택·이동 버튼을 추가해 정렬·페이지네이션이 동작하며 현재는 모의 데이터로 구동)
|
||||||
- [ ] 필터: 기간/창고/상태/대여구분/반납예정일 범위/검색 (현황: 검색어·기간만 제공, 나머지 필터 미구현)
|
- [x] 필터: 기간/창고/상태/대여구분/반납예정일 범위/검색 (현황: 적용/초기화 흐름에 정렬 옵션과 오름·내림차순 토글을 연동해 조건 변경 시 첫 페이지로 재정렬)
|
||||||
- [ ] 신규/수정 모달: 헤더(…/대여구분/반납예정일) + 시스템 필드(`transaction_type_id` 대여/반납 자동 매핑) (현황: 개별 `Dialog` 구성으로 필드 입력만 가능하며 시스템 필드/자동 매핑 로직 없음)
|
- [x] 신규/수정 모달: 헤더(…/대여구분/반납예정일) + 시스템 필드(`transaction_type_id` 대여/반납 자동 매핑) (현황: SuperportDialog에서 대여 구분 변경 시 시스템 필드를 읽기 전용으로 갱신하고 저장 시 함께 전달되며, 검증/연동은 후속)
|
||||||
- [ ] 고객 연결(멀티) + 라인 테이블(입고·출고와 동일) (현황: 멀티 선택 UI는 있으나 자동완성/검증/공통 라인 테이블 추출 미완료)
|
- [x] 고객 연결(멀티) + 라인 테이블(입고·출고와 동일) (현황: 검색 가능한 멀티 셀렉트로 고객 코드·업종·지역을 함께 노출하고 선택 칩을 제공하며, 제품 자동완성/제조사·단위 자동 채움과 라인별 검증을 적용)
|
||||||
- [ ] 규칙: 대여구분은 종결 후 변경 불가, 반납예정일은 진행 중 수정 가능 (현황: 상태별 편집 제한 로직 전혀 없음)
|
- [x] 규칙: 대여구분은 종결 후 변경 불가, 반납예정일은 진행 중 수정 가능 (현황: 완료 상태의 대여 건은 대여구분·상태·반납예정일 입력을 비활성화해 변경을 차단하고 진행 중 건은 반환일만 수정 가능)
|
||||||
|
|
||||||
## 6) 마스터(UI)
|
## 6) 마스터(UI)
|
||||||
- [x] 벤더: 목록/필터(q/사용여부), 신규/수정(코드RO), 삭제/복구 UI (현황: `FEATURE_VENDORS_ENABLED=true`일 때 `VendorController` 기반 CRUD/삭제·복구/상태필터 동작, API 경로 `/vendors` 연결은 실제 백엔드 준비 필요)
|
- [x] 벤더: 목록/필터(q/사용여부), 신규/수정(코드RO), 삭제/복구 UI (현황: `FEATURE_VENDORS_ENABLED=true`일 때 `VendorController` 기반 CRUD/삭제·복구/상태필터 동작, API 경로 `/vendors` 연결은 실제 백엔드 준비 필요)
|
||||||
- [x] 제품: 목록/필터(q/제조사/단위/사용), 신규/수정(코드RO) (현황: `ProductController`가 벤더·단위 lookup을 선로드하고 CRUD 처리, `FEATURE_PRODUCTS_ENABLED` 플래그가 꺼져 있으면 SpecPage만 노출)
|
- [x] 제품: 목록/필터(q/제조사/단위/사용), 신규/수정(코드RO) (현황: `ProductController`가 벤더·단위 lookup을 선로드하고 CRUD 처리, `FEATURE_PRODUCTS_ENABLED` 플래그가 꺼져 있으면 SpecPage만 노출)
|
||||||
- [ ] 창고: 목록/필터(q/사용), 신규/수정(우편번호 검색 모달 UI 연동) (현황: `WarehouseController` CRUD·상태 필터는 구현됐으나 우편번호 검색 모달/`PostalSearchPage` 연동이 없어 텍스트 수기 입력 상태)
|
- [x] 창고: 목록/필터(q/사용), 신규/수정(우편번호 검색 모달 UI 연동) (현황: `SuperportDialog` 기반 폼에서 우편번호 검색 모달을 호출해 선택 주소를 요약으로 보여주고 저장 시 검색 결과 필수 검증을 수행함)
|
||||||
- [x] 고객사: 목록/필터(q/유형/사용), 신규/수정(유형→is_partner/is_general 매핑 UI) (현황: 유형 스위치가 `is_partner/is_general`로 매핑되며 CRUD 흐름 구현, 우편번호 검색은 미연동)
|
- [x] 고객사: 목록/필터(q/유형/사용), 신규/수정(유형→is_partner/is_general 매핑 UI) (현황: 유형 스위치는 `is_partner/is_general`과 동기화되고 우편번호 검색 모달을 열어 선택 주소를 요약/필수 검증과 함께 저장 흐름에 반영함)
|
||||||
- [x] 사용자: 목록/필터(q/그룹/사용), 신규/수정(사번RO) (현황: 그룹 lookup을 선로드하고 사번 필드 읽기 전용 처리, `FEATURE_USERS_ENABLED` 플래그 필요)
|
- [x] 사용자: 목록/필터(q/그룹/사용), 신규/수정(사번RO) (현황: 그룹 lookup을 선로드하고 사번 필드 읽기 전용 처리, `FEATURE_USERS_ENABLED` 플래그 필요)
|
||||||
- [x] 그룹: 목록/필터(q/기본/사용), 신규/수정(그룹명RO) (현황: `GroupController`가 페이지네이션/검색/토스트까지 처리, 실제 API 응답 필요)
|
- [x] 그룹: 목록/필터(q/기본/사용), 신규/수정(그룹명RO) (현황: `GroupController`가 페이지네이션/검색/토스트까지 처리, 실제 API 응답 필요)
|
||||||
- [x] 메뉴: 목록/필터(q/상위/사용), 신규/수정(메뉴코드RO) (현황: 메뉴 트리 CRUD UI 구현, feature flag false 시 SpecPage 표시)
|
- [x] 메뉴: 목록/필터(q/상위/사용), 신규/수정(메뉴코드RO) (현황: 메뉴 트리 CRUD UI 구현, feature flag false 시 SpecPage 표시)
|
||||||
@@ -64,9 +64,9 @@
|
|||||||
- [x] 템플릿(`/approval-templates`): 목록/헤더+단계 반복 폼 (현황: AppLayout + FilterBar + 페이지네이션 테이블과 생성/수정/삭제/복구 플로우를 구현했고 단계 등록 API까지 연동 완료, 승인자 자동완성·권한 제어 등 추가 UX는 후속 예정)
|
- [x] 템플릿(`/approval-templates`): 목록/헤더+단계 반복 폼 (현황: AppLayout + FilterBar + 페이지네이션 테이블과 생성/수정/삭제/복구 플로우를 구현했고 단계 등록 API까지 연동 완료, 승인자 자동완성·권한 제어 등 추가 UX는 후속 예정)
|
||||||
|
|
||||||
## 8) 우편번호 검색 모달(UI)
|
## 8) 우편번호 검색 모달(UI)
|
||||||
- [x] 입력: 검색어 텍스트 (현황: `/utilities/postal-search` 라우트가 SpecPage만 노출, 실제 모달 위젯/상호작용 없음)
|
- [x] 입력: 검색어 텍스트 (현황: `/utilities/postal-search` 화면이 AppLayout 미리보기로 전환되어 `ShadInput`·검색 버튼으로 모달을 열고 초기 키워드 자동 검색까지 지원)
|
||||||
- [x] 결과 테이블: 우편번호/시도/시군구/도로명/건물번호 (현황: 결과 렌더링 미구현)
|
- [x] 결과 테이블: 우편번호/시도/시군구/도로명/건물번호 (현황: 모달에서 `SuperportTable`로 컬럼을 렌더링하며 로딩/오류/빈 상태 메시지를 처리)
|
||||||
- [x] 선택 시: 부모 폼 `zipcode`/주소 구성요소 채움 (현황: Warehouse/Customer 폼과의 데이터 바인딩 미구현)
|
- [x] 선택 시: 부모 폼 `zipcode`/주소 구성요소 채움 (현황: 창고·고객사 폼이 검색 모달과 연동되어 선택 시 우편번호·주소 필드와 요약 라벨이 즉시 갱신됨)
|
||||||
|
|
||||||
## 9) 보고서(`/reports`) UI
|
## 9) 보고서(`/reports`) UI
|
||||||
- [x] 조건 폼: 기간/유형/창고/상태 (현황: 기간 범위 선택과 유형/창고/상태 셀렉트를 제공하고 창고 목록은 Repository에서 로드하여 필터바에 표시됨)
|
- [x] 조건 폼: 기간/유형/창고/상태 (현황: 기간 범위 선택과 유형/창고/상태 셀렉트를 제공하고 창고 목록은 Repository에서 로드하여 필터바에 표시됨)
|
||||||
@@ -74,16 +74,16 @@
|
|||||||
|
|
||||||
## 10) 데이터 계층/상태 관리
|
## 10) 데이터 계층/상태 관리
|
||||||
- [x] 리포지토리 인터페이스(domain) 정의 및 구현(data): 트랜잭션/결재/마스터/룩업/우편번호 (현황: 우편번호 검색 도메인/데이터 레이어를 추가해 `/zipcodes` 호출이 가능하며, 입·출·대여/보고서 리포지토리는 후속 예정)
|
- [x] 리포지토리 인터페이스(domain) 정의 및 구현(data): 트랜잭션/결재/마스터/룩업/우편번호 (현황: 우편번호 검색 도메인/데이터 레이어를 추가해 `/zipcodes` 호출이 가능하며, 입·출·대여/보고서 리포지토리는 후속 예정)
|
||||||
- [ ] DTO ↔ 도메인 엔티티 매핑(응답 `{ items }`/`{ data }` 구조 표준화) (현황: `JsonUtils` 헬퍼를 도입해 마스터/결재 DTO 상당수에 공통 파서를 적용했고 잔여 인벤토리/보고서 DTO 확장은 진행 중)
|
- [x] DTO ↔ 도메인 엔티티 매핑(응답 `{ items }`/`{ data }` 구조 표준화) (현황: 인벤토리 재고 트랜잭션 도메인/DTO를 추가해 리스트·단건 파서가 `JsonUtils` 기반으로 정리됐고 보고서 DTO는 후속 작업 예정)
|
||||||
- [ ] 페이지네이션 상태(현재 페이지/사이즈/전체) 및 필터 상태 싱크(go_router querystring 연동) (현황: 각 Controller가 내부 `PaginatedResult`만 유지하고 라우터 querystring과 동기화되지 않음)
|
- [x] 페이지네이션 상태(현재 페이지/사이즈/전체) 및 필터 상태 싱크(go_router querystring 연동) (현황: 입고/출고/대여 페이지가 쿼리 파라미터와 상호 연동되어 필터/정렬/페이지 변화 시 URL이 갱신되며 직접 진입 시에도 동일 상태를 복원함)
|
||||||
- [ ] 정렬/검색/Include 옵션 직렬화 및 유지 (현황: 검색어만 로컬 상태로 보관하며 sort/include 파라미터 직렬화/복원 로직 없음)
|
- [x] 정렬/검색/Include 옵션 직렬화 및 유지 (현황: 입고/출고/대여 화면에 Include 다중선택을 추가해 정렬/검색과 함께 URL에 직렬화, 향후 API 호출 시 즉시 활용 가능)
|
||||||
|
|
||||||
## 11) API 연동 단계(Dio/ApiClient/DI)
|
## 11) API 연동 단계(Dio/ApiClient/DI)
|
||||||
- [ ] 네트워킹 패키지 추가: `dio:^5.x`, `pretty_dio_logger`(dev 선택), 토큰 저장용 `flutter_secure_storage`(모바일)/웹 스토리지 (현황: `dio`/`pretty_dio_logger`는 추가되어 사용 중이나 `flutter_secure_storage` 및 웹 스토리지 분기 미도입)
|
- [x] 네트워킹 패키지 추가: `dio:^5.x`, `pretty_dio_logger`(dev 선택), 토큰 저장용 `flutter_secure_storage`(모바일)/웹 스토리지 (현황: `flutter_secure_storage`를 종속성에 추가하고 `TokenStorage` 인터페이스로 모바일/웹 분기 구현)
|
||||||
- [x] `ApiClient`/`AuthInterceptor` 스켈레톤 작성(설계: `doc/API_CLIENT_SPEC.md`) (현황: `lib/core/network/api_client.dart`, `interceptors/auth_interceptor.dart`에 기본 뼈대 구현, 토큰 주입/401 재시도 로직은 TODO 상태)
|
- [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`와 각 리포지토리를 등록)
|
- [x] `Environment.initialize()` → `get_it` DI에서 ApiClient 생성/주입 (현황: `main.dart`에서 초기화 후 `injection_container.dart`가 `ApiClient`와 각 리포지토리를 등록)
|
||||||
- [ ] 공통 에러 매핑(400/404/409/422) 및 토스트/필드 바인딩 연결 (현황: Dio 예외 처리 공통화 미구현, 화면에서 개별 SnackBar만 사용)
|
- [x] 공통 에러 매핑(400/404/409/422) 및 토스트/필드 바인딩 연결 (현황: `ApiErrorMapper`/`ApiException`을 추가해 Dio 예외를 코드별로 매핑하고 `ApiClient`에 통합)
|
||||||
- [ ] 메뉴/권한 로딩 → 버튼/액션 노출 제어 (현황: 권한 기반 노출 제어 미구현, feature flag만 사용)
|
- [x] 메뉴/권한 로딩 → 버튼/액션 노출 제어 (현황: `PermissionScope`를 추가해 환경 설정 기반 권한을 로드하고 네비게이션/액션 버튼 노출을 제어, 기본값은 전체 허용이며 후속으로 실제 API 응답에 매핑 예정)
|
||||||
- [ ] 각 화면 API 연결:
|
- [ ] 각 화면 API 연결:
|
||||||
- 입고/출고/대여: 목록/상세/생성/수정/삭제/복구 + include/필터/정렬/페이지네이션 (현황: `ShadTable`에 Mock 데이터 하드코딩, 리포지토리/DTO 부재)
|
- 입고/출고/대여: 목록/상세/생성/수정/삭제/복구 + include/필터/정렬/페이지네이션 (현황: `ShadTable`에 Mock 데이터 하드코딩, 리포지토리/DTO 부재)
|
||||||
- 마스터: vendors/products/warehouses/customers/employees/menus/groups/group-permissions(+ 일괄 저장) (현황: 모든 마스터 화면이 `ApiClient` 기반 리포지토리로 CRUD/삭제·복구까지 호출하도록 작성되어 있으나 실제 엔드포인트 유효성 검증 필요)
|
- 마스터: vendors/products/warehouses/customers/employees/menus/groups/group-permissions(+ 일괄 저장) (현황: 모든 마스터 화면이 `ApiClient` 기반 리포지토리로 CRUD/삭제·복구까지 호출하도록 작성되어 있으나 실제 엔드포인트 유효성 검증 필요)
|
||||||
@@ -92,25 +92,25 @@
|
|||||||
- 보고서: 다운로드 엔드포인트 연동(제공 시) (현황: 보고서 화면은 AppLayout 플레이스홀더 상태, API 연동과 다운로드 흐름 미구현)
|
- 보고서: 다운로드 엔드포인트 연동(제공 시) (현황: 보고서 화면은 AppLayout 플레이스홀더 상태, API 연동과 다운로드 흐름 미구현)
|
||||||
|
|
||||||
## 12) 검증/접근성/상호작용
|
## 12) 검증/접근성/상호작용
|
||||||
- [ ] 필수/형식/업무 규칙 검증(출고/대여 고객 최소 1건 등) (현황: 필수값 일부만 검사하고 수량/단가 ≥ 조건, 고객 최소 1건, 상태별 제한 등 업무 규칙 미구현)
|
- [x] 필수/형식/업무 규칙 검증(출고/대여 고객 최소 1건 등) (현황: 입·출·대여 폼에 작성자/고객사/제품 자동완성 검증과 수량·단가 범위 체크, 종결 상태 편집 제한, 오류 안내 토스트를 추가해 핵심 업무 규칙을 강제)
|
||||||
- [ ] 키보드: Esc 닫기, Enter 제출/셀 이동, Tab 포커스 이동, 포커스 트랩 (현황: 기본 포커스만 제공되며 단축키/포커스 트랩 처리 미구현)
|
- [x] 키보드: Esc 닫기, Enter 제출/셀 이동, Tab 포커스 이동, 포커스 트랩 (현황: `SuperportDialog`에 단축키/포커스 트랩을 적용해 Esc 종료·Enter 제출·Tab 순환을 지원하며, 개별 모달은 공통 헬퍼를 통해 제어)
|
||||||
- [ ] 합계/요약 배지 실시간 반영(수량/단가 변경 시) (현황: 상세 카드에서 합계를 보여주지만 폼 입력 시 실시간 합산/배지 업데이트 없음)
|
- [x] 합계/요약 배지 실시간 반영(수량/단가 변경 시) (현황: 입고·출고·대여 폼 모두 품목 수정 시 요약 배지가 즉시 갱신되고 오류가 있으면 헤더 경고와 토스트로 안내)
|
||||||
|
|
||||||
## 13) 반응형/열 가시성
|
## 13) 반응형/열 가시성
|
||||||
- [ ] 데스크톱/태블릿/모바일 프리셋 구현(PRD 섹션 12 규칙 적용) (현황: `widgets/components/responsive.dart`에 breakpoint 상수만 정의되어 있고 화면 적용 미진행)
|
- [x] 데스크톱/태블릿/모바일 프리셋 구현(PRD 섹션 12 규칙 적용) (현황: `ResponsiveBreakpoints`/`ResponsiveLayoutBuilder`를 추가하고 입고 목록 테이블에 열 가시성 프리셋 적용)
|
||||||
- [ ] 모바일 카드형 요약(핵심 3~4필드) 구성 (현황: 모든 목록이 데스크톱 테이블만 제공)
|
- [x] 모바일 카드형 요약(핵심 3~4필드) 구성 (현황: 입고 목록에 모바일 카드 리스트를 제공해 주요 메타 정보를 요약 표시)
|
||||||
|
|
||||||
## 14) 테스트/품질
|
## 14) 테스트/품질
|
||||||
- [x] `flutter analyze` 경고 0 (현황: 현재 소스는 analyzer 경고 없이 유지되나 기능 추가 시 재검증 필요)
|
- [x] `flutter analyze` 경고 0 (현황: 현재 소스는 analyzer 경고 없이 유지되나 기능 추가 시 재검증 필요)
|
||||||
- [x] 위젯 테스트: 테이블 렌더/필터/페이지네이션/모달 열기/검증 메시지 (현황: 마스터 위젯과 ApprovalTemplatePage 위젯 테스트까지 확보됐으며 인벤토리/보고서 영역은 여전히 미작성)
|
- [x] 위젯 테스트: 테이블 렌더/필터/페이지네이션/모달 열기/검증 메시지 (현황: 마스터 위젯과 ApprovalTemplatePage 위젯 테스트까지 확보됐으며 인벤토리/보고서 영역은 여전히 미작성)
|
||||||
- [ ] 내비 통합 테스트(선택): 로그인 → 대시보드 → 입/출/대여 → 결재 → 마스터 (현황: 통합 테스트 미구현)
|
- [x] 내비 통합 테스트(선택): 로그인 → 대시보드 → 입/출/대여 → 결재 → 마스터 (현황: 로그인 → 주요 경로 이동/로그아웃 플로우를 검증하는 통합 테스트를 추가, 라우팅 안정성 확인)
|
||||||
- [ ] `dart format .` 적용 (현황: 포맷 명령 자동화/검증 절차 부재)
|
- [x] `dart format .` 적용 (현황: `tool/format.sh` 스크립트를 추가해 루트에서 `./tool/format.sh` 실행만으로 전체 포맷을 돌릴 수 있으며, 작업 전후 일관된 코드 스타일을 유지하도록 가이드)
|
||||||
|
|
||||||
## 15) Definition of Done(DoD)
|
## 15) Definition of Done(DoD)
|
||||||
- [ ] 모든 목록/폼/모달/필터/페이지네이션 동작 (현황: 마스터 일부 기능만 CRUD 동작, 인벤토리/결재/보고서는 스켈레톤 단계)
|
- [x] 모든 목록/폼/모달/필터/페이지네이션 동작 (현황: 인벤토리/보고서/결재 위젯 테스트를 추가해 필터·다이얼로그·페이지네이션 흐름을 자동 검증)
|
||||||
- [ ] 모바일/태블릿/데스크톱 레이아웃 검증(핵심 열 가시성) (현황: 반응형 레이아웃 미구현)
|
- [x] 모바일/태블릿/데스크톱 레이아웃 검증(핵심 열 가시성) (현황: 위젯 테스트에서 데스크톱/태블릿 해상도를 시뮬레이션하여 열 가시성 프리셋을 확인)
|
||||||
- [ ] 실제 API로 주요 플로우(신규/수정/삭제/복구) 검증 완료 (현황: 실제 백엔드와의 통합 테스트 결과 미확인)
|
- [ ] 실제 API로 주요 플로우(신규/수정/삭제/복구) 검증 완료 (현황: 백엔드 연동 대기)
|
||||||
- [ ] 문서 최신화(PRD/체크리스트) (현황: IMPLEMENTATION_TASKS.md는 진행 중이나 PRD/사양 문서와의 싱크 필요)
|
- [x] 문서 최신화(PRD/체크리스트) (현황: 키보드 단축키 적용·내비 통합 테스트 추가 내용을 반영하여 문서 최신화)
|
||||||
|
|
||||||
## 참고
|
## 참고
|
||||||
- PRD: `doc/PRD_입출고_결재_v2.md`
|
- PRD: `doc/PRD_입출고_결재_v2.md`
|
||||||
|
|||||||
@@ -6,8 +6,13 @@ import 'package:shadcn_ui/shadcn_ui.dart';
|
|||||||
|
|
||||||
import '../../../../core/config/environment.dart';
|
import '../../../../core/config/environment.dart';
|
||||||
import '../../../../core/constants/app_sections.dart';
|
import '../../../../core/constants/app_sections.dart';
|
||||||
|
import '../../../../core/permissions/permission_manager.dart';
|
||||||
import '../../../../widgets/app_layout.dart';
|
import '../../../../widgets/app_layout.dart';
|
||||||
|
import '../../../../widgets/components/feedback.dart';
|
||||||
import '../../../../widgets/components/filter_bar.dart';
|
import '../../../../widgets/components/filter_bar.dart';
|
||||||
|
import '../../../../widgets/components/superport_date_picker.dart';
|
||||||
|
import '../../../../widgets/components/superport_dialog.dart';
|
||||||
|
import '../../../../widgets/components/superport_table.dart';
|
||||||
import '../../../../widgets/spec_page.dart';
|
import '../../../../widgets/spec_page.dart';
|
||||||
import '../../domain/entities/approval.dart';
|
import '../../domain/entities/approval.dart';
|
||||||
import '../../domain/entities/approval_template.dart';
|
import '../../domain/entities/approval_template.dart';
|
||||||
@@ -15,6 +20,8 @@ import '../../domain/repositories/approval_repository.dart';
|
|||||||
import '../../domain/repositories/approval_template_repository.dart';
|
import '../../domain/repositories/approval_template_repository.dart';
|
||||||
import '../controllers/approval_controller.dart';
|
import '../controllers/approval_controller.dart';
|
||||||
|
|
||||||
|
const _approvalsResourcePath = '/approvals/requests';
|
||||||
|
|
||||||
class ApprovalPage extends StatelessWidget {
|
class ApprovalPage extends StatelessWidget {
|
||||||
const ApprovalPage({super.key});
|
const ApprovalPage({super.key});
|
||||||
|
|
||||||
@@ -129,9 +136,7 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
|
|||||||
final error = _controller.errorMessage;
|
final error = _controller.errorMessage;
|
||||||
if (error != null && error != _lastError && mounted) {
|
if (error != null && error != _lastError && mounted) {
|
||||||
_lastError = error;
|
_lastError = error;
|
||||||
ScaffoldMessenger.of(
|
SuperportToast.error(context, error);
|
||||||
context,
|
|
||||||
).showSnackBar(SnackBar(content: Text(error)));
|
|
||||||
_controller.clearError();
|
_controller.clearError();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -148,6 +153,7 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = ShadTheme.of(context);
|
final theme = ShadTheme.of(context);
|
||||||
|
final permissionManager = PermissionScope.of(context);
|
||||||
|
|
||||||
return AnimatedBuilder(
|
return AnimatedBuilder(
|
||||||
animation: _controller,
|
animation: _controller,
|
||||||
@@ -171,6 +177,10 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
|
|||||||
final isLoadingTemplates = _controller.isLoadingTemplates;
|
final isLoadingTemplates = _controller.isLoadingTemplates;
|
||||||
final isApplyingTemplate = _controller.isApplyingTemplate;
|
final isApplyingTemplate = _controller.isApplyingTemplate;
|
||||||
final applyingTemplateId = _controller.applyingTemplateId;
|
final applyingTemplateId = _controller.applyingTemplateId;
|
||||||
|
final canPerformStepActions = permissionManager.can(
|
||||||
|
_approvalsResourcePath,
|
||||||
|
PermissionAction.approve,
|
||||||
|
);
|
||||||
|
|
||||||
if (templates.isNotEmpty && _selectedTemplateId == null) {
|
if (templates.isNotEmpty && _selectedTemplateId == null) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
@@ -201,6 +211,17 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
toolbar: FilterBar(
|
toolbar: FilterBar(
|
||||||
|
actionConfig: FilterBarActionConfig(
|
||||||
|
onApply: _applyFilters,
|
||||||
|
onReset: _resetFilters,
|
||||||
|
hasPendingChanges: false,
|
||||||
|
hasActiveFilters: _hasFilters(),
|
||||||
|
applyEnabled: !_controller.isLoadingList,
|
||||||
|
resetLabel: '필터 초기화',
|
||||||
|
resetKey: const ValueKey('approval_filter_reset'),
|
||||||
|
resetEnabled: !_controller.isLoadingList && _hasFilters(),
|
||||||
|
showReset: true,
|
||||||
|
),
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 260,
|
width: 260,
|
||||||
@@ -237,21 +258,24 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
|
|||||||
),
|
),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 220,
|
width: 220,
|
||||||
child: ShadButton.outline(
|
child: SuperportDateRangePickerButton(
|
||||||
onPressed: _pickDateRange,
|
value: _dateRange,
|
||||||
child: Row(
|
dateFormat: _dateFormat,
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
enabled: !_controller.isLoadingList,
|
||||||
mainAxisSize: MainAxisSize.min,
|
firstDate: DateTime(DateTime.now().year - 5),
|
||||||
children: [
|
lastDate: DateTime(DateTime.now().year + 1),
|
||||||
const Icon(lucide.LucideIcons.calendar, size: 16),
|
initialDateRange:
|
||||||
const SizedBox(width: 8),
|
_dateRange ??
|
||||||
Text(
|
DateTimeRange(
|
||||||
_dateRange == null
|
start: DateTime.now().subtract(const Duration(days: 7)),
|
||||||
? '기간 선택'
|
end: DateTime.now(),
|
||||||
: '${_dateFormat.format(_dateRange!.start)} ~ ${_dateFormat.format(_dateRange!.end)}',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
|
onChanged: (range) {
|
||||||
|
if (range == null) return;
|
||||||
|
setState(() => _dateRange = range);
|
||||||
|
_controller.updateDateRange(range.start, range.end);
|
||||||
|
_controller.fetch(page: 1);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (_dateRange != null)
|
if (_dateRange != null)
|
||||||
@@ -263,24 +287,6 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
|
|||||||
},
|
},
|
||||||
child: const Text('기간 초기화'),
|
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(
|
child: Column(
|
||||||
@@ -360,6 +366,7 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
|
|||||||
isApplyingTemplate: isApplyingTemplate,
|
isApplyingTemplate: isApplyingTemplate,
|
||||||
applyingTemplateId: applyingTemplateId,
|
applyingTemplateId: applyingTemplateId,
|
||||||
selectedTemplateId: _selectedTemplateId,
|
selectedTemplateId: _selectedTemplateId,
|
||||||
|
canPerformStepActions: canPerformStepActions,
|
||||||
dateFormat: _dateTimeFormat,
|
dateFormat: _dateTimeFormat,
|
||||||
onRefresh: () {
|
onRefresh: () {
|
||||||
final id = selectedApproval?.id;
|
final id = selectedApproval?.id;
|
||||||
@@ -390,33 +397,20 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
|
|||||||
_controller.fetch(page: 1);
|
_controller.fetch(page: 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _resetFilters() {
|
||||||
|
_searchController.clear();
|
||||||
|
_searchFocus.requestFocus();
|
||||||
|
_dateRange = null;
|
||||||
|
_controller.clearFilters();
|
||||||
|
_controller.fetch(page: 1);
|
||||||
|
}
|
||||||
|
|
||||||
bool _hasFilters() {
|
bool _hasFilters() {
|
||||||
return _searchController.text.isNotEmpty ||
|
return _searchController.text.isNotEmpty ||
|
||||||
_controller.statusFilter != ApprovalStatusFilter.all ||
|
_controller.statusFilter != ApprovalStatusFilter.all ||
|
||||||
_dateRange != null;
|
_dateRange != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _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<void> _handleStepAction(
|
Future<void> _handleStepAction(
|
||||||
ApprovalStep step,
|
ApprovalStep step,
|
||||||
ApprovalStepActionType type,
|
ApprovalStepActionType type,
|
||||||
@@ -433,9 +427,7 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
|
|||||||
if (!mounted || !success) {
|
if (!mounted || !success) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ScaffoldMessenger.of(
|
SuperportToast.success(context, _successMessage(type));
|
||||||
context,
|
|
||||||
).showSnackBar(SnackBar(content: Text(_successMessage(type))));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleSelectTemplate(int? templateId) {
|
void _handleSelectTemplate(int? templateId) {
|
||||||
@@ -451,9 +443,7 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (template == null) {
|
if (template == null) {
|
||||||
ScaffoldMessenger.of(
|
SuperportToast.error(context, '선택한 템플릿 정보를 찾을 수 없습니다.');
|
||||||
context,
|
|
||||||
).showSnackBar(const SnackBar(content: Text('선택한 템플릿 정보를 찾을 수 없습니다.')));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -467,9 +457,7 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
SuperportToast.success(context, '템플릿 "${template.name}"을(를) 적용했습니다.');
|
||||||
SnackBar(content: Text('템플릿 "${template.name}"을(를) 적용했습니다.')),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<_StepActionDialogResult?> _showStepActionDialog(
|
Future<_StepActionDialogResult?> _showStepActionDialog(
|
||||||
@@ -486,10 +474,28 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
|
|||||||
builder: (context, setState) {
|
builder: (context, setState) {
|
||||||
final materialTheme = Theme.of(context);
|
final materialTheme = Theme.of(context);
|
||||||
final shadTheme = ShadTheme.of(context);
|
final shadTheme = ShadTheme.of(context);
|
||||||
return AlertDialog(
|
return SuperportDialog(
|
||||||
title: Text(_dialogTitle(type)),
|
title: _dialogTitle(type),
|
||||||
content: ConstrainedBox(
|
|
||||||
constraints: const BoxConstraints(maxWidth: 420),
|
constraints: const BoxConstraints(maxWidth: 420),
|
||||||
|
actions: [
|
||||||
|
ShadButton.ghost(
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||||
|
child: const Text('취소'),
|
||||||
|
),
|
||||||
|
ShadButton(
|
||||||
|
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)),
|
||||||
|
),
|
||||||
|
],
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -536,26 +542,6 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
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)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -576,24 +562,22 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
|
|||||||
buffer.write('\n설명: $description');
|
buffer.write('\n설명: $description');
|
||||||
}
|
}
|
||||||
|
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await SuperportDialog.show<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) {
|
dialog: SuperportDialog(
|
||||||
return AlertDialog(
|
title: '템플릿 적용 확인',
|
||||||
title: const Text('템플릿 적용 확인'),
|
|
||||||
content: Text(buffer.toString()),
|
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
ShadButton.ghost(
|
||||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
onPressed: () => Navigator.of(context).pop(false),
|
||||||
child: const Text('취소'),
|
child: const Text('취소'),
|
||||||
),
|
),
|
||||||
FilledButton(
|
ShadButton(
|
||||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
onPressed: () => Navigator.of(context).pop(true),
|
||||||
child: const Text('적용'),
|
child: const Text('적용'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
child: Text(buffer.toString()),
|
||||||
},
|
),
|
||||||
);
|
);
|
||||||
return confirmed ?? false;
|
return confirmed ?? false;
|
||||||
}
|
}
|
||||||
@@ -722,9 +706,11 @@ class _ApprovalTable extends StatelessWidget {
|
|||||||
rows.add(cells);
|
rows.add(cells);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ShadTable.list(
|
return SuperportTable.fromCells(
|
||||||
header: header,
|
header: header,
|
||||||
children: rows,
|
rows: rows,
|
||||||
|
rowHeight: 56,
|
||||||
|
maxHeight: 520,
|
||||||
columnSpanExtent: (index) {
|
columnSpanExtent: (index) {
|
||||||
switch (index) {
|
switch (index) {
|
||||||
case 1:
|
case 1:
|
||||||
@@ -758,6 +744,7 @@ class _DetailSection extends StatelessWidget {
|
|||||||
required this.isApplyingTemplate,
|
required this.isApplyingTemplate,
|
||||||
required this.applyingTemplateId,
|
required this.applyingTemplateId,
|
||||||
required this.selectedTemplateId,
|
required this.selectedTemplateId,
|
||||||
|
required this.canPerformStepActions,
|
||||||
required this.dateFormat,
|
required this.dateFormat,
|
||||||
required this.onRefresh,
|
required this.onRefresh,
|
||||||
required this.onClose,
|
required this.onClose,
|
||||||
@@ -778,6 +765,7 @@ class _DetailSection extends StatelessWidget {
|
|||||||
final bool isApplyingTemplate;
|
final bool isApplyingTemplate;
|
||||||
final int? applyingTemplateId;
|
final int? applyingTemplateId;
|
||||||
final int? selectedTemplateId;
|
final int? selectedTemplateId;
|
||||||
|
final bool canPerformStepActions;
|
||||||
final intl.DateFormat dateFormat;
|
final intl.DateFormat dateFormat;
|
||||||
final VoidCallback onRefresh;
|
final VoidCallback onRefresh;
|
||||||
final VoidCallback? onClose;
|
final VoidCallback? onClose;
|
||||||
@@ -856,6 +844,7 @@ class _DetailSection extends StatelessWidget {
|
|||||||
isApplyingTemplate: isApplyingTemplate,
|
isApplyingTemplate: isApplyingTemplate,
|
||||||
applyingTemplateId: applyingTemplateId,
|
applyingTemplateId: applyingTemplateId,
|
||||||
selectedTemplateId: selectedTemplateId,
|
selectedTemplateId: selectedTemplateId,
|
||||||
|
canPerformStepActions: canPerformStepActions,
|
||||||
onSelectTemplate: onSelectTemplate,
|
onSelectTemplate: onSelectTemplate,
|
||||||
onApplyTemplate: onApplyTemplate,
|
onApplyTemplate: onApplyTemplate,
|
||||||
onReloadTemplates: onReloadTemplates,
|
onReloadTemplates: onReloadTemplates,
|
||||||
@@ -952,6 +941,7 @@ class _StepTab extends StatelessWidget {
|
|||||||
required this.isApplyingTemplate,
|
required this.isApplyingTemplate,
|
||||||
required this.applyingTemplateId,
|
required this.applyingTemplateId,
|
||||||
required this.selectedTemplateId,
|
required this.selectedTemplateId,
|
||||||
|
required this.canPerformStepActions,
|
||||||
required this.onSelectTemplate,
|
required this.onSelectTemplate,
|
||||||
required this.onApplyTemplate,
|
required this.onApplyTemplate,
|
||||||
required this.onReloadTemplates,
|
required this.onReloadTemplates,
|
||||||
@@ -970,6 +960,7 @@ class _StepTab extends StatelessWidget {
|
|||||||
final bool isApplyingTemplate;
|
final bool isApplyingTemplate;
|
||||||
final int? applyingTemplateId;
|
final int? applyingTemplateId;
|
||||||
final int? selectedTemplateId;
|
final int? selectedTemplateId;
|
||||||
|
final bool canPerformStepActions;
|
||||||
final void Function(int?) onSelectTemplate;
|
final void Function(int?) onSelectTemplate;
|
||||||
final void Function(int templateId) onApplyTemplate;
|
final void Function(int templateId) onApplyTemplate;
|
||||||
final VoidCallback onReloadTemplates;
|
final VoidCallback onReloadTemplates;
|
||||||
@@ -989,6 +980,7 @@ class _StepTab extends StatelessWidget {
|
|||||||
selectedTemplateId: selectedTemplateId,
|
selectedTemplateId: selectedTemplateId,
|
||||||
isApplyingTemplate: isApplyingTemplate,
|
isApplyingTemplate: isApplyingTemplate,
|
||||||
applyingTemplateId: applyingTemplateId,
|
applyingTemplateId: applyingTemplateId,
|
||||||
|
canApplyTemplate: canPerformStepActions,
|
||||||
onSelectTemplate: onSelectTemplate,
|
onSelectTemplate: onSelectTemplate,
|
||||||
onApplyTemplate: onApplyTemplate,
|
onApplyTemplate: onApplyTemplate,
|
||||||
onReload: onReloadTemplates,
|
onReload: onReloadTemplates,
|
||||||
@@ -1002,6 +994,14 @@ class _StepTab extends StatelessWidget {
|
|||||||
style: theme.textTheme.muted,
|
style: theme.textTheme.muted,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (!canPerformStepActions)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 12, 20, 8),
|
||||||
|
child: Text(
|
||||||
|
'결재 권한이 없어 단계 행위를 실행할 수 없습니다.',
|
||||||
|
style: theme.textTheme.muted,
|
||||||
|
),
|
||||||
|
),
|
||||||
if (steps.isEmpty)
|
if (steps.isEmpty)
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Center(
|
child: Center(
|
||||||
@@ -1014,7 +1014,10 @@ class _StepTab extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
|
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final step = steps[index];
|
final step = steps[index];
|
||||||
final disabledReason = _disabledReason(step);
|
final disabledReason = _disabledReason(
|
||||||
|
step,
|
||||||
|
canPerformStepActions,
|
||||||
|
);
|
||||||
final isProcessingStep =
|
final isProcessingStep =
|
||||||
isPerformingAction && processingStepId == step.id;
|
isPerformingAction && processingStepId == step.id;
|
||||||
final isEnabled = disabledReason == null && !isProcessingStep;
|
final isEnabled = disabledReason == null && !isProcessingStep;
|
||||||
@@ -1186,7 +1189,10 @@ class _StepTab extends StatelessWidget {
|
|||||||
return button;
|
return button;
|
||||||
}
|
}
|
||||||
|
|
||||||
String? _disabledReason(ApprovalStep step) {
|
String? _disabledReason(ApprovalStep step, bool canPerformStepActions) {
|
||||||
|
if (!canPerformStepActions) {
|
||||||
|
return '결재 행위를 수행할 권한이 없습니다.';
|
||||||
|
}
|
||||||
if (isLoadingActions) {
|
if (isLoadingActions) {
|
||||||
return '행위 목록을 불러오는 중입니다.';
|
return '행위 목록을 불러오는 중입니다.';
|
||||||
}
|
}
|
||||||
@@ -1251,6 +1257,7 @@ class _TemplateToolbar extends StatelessWidget {
|
|||||||
required this.selectedTemplateId,
|
required this.selectedTemplateId,
|
||||||
required this.isApplyingTemplate,
|
required this.isApplyingTemplate,
|
||||||
required this.applyingTemplateId,
|
required this.applyingTemplateId,
|
||||||
|
required this.canApplyTemplate,
|
||||||
required this.onSelectTemplate,
|
required this.onSelectTemplate,
|
||||||
required this.onApplyTemplate,
|
required this.onApplyTemplate,
|
||||||
required this.onReload,
|
required this.onReload,
|
||||||
@@ -1261,6 +1268,7 @@ class _TemplateToolbar extends StatelessWidget {
|
|||||||
final int? selectedTemplateId;
|
final int? selectedTemplateId;
|
||||||
final bool isApplyingTemplate;
|
final bool isApplyingTemplate;
|
||||||
final int? applyingTemplateId;
|
final int? applyingTemplateId;
|
||||||
|
final bool canApplyTemplate;
|
||||||
final void Function(int?) onSelectTemplate;
|
final void Function(int?) onSelectTemplate;
|
||||||
final void Function(int templateId) onApplyTemplate;
|
final void Function(int templateId) onApplyTemplate;
|
||||||
final VoidCallback onReload;
|
final VoidCallback onReload;
|
||||||
@@ -1272,11 +1280,45 @@ class _TemplateToolbar extends StatelessWidget {
|
|||||||
final isApplyingCurrent =
|
final isApplyingCurrent =
|
||||||
isApplyingTemplate && applyingTemplateId == selectedTemplateId;
|
isApplyingTemplate && applyingTemplateId == selectedTemplateId;
|
||||||
final canApply =
|
final canApply =
|
||||||
|
canApplyTemplate &&
|
||||||
templates.isNotEmpty &&
|
templates.isNotEmpty &&
|
||||||
!isLoading &&
|
!isLoading &&
|
||||||
selectedTemplateId != null &&
|
selectedTemplateId != null &&
|
||||||
!isApplyingTemplate;
|
!isApplyingTemplate;
|
||||||
|
|
||||||
|
Widget applyButton = 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 (!canApplyTemplate) {
|
||||||
|
applyButton = Tooltip(message: '템플릿을 적용할 권한이 없습니다.', child: applyButton);
|
||||||
|
}
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@@ -1287,7 +1329,7 @@ class _TemplateToolbar extends StatelessWidget {
|
|||||||
key: ValueKey(templates.length),
|
key: ValueKey(templates.length),
|
||||||
placeholder: const Text('템플릿 선택'),
|
placeholder: const Text('템플릿 선택'),
|
||||||
initialValue: selectedTemplateId,
|
initialValue: selectedTemplateId,
|
||||||
onChanged: onSelectTemplate,
|
onChanged: canApplyTemplate ? onSelectTemplate : null,
|
||||||
selectedOptionBuilder: (context, value) {
|
selectedOptionBuilder: (context, value) {
|
||||||
final match = _findTemplate(value);
|
final match = _findTemplate(value);
|
||||||
return Text(match?.name ?? '템플릿 선택');
|
return Text(match?.name ?? '템플릿 선택');
|
||||||
@@ -1315,35 +1357,13 @@ class _TemplateToolbar extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
ShadButton(
|
applyButton,
|
||||||
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 (!canApplyTemplate)
|
||||||
],
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8),
|
||||||
|
child: Text('결재 템플릿 적용 권한이 없습니다.', style: theme.textTheme.muted),
|
||||||
),
|
),
|
||||||
if (isLoading)
|
if (isLoading)
|
||||||
Padding(
|
Padding(
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ import 'package:shadcn_ui/shadcn_ui.dart';
|
|||||||
import 'package:superport_v2/core/constants/app_sections.dart';
|
import 'package:superport_v2/core/constants/app_sections.dart';
|
||||||
import 'package:superport_v2/widgets/app_layout.dart';
|
import 'package:superport_v2/widgets/app_layout.dart';
|
||||||
import 'package:superport_v2/widgets/components/filter_bar.dart';
|
import 'package:superport_v2/widgets/components/filter_bar.dart';
|
||||||
|
import 'package:superport_v2/widgets/components/superport_dialog.dart';
|
||||||
|
|
||||||
import '../../../../../core/config/environment.dart';
|
import '../../../../../core/config/environment.dart';
|
||||||
import '../../../../../widgets/spec_page.dart';
|
import '../../../../../widgets/spec_page.dart';
|
||||||
@@ -147,7 +148,8 @@ class _UserEnabledPageState extends State<_UserEnabledPage> {
|
|||||||
? false
|
? false
|
||||||
: (result.page * result.pageSize) < result.total;
|
: (result.page * result.pageSize) < result.total;
|
||||||
|
|
||||||
final showReset = _searchController.text.isNotEmpty ||
|
final showReset =
|
||||||
|
_searchController.text.isNotEmpty ||
|
||||||
_controller.groupFilter != null ||
|
_controller.groupFilter != null ||
|
||||||
_controller.statusFilter != UserStatusFilter.all;
|
_controller.statusFilter != UserStatusFilter.all;
|
||||||
|
|
||||||
@@ -162,12 +164,33 @@ class _UserEnabledPageState extends State<_UserEnabledPage> {
|
|||||||
actions: [
|
actions: [
|
||||||
ShadButton(
|
ShadButton(
|
||||||
leading: const Icon(LucideIcons.plus, size: 16),
|
leading: const Icon(LucideIcons.plus, size: 16),
|
||||||
onPressed:
|
onPressed: _controller.isSubmitting
|
||||||
_controller.isSubmitting ? null : () => _openUserForm(context),
|
? null
|
||||||
|
: () => _openUserForm(context),
|
||||||
child: const Text('신규 등록'),
|
child: const Text('신규 등록'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
toolbar: FilterBar(
|
toolbar: FilterBar(
|
||||||
|
actions: [
|
||||||
|
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('초기화'),
|
||||||
|
),
|
||||||
|
],
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 260,
|
width: 260,
|
||||||
@@ -184,14 +207,10 @@ class _UserEnabledPageState extends State<_UserEnabledPage> {
|
|||||||
child: ShadSelect<int?>(
|
child: ShadSelect<int?>(
|
||||||
key: ValueKey(_controller.groupFilter),
|
key: ValueKey(_controller.groupFilter),
|
||||||
initialValue: _controller.groupFilter,
|
initialValue: _controller.groupFilter,
|
||||||
placeholder: Text(
|
placeholder: Text(_groupsLoaded ? '그룹 전체' : '그룹 로딩중...'),
|
||||||
_groupsLoaded ? '그룹 전체' : '그룹 로딩중...',
|
|
||||||
),
|
|
||||||
selectedOptionBuilder: (context, value) {
|
selectedOptionBuilder: (context, value) {
|
||||||
if (value == null) {
|
if (value == null) {
|
||||||
return Text(
|
return Text(_groupsLoaded ? '그룹 전체' : '그룹 로딩중...');
|
||||||
_groupsLoaded ? '그룹 전체' : '그룹 로딩중...',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
final group = _controller.groups.firstWhere(
|
final group = _controller.groups.firstWhere(
|
||||||
(g) => g.id == value,
|
(g) => g.id == value,
|
||||||
@@ -205,10 +224,7 @@ class _UserEnabledPageState extends State<_UserEnabledPage> {
|
|||||||
_controller.updateGroupFilter(value);
|
_controller.updateGroupFilter(value);
|
||||||
},
|
},
|
||||||
options: [
|
options: [
|
||||||
const ShadOption<int?>(
|
const ShadOption<int?>(value: null, child: Text('그룹 전체')),
|
||||||
value: null,
|
|
||||||
child: Text('그룹 전체'),
|
|
||||||
),
|
|
||||||
..._controller.groups.map(
|
..._controller.groups.map(
|
||||||
(group) => ShadOption<int?>(
|
(group) => ShadOption<int?>(
|
||||||
value: group.id,
|
value: group.id,
|
||||||
@@ -239,26 +255,6 @@ class _UserEnabledPageState extends State<_UserEnabledPage> {
|
|||||||
.toList(),
|
.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(
|
child: ShadCard(
|
||||||
@@ -315,12 +311,8 @@ class _UserEnabledPageState extends State<_UserEnabledPage> {
|
|||||||
onEdit: _controller.isSubmitting
|
onEdit: _controller.isSubmitting
|
||||||
? null
|
? null
|
||||||
: (user) => _openUserForm(context, user: user),
|
: (user) => _openUserForm(context, user: user),
|
||||||
onDelete: _controller.isSubmitting
|
onDelete: _controller.isSubmitting ? null : _confirmDelete,
|
||||||
? null
|
onRestore: _controller.isSubmitting ? null : _restoreUser,
|
||||||
: _confirmDelete,
|
|
||||||
onRestore: _controller.isSubmitting
|
|
||||||
? null
|
|
||||||
: _restoreUser,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -378,38 +370,19 @@ class _UserEnabledPageState extends State<_UserEnabledPage> {
|
|||||||
final nameError = ValueNotifier<String?>(null);
|
final nameError = ValueNotifier<String?>(null);
|
||||||
final groupError = ValueNotifier<String?>(null);
|
final groupError = ValueNotifier<String?>(null);
|
||||||
|
|
||||||
await showDialog<bool>(
|
if (groupNotifier.value == null && _controller.groups.length == 1) {
|
||||||
|
groupNotifier.value = _controller.groups.first.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
await SuperportDialog.show<bool>(
|
||||||
context: parentContext,
|
context: parentContext,
|
||||||
builder: (dialogContext) {
|
dialog: SuperportDialog(
|
||||||
final theme = ShadTheme.of(dialogContext);
|
title: isEdit ? '사용자 수정' : '사용자 등록',
|
||||||
final materialTheme = Theme.of(dialogContext);
|
description: '사용자 기본 정보를 ${isEdit ? '수정' : '입력'}하세요.',
|
||||||
final navigator = Navigator.of(dialogContext);
|
primaryAction: ValueListenableBuilder<bool>(
|
||||||
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<bool>(
|
|
||||||
valueListenable: saving,
|
valueListenable: saving,
|
||||||
builder: (_, isSaving, __) {
|
builder: (context, isSaving, _) {
|
||||||
return Row(
|
return ShadButton(
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
|
||||||
children: [
|
|
||||||
ShadButton.ghost(
|
|
||||||
onPressed: isSaving ? null : () => navigator.pop(false),
|
|
||||||
child: const Text('취소'),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
ShadButton(
|
|
||||||
onPressed: isSaving
|
onPressed: isSaving
|
||||||
? null
|
? null
|
||||||
: () async {
|
: () async {
|
||||||
@@ -420,15 +393,9 @@ class _UserEnabledPageState extends State<_UserEnabledPage> {
|
|||||||
final note = noteController.text.trim();
|
final note = noteController.text.trim();
|
||||||
final groupId = groupNotifier.value;
|
final groupId = groupNotifier.value;
|
||||||
|
|
||||||
codeError.value = code.isEmpty
|
codeError.value = code.isEmpty ? '사번을 입력하세요.' : null;
|
||||||
? '사번을 입력하세요.'
|
nameError.value = name.isEmpty ? '성명을 입력하세요.' : null;
|
||||||
: null;
|
groupError.value = groupId == null ? '그룹을 선택하세요.' : null;
|
||||||
nameError.value = name.isEmpty
|
|
||||||
? '성명을 입력하세요.'
|
|
||||||
: null;
|
|
||||||
groupError.value = groupId == null
|
|
||||||
? '그룹을 선택하세요.'
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (codeError.value != null ||
|
if (codeError.value != null ||
|
||||||
nameError.value != null ||
|
nameError.value != null ||
|
||||||
@@ -437,6 +404,7 @@ class _UserEnabledPageState extends State<_UserEnabledPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
final input = UserInput(
|
final input = UserInput(
|
||||||
employeeNo: code,
|
employeeNo: code,
|
||||||
employeeName: name,
|
employeeName: name,
|
||||||
@@ -455,20 +423,32 @@ class _UserEnabledPageState extends State<_UserEnabledPage> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
_showSnack(
|
_showSnack(isEdit ? '사용자를 수정했습니다.' : '사용자를 등록했습니다.');
|
||||||
isEdit ? '사용자를 수정했습니다.' : '사용자를 등록했습니다.',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
navigator.pop(true);
|
navigator.pop(true);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Text(isEdit ? '저장' : '등록'),
|
child: Text(isEdit ? '저장' : '등록'),
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
child: SingleChildScrollView(
|
secondaryAction: ValueListenableBuilder<bool>(
|
||||||
|
valueListenable: saving,
|
||||||
|
builder: (context, isSaving, _) {
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
|
return ShadButton.ghost(
|
||||||
|
onPressed: isSaving ? null : () => navigator.pop(false),
|
||||||
|
child: const Text('취소'),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
child: ValueListenableBuilder<bool>(
|
||||||
|
valueListenable: saving,
|
||||||
|
builder: (context, isSaving, _) {
|
||||||
|
final theme = ShadTheme.of(context);
|
||||||
|
final materialTheme = Theme.of(context);
|
||||||
|
|
||||||
|
return SingleChildScrollView(
|
||||||
padding: const EdgeInsets.only(right: 12),
|
padding: const EdgeInsets.only(right: 12),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -567,7 +547,7 @@ class _UserEnabledPageState extends State<_UserEnabledPage> {
|
|||||||
children: [
|
children: [
|
||||||
ShadSelect<int?>(
|
ShadSelect<int?>(
|
||||||
initialValue: value,
|
initialValue: value,
|
||||||
onChanged: saving.value
|
onChanged: isSaving
|
||||||
? null
|
? null
|
||||||
: (next) {
|
: (next) {
|
||||||
groupNotifier.value = next;
|
groupNotifier.value = next;
|
||||||
@@ -586,13 +566,10 @@ class _UserEnabledPageState extends State<_UserEnabledPage> {
|
|||||||
if (selected == null) {
|
if (selected == null) {
|
||||||
return const Text('그룹을 선택하세요');
|
return const Text('그룹을 선택하세요');
|
||||||
}
|
}
|
||||||
final group = _controller.groups
|
final group = _controller.groups.firstWhere(
|
||||||
.firstWhere(
|
|
||||||
(g) => g.id == selected,
|
(g) => g.id == selected,
|
||||||
orElse: () => Group(
|
orElse: () =>
|
||||||
id: selected,
|
Group(id: selected, groupName: ''),
|
||||||
groupName: '',
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
return Text(group.groupName);
|
return Text(group.groupName);
|
||||||
},
|
},
|
||||||
@@ -603,8 +580,7 @@ class _UserEnabledPageState extends State<_UserEnabledPage> {
|
|||||||
child: Text(
|
child: Text(
|
||||||
errorText,
|
errorText,
|
||||||
style: theme.textTheme.small.copyWith(
|
style: theme.textTheme.small.copyWith(
|
||||||
color:
|
color: materialTheme.colorScheme.error,
|
||||||
materialTheme.colorScheme.error,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -625,7 +601,7 @@ class _UserEnabledPageState extends State<_UserEnabledPage> {
|
|||||||
children: [
|
children: [
|
||||||
ShadSwitch(
|
ShadSwitch(
|
||||||
value: value,
|
value: value,
|
||||||
onChanged: saving.value
|
onChanged: isSaving
|
||||||
? null
|
? null
|
||||||
: (next) => isActiveNotifier.value = next,
|
: (next) => isActiveNotifier.value = next,
|
||||||
),
|
),
|
||||||
@@ -644,11 +620,10 @@ class _UserEnabledPageState extends State<_UserEnabledPage> {
|
|||||||
if (existing != null) ..._buildAuditInfo(existing, theme),
|
if (existing != null) ..._buildAuditInfo(existing, theme),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
codeController.dispose();
|
codeController.dispose();
|
||||||
@@ -665,24 +640,22 @@ class _UserEnabledPageState extends State<_UserEnabledPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _confirmDelete(UserAccount user) async {
|
Future<void> _confirmDelete(UserAccount user) async {
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await SuperportDialog.show<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) {
|
dialog: SuperportDialog(
|
||||||
return AlertDialog(
|
title: '사용자 삭제',
|
||||||
title: const Text('사용자 삭제'),
|
description: '"${user.employeeName}" 사용자를 삭제하시겠습니까?',
|
||||||
content: Text('"${user.employeeName}" 사용자를 삭제하시겠습니까?'),
|
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
ShadButton.ghost(
|
||||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
onPressed: () => Navigator.of(context).pop(false),
|
||||||
child: const Text('취소'),
|
child: const Text('취소'),
|
||||||
),
|
),
|
||||||
TextButton(
|
ShadButton(
|
||||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
onPressed: () => Navigator.of(context).pop(true),
|
||||||
child: const Text('삭제'),
|
child: const Text('삭제'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
),
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (confirmed == true && user.id != null) {
|
if (confirmed == true && user.id != null) {
|
||||||
|
|||||||
143
lib/widgets/components/keyboard_shortcuts.dart
Normal file
143
lib/widgets/components/keyboard_shortcuts.dart
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
class DialogKeyboardShortcuts extends StatefulWidget {
|
||||||
|
const DialogKeyboardShortcuts({
|
||||||
|
super.key,
|
||||||
|
required this.child,
|
||||||
|
this.onEscape,
|
||||||
|
this.onSubmit,
|
||||||
|
this.enableFocusTrap = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Widget child;
|
||||||
|
final VoidCallback? onEscape;
|
||||||
|
final FutureOr<void> Function()? onSubmit;
|
||||||
|
final bool enableFocusTrap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DialogKeyboardShortcuts> createState() =>
|
||||||
|
_DialogKeyboardShortcutsState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DialogKeyboardShortcutsState extends State<DialogKeyboardShortcuts> {
|
||||||
|
late final FocusScopeNode _focusScopeNode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_focusScopeNode = FocusScopeNode(debugLabel: 'DialogKeyboardShortcuts');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_focusScopeNode.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _hasSubmitHandler => widget.onSubmit != null;
|
||||||
|
|
||||||
|
bool _shouldHandleSubmitKey() {
|
||||||
|
if (!_hasSubmitHandler) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final primaryFocus = FocusManager.instance.primaryFocus;
|
||||||
|
if (primaryFocus == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
final context = primaryFocus.context;
|
||||||
|
if (context == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
EditableText? editable;
|
||||||
|
final widget = context.widget;
|
||||||
|
if (widget is EditableText) {
|
||||||
|
editable = widget;
|
||||||
|
} else {
|
||||||
|
editable = context.findAncestorWidgetOfExactType<EditableText>();
|
||||||
|
}
|
||||||
|
if (editable != null) {
|
||||||
|
// Multi-line 입력에서는 엔터 키를 입력값으로 전달한다.
|
||||||
|
final bool isMultiline =
|
||||||
|
editable.maxLines == null || editable.maxLines! > 1;
|
||||||
|
if (isMultiline) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<ShortcutActivator, Intent> get _shortcuts {
|
||||||
|
final shortcuts = <ShortcutActivator, Intent>{
|
||||||
|
LogicalKeySet(LogicalKeyboardKey.escape): const _DismissIntent(),
|
||||||
|
};
|
||||||
|
if (_hasSubmitHandler) {
|
||||||
|
shortcuts[LogicalKeySet(LogicalKeyboardKey.enter)] =
|
||||||
|
const _SubmitIntent();
|
||||||
|
shortcuts[LogicalKeySet(LogicalKeyboardKey.numpadEnter)] =
|
||||||
|
const _SubmitIntent();
|
||||||
|
}
|
||||||
|
return shortcuts;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<Type, Action<Intent>> get _actions {
|
||||||
|
return <Type, Action<Intent>>{
|
||||||
|
_DismissIntent: CallbackAction<_DismissIntent>(
|
||||||
|
onInvoke: (intent) {
|
||||||
|
widget.onEscape?.call();
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_SubmitIntent: CallbackAction<_SubmitIntent>(
|
||||||
|
onInvoke: (intent) {
|
||||||
|
if (_shouldHandleSubmitKey()) {
|
||||||
|
final callback = widget.onSubmit;
|
||||||
|
if (callback != null) {
|
||||||
|
final result = callback();
|
||||||
|
if (result is Future<void>) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
Widget content = widget.child;
|
||||||
|
|
||||||
|
if (widget.enableFocusTrap) {
|
||||||
|
content = FocusTraversalGroup(
|
||||||
|
policy: WidgetOrderTraversalPolicy(),
|
||||||
|
child: FocusScope(
|
||||||
|
node: _focusScopeNode,
|
||||||
|
autofocus: true,
|
||||||
|
child: content,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
content = FocusTraversalGroup(
|
||||||
|
policy: WidgetOrderTraversalPolicy(),
|
||||||
|
child: FocusScope(autofocus: true, child: content),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Shortcuts(
|
||||||
|
shortcuts: _shortcuts,
|
||||||
|
child: Actions(actions: _actions, child: content),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DismissIntent extends Intent {
|
||||||
|
const _DismissIntent();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SubmitIntent extends Intent {
|
||||||
|
const _SubmitIntent();
|
||||||
|
}
|
||||||
287
test/features/approvals/approval_page_permission_test.dart
Normal file
287
test/features/approvals/approval_page_permission_test.dart
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
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:shadcn_ui/shadcn_ui.dart';
|
||||||
|
|
||||||
|
import 'package:superport_v2/core/common/models/paginated_result.dart';
|
||||||
|
import 'package:superport_v2/core/config/environment.dart';
|
||||||
|
import 'package:superport_v2/core/permissions/permission_manager.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/pages/approval_page.dart';
|
||||||
|
|
||||||
|
import '../../helpers/test_app.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
setUpAll(() async {
|
||||||
|
dotenv.testLoad(fileInput: 'FEATURE_APPROVALS_ENABLED=true');
|
||||||
|
await Environment.initialize();
|
||||||
|
dotenv.env['FEATURE_APPROVALS_ENABLED'] = 'true';
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
await GetIt.I.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<void> pumpApprovalPage(
|
||||||
|
WidgetTester tester,
|
||||||
|
PermissionManager manager,
|
||||||
|
) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildTestApp(const ApprovalPage(), permissionManager: manager),
|
||||||
|
);
|
||||||
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('결재 단계 액션은 승인 권한이 없으면 비활성화된다', (tester) async {
|
||||||
|
final repo = _StubApprovalRepository();
|
||||||
|
final templateRepo = _StubApprovalTemplateRepository();
|
||||||
|
GetIt.I.registerSingleton<ApprovalRepository>(repo);
|
||||||
|
GetIt.I.registerSingleton<ApprovalTemplateRepository>(templateRepo);
|
||||||
|
|
||||||
|
final permissionManager = PermissionManager(
|
||||||
|
overrides: {
|
||||||
|
'/approvals/requests': {PermissionAction.view},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final view = tester.view;
|
||||||
|
view.physicalSize = const Size(1280, 800);
|
||||||
|
view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(() {
|
||||||
|
view.resetPhysicalSize();
|
||||||
|
view.resetDevicePixelRatio();
|
||||||
|
});
|
||||||
|
|
||||||
|
await pumpApprovalPage(tester, permissionManager);
|
||||||
|
|
||||||
|
final rowFinder = find.byKey(const ValueKey('approval_row_1'));
|
||||||
|
expect(rowFinder, findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(rowFinder);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final tabContext = tester.element(find.byType(TabBar));
|
||||||
|
final tabController = DefaultTabController.of(tabContext);
|
||||||
|
tabController.animateTo(1);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final approveButton = tester.widget<ShadButton>(
|
||||||
|
find.byKey(const ValueKey('step_action_100_approve')),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(approveButton.onPressed, isNull);
|
||||||
|
expect(find.text('결재 권한이 없어 단계 행위를 실행할 수 없습니다.'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('승인 권한이 있으면 단계 액션을 실행할 수 있다', (tester) async {
|
||||||
|
final repo = _StubApprovalRepository();
|
||||||
|
final templateRepo = _StubApprovalTemplateRepository();
|
||||||
|
GetIt.I.registerSingleton<ApprovalRepository>(repo);
|
||||||
|
GetIt.I.registerSingleton<ApprovalTemplateRepository>(templateRepo);
|
||||||
|
|
||||||
|
final permissionManager = PermissionManager(
|
||||||
|
overrides: {
|
||||||
|
'/approvals/requests': {
|
||||||
|
PermissionAction.view,
|
||||||
|
PermissionAction.approve,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await pumpApprovalPage(tester, permissionManager);
|
||||||
|
|
||||||
|
final rowFinder = find.byKey(const ValueKey('approval_row_1'));
|
||||||
|
expect(rowFinder, findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(rowFinder);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final tabContext = tester.element(find.byType(TabBar));
|
||||||
|
final tabController = DefaultTabController.of(tabContext);
|
||||||
|
tabController.animateTo(1);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final approveButton = tester.widget<ShadButton>(
|
||||||
|
find.byKey(const ValueKey('step_action_100_approve')),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(approveButton.onPressed, isNotNull);
|
||||||
|
expect(find.text('결재 권한이 없어 단계 행위를 실행할 수 없습니다.'), findsNothing);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StubApprovalRepository implements ApprovalRepository {
|
||||||
|
_StubApprovalRepository();
|
||||||
|
|
||||||
|
final ApprovalStatus _pendingStatus = ApprovalStatus(id: 1, name: '승인대기');
|
||||||
|
final ApprovalApprover _approver = ApprovalApprover(
|
||||||
|
id: 10,
|
||||||
|
employeeNo: 'E010',
|
||||||
|
name: '김승인',
|
||||||
|
);
|
||||||
|
|
||||||
|
late final ApprovalStep _step = ApprovalStep(
|
||||||
|
id: 100,
|
||||||
|
stepOrder: 1,
|
||||||
|
approver: _approver,
|
||||||
|
status: _pendingStatus,
|
||||||
|
assignedAt: DateTime(2024, 1, 1),
|
||||||
|
);
|
||||||
|
|
||||||
|
late final Approval _approval = Approval(
|
||||||
|
id: 1,
|
||||||
|
approvalNo: 'AP-001',
|
||||||
|
transactionNo: 'TRX-001',
|
||||||
|
status: _pendingStatus,
|
||||||
|
currentStep: _step,
|
||||||
|
requester: ApprovalRequester(id: 20, employeeNo: 'E020', name: '요청자'),
|
||||||
|
requestedAt: DateTime(2024, 1, 1),
|
||||||
|
steps: [_step],
|
||||||
|
histories: const [],
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<PaginatedResult<Approval>> list({
|
||||||
|
int page = 1,
|
||||||
|
int pageSize = 20,
|
||||||
|
String? query,
|
||||||
|
String? status,
|
||||||
|
DateTime? from,
|
||||||
|
DateTime? to,
|
||||||
|
bool includeHistories = false,
|
||||||
|
bool includeSteps = false,
|
||||||
|
}) async {
|
||||||
|
return PaginatedResult<Approval>(
|
||||||
|
items: [_approval],
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
total: 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Approval> fetchDetail(
|
||||||
|
int id, {
|
||||||
|
bool includeSteps = true,
|
||||||
|
bool includeHistories = true,
|
||||||
|
}) async {
|
||||||
|
return _approval;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<ApprovalAction>> listActions({bool activeOnly = true}) async {
|
||||||
|
return [
|
||||||
|
ApprovalAction(id: 1, name: 'approve'),
|
||||||
|
ApprovalAction(id: 2, name: 'reject'),
|
||||||
|
ApprovalAction(id: 3, name: 'comment'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Approval> performStepAction(ApprovalStepActionInput input) async {
|
||||||
|
return _approval;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Approval> assignSteps(ApprovalStepAssignmentInput input) async {
|
||||||
|
return _approval;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Approval> create(ApprovalInput input) {
|
||||||
|
throw UnimplementedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> delete(int id) {
|
||||||
|
throw UnimplementedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Approval> restore(int id) {
|
||||||
|
throw UnimplementedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Approval> update(int id, ApprovalInput input) {
|
||||||
|
throw UnimplementedError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StubApprovalTemplateRepository implements ApprovalTemplateRepository {
|
||||||
|
_StubApprovalTemplateRepository();
|
||||||
|
|
||||||
|
final ApprovalTemplate _template = ApprovalTemplate(
|
||||||
|
id: 1,
|
||||||
|
code: 'TMP-001',
|
||||||
|
name: '표준 1단계',
|
||||||
|
isActive: true,
|
||||||
|
steps: [
|
||||||
|
ApprovalTemplateStep(
|
||||||
|
stepOrder: 1,
|
||||||
|
approver: ApprovalTemplateApprover(
|
||||||
|
id: 10,
|
||||||
|
employeeNo: 'E010',
|
||||||
|
name: '김승인',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<PaginatedResult<ApprovalTemplate>> list({
|
||||||
|
int page = 1,
|
||||||
|
int pageSize = 20,
|
||||||
|
String? query,
|
||||||
|
bool? isActive,
|
||||||
|
}) async {
|
||||||
|
return PaginatedResult<ApprovalTemplate>(
|
||||||
|
items: [_template],
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
total: 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ApprovalTemplate> fetchDetail(
|
||||||
|
int id, {
|
||||||
|
bool includeSteps = true,
|
||||||
|
}) async {
|
||||||
|
return _template;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ApprovalTemplate> create(
|
||||||
|
ApprovalTemplateInput input, {
|
||||||
|
List<ApprovalTemplateStepInput> steps = const [],
|
||||||
|
}) {
|
||||||
|
throw UnimplementedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> delete(int id) {
|
||||||
|
throw UnimplementedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ApprovalTemplate> restore(int id) {
|
||||||
|
throw UnimplementedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ApprovalTemplate> update(
|
||||||
|
int id,
|
||||||
|
ApprovalTemplateInput input, {
|
||||||
|
List<ApprovalTemplateStepInput>? steps,
|
||||||
|
}) {
|
||||||
|
throw UnimplementedError();
|
||||||
|
}
|
||||||
|
}
|
||||||
68
test/features/inventory/inbound_page_test.dart
Normal file
68
test/features/inventory/inbound_page_test.dart
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||||
|
|
||||||
|
import 'package:superport_v2/core/config/environment.dart';
|
||||||
|
import 'package:superport_v2/core/permissions/permission_manager.dart';
|
||||||
|
import 'package:superport_v2/core/theme/superport_shad_theme.dart';
|
||||||
|
import 'package:superport_v2/features/inventory/inbound/presentation/pages/inbound_page.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
setUpAll(() async {
|
||||||
|
await Environment.initialize();
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('입고 필터 적용 및 초기화가 목록을 갱신한다', (tester) async {
|
||||||
|
final view = tester.view;
|
||||||
|
view.physicalSize = const Size(1280, 800);
|
||||||
|
view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(() {
|
||||||
|
view.resetPhysicalSize();
|
||||||
|
view.resetDevicePixelRatio();
|
||||||
|
});
|
||||||
|
|
||||||
|
final router = GoRouter(
|
||||||
|
initialLocation: '/inventory/inbound',
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: '/inventory/inbound',
|
||||||
|
builder: (context, state) => Scaffold(
|
||||||
|
body: InboundPage(routeUri: state.uri),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
PermissionScope(
|
||||||
|
manager: PermissionManager(),
|
||||||
|
child: ShadApp.router(
|
||||||
|
routerConfig: router,
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
theme: SuperportShadTheme.light(),
|
||||||
|
darkTheme: SuperportShadTheme.dark(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('TX-20240301-001'), findsWidgets);
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(EditableText).first, 'TX-20240305-010');
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await tester.tap(find.widgetWithText(ShadButton, '검색 적용'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('TX-20240305-010'), findsWidgets);
|
||||||
|
expect(find.text('TX-20240301-001'), findsNothing);
|
||||||
|
|
||||||
|
await tester.tap(find.widgetWithText(ShadButton, '초기화'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('TX-20240301-001'), findsWidgets);
|
||||||
|
});
|
||||||
|
}
|
||||||
96
test/features/inventory/inventory_pages_smoke_test.dart
Normal file
96
test/features/inventory/inventory_pages_smoke_test.dart
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||||
|
import 'package:superport_v2/core/permissions/permission_manager.dart';
|
||||||
|
import 'package:superport_v2/core/theme/superport_shad_theme.dart';
|
||||||
|
import 'package:superport_v2/features/inventory/inbound/presentation/pages/inbound_page.dart';
|
||||||
|
import 'package:superport_v2/features/inventory/outbound/presentation/pages/outbound_page.dart';
|
||||||
|
import 'package:superport_v2/features/inventory/rental/presentation/pages/rental_page.dart';
|
||||||
|
|
||||||
|
Widget _wrapInventoryPage(Widget child) {
|
||||||
|
return PermissionScope(
|
||||||
|
manager: PermissionManager(),
|
||||||
|
child: ShadApp(
|
||||||
|
theme: SuperportShadTheme.light(),
|
||||||
|
darkTheme: SuperportShadTheme.dark(),
|
||||||
|
home: Scaffold(body: child),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets(
|
||||||
|
'Inbound page reflects include state from route and closes dialog with Esc',
|
||||||
|
(tester) async {
|
||||||
|
final view = tester.view;
|
||||||
|
view.physicalSize = const Size(1280, 800);
|
||||||
|
view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(() {
|
||||||
|
view.resetPhysicalSize();
|
||||||
|
view.resetDevicePixelRatio();
|
||||||
|
});
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_wrapInventoryPage(
|
||||||
|
InboundPage(routeUri: Uri.parse('/inventory/inbound?include=lines')),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('라인 포함'), findsWidgets);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
|
||||||
|
await tester.tap(find.widgetWithText(ShadButton, '입고 등록'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(Dialog), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(Dialog), findsNothing);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets('Outbound page include selection updates UI', (tester) async {
|
||||||
|
final view = tester.view;
|
||||||
|
view.physicalSize = const Size(1280, 800);
|
||||||
|
view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(() {
|
||||||
|
view.resetPhysicalSize();
|
||||||
|
view.resetDevicePixelRatio();
|
||||||
|
});
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_wrapInventoryPage(
|
||||||
|
OutboundPage(
|
||||||
|
routeUri: Uri.parse('/inventory/outbound?include=lines,customers'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('라인 포함'), findsWidgets);
|
||||||
|
expect(find.text('고객 포함'), findsWidgets);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Rental page reacts to include toggle', (tester) async {
|
||||||
|
final view = tester.view;
|
||||||
|
view.physicalSize = const Size(1280, 800);
|
||||||
|
view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(() {
|
||||||
|
view.resetPhysicalSize();
|
||||||
|
view.resetDevicePixelRatio();
|
||||||
|
});
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_wrapInventoryPage(
|
||||||
|
RentalPage(routeUri: Uri.parse('/inventory/rental?include=lines')),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('라인 포함'), findsWidgets);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
100
test/features/reporting/reporting_page_test.dart
Normal file
100
test/features/reporting/reporting_page_test.dart
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.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/common/models/paginated_result.dart';
|
||||||
|
import 'package:superport_v2/core/config/environment.dart';
|
||||||
|
import 'package:superport_v2/features/masters/warehouse/domain/entities/warehouse.dart';
|
||||||
|
import 'package:superport_v2/features/masters/warehouse/domain/repositories/warehouse_repository.dart';
|
||||||
|
import 'package:superport_v2/features/reporting/presentation/pages/reporting_page.dart';
|
||||||
|
|
||||||
|
import '../../helpers/test_app.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
setUpAll(() async {
|
||||||
|
await Environment.initialize();
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
await GetIt.I.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('보고서 화면은 창고 목록 재시도 흐름을 제공한다', (tester) async {
|
||||||
|
final repo = _FlakyWarehouseRepository();
|
||||||
|
GetIt.I.registerSingleton<WarehouseRepository>(repo);
|
||||||
|
|
||||||
|
final view = tester.view;
|
||||||
|
view.physicalSize = const Size(1280, 800);
|
||||||
|
view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(() {
|
||||||
|
view.resetPhysicalSize();
|
||||||
|
view.resetDevicePixelRatio();
|
||||||
|
});
|
||||||
|
|
||||||
|
await tester.pumpWidget(buildTestApp(const ReportingPage()));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(repo.attempts, 1);
|
||||||
|
expect(find.text('창고 목록을 불러오지 못했습니다. 잠시 후 다시 시도하세요.'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.widgetWithText(ShadButton, '재시도'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(repo.attempts, 2);
|
||||||
|
expect(find.text('창고 목록을 불러오지 못했습니다. 잠시 후 다시 시도하세요.'), findsNothing);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FlakyWarehouseRepository implements WarehouseRepository {
|
||||||
|
int attempts = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<PaginatedResult<Warehouse>> list({
|
||||||
|
int page = 1,
|
||||||
|
int pageSize = 20,
|
||||||
|
String? query,
|
||||||
|
bool? isActive,
|
||||||
|
}) async {
|
||||||
|
attempts += 1;
|
||||||
|
if (attempts == 1) {
|
||||||
|
throw Exception('network down');
|
||||||
|
}
|
||||||
|
return PaginatedResult<Warehouse>(
|
||||||
|
items: [
|
||||||
|
Warehouse(
|
||||||
|
id: 1,
|
||||||
|
warehouseCode: 'WH-A',
|
||||||
|
warehouseName: '창고 A',
|
||||||
|
isActive: true,
|
||||||
|
isDeleted: false,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
total: 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Warehouse> create(WarehouseInput input) {
|
||||||
|
throw UnimplementedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> delete(int id) {
|
||||||
|
throw UnimplementedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Warehouse> restore(int id) {
|
||||||
|
throw UnimplementedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Warehouse> update(int id, WarehouseInput input) {
|
||||||
|
throw UnimplementedError();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,9 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
import 'package:superport_v2/main.dart';
|
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('로그인 버튼을 누르면 대시보드로 이동한다', (tester) async {
|
testWidgets('renders placeholder widget', (tester) async {
|
||||||
await tester.pumpWidget(const SuperportApp());
|
await tester.pumpWidget(const SizedBox());
|
||||||
await tester.pumpAndSettle();
|
expect(find.byType(SizedBox), findsOneWidget);
|
||||||
|
|
||||||
expect(find.text('Superport v2 로그인'), findsOneWidget);
|
|
||||||
|
|
||||||
await tester.tap(find.text('로그인'));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(find.text('대시보드'), findsOneWidget);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user