From 437d5f683495f3d6f3cd67d7e38fc2ec36466202 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Tue, 10 Feb 2026 16:01:23 +0900 Subject: [PATCH] =?UTF-8?q?refactor(WEB):=20SearchableSelectionModal=20?= =?UTF-8?q?=EA=B3=B5=ED=86=B5=ED=99=94=20=EB=B0=8F=20actions=20lookup=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SearchableSelectionModal 제네릭 컴포넌트 추출 (organisms) - 검색 모달 5개 리팩토링: SupplierSearch, QuotationSelect, SalesOrderSelect, OrderSelect, ItemSearch - shared-lookups API 유틸 추가 (거래처/품목/수주 등 공통 조회) - create-crud-service 확장 (lookup, search 메서드) - actions.ts 20+개 파일 lookup 패턴 통일 - 공통 페이지 패턴 가이드 문서 추가 - CLAUDE.md Common Component Usage Rules 추가 Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 21 + .../[PLAN-2026-02-06] refactoring-roadmap.md | 121 ++-- .../guides/[GUIDE] common-page-patterns.md | 522 ++++++++++++++++++ .../accounting/BadDebtCollection/actions.ts | 11 +- .../BankTransactionInquiry/actions.ts | 9 +- .../accounting/BillManagement/actions.ts | 9 +- .../CardTransactionInquiry/actions.ts | 9 +- .../accounting/DepositManagement/actions.ts | 30 +- .../ExpectedExpenseManagement/actions.ts | 20 +- .../accounting/PurchaseManagement/actions.ts | 32 +- .../accounting/VendorLedger/actions.ts | 9 +- .../WithdrawalManagement/actions.ts | 30 +- .../approval/ApprovalBox/actions.ts | 11 +- src/components/approval/DraftBox/actions.ts | 11 +- .../approval/ReferenceBox/actions.ts | 11 +- src/components/attendance/actions.ts | 9 +- .../hr/AttendanceManagement/actions.ts | 17 +- .../hr/EmployeeManagement/actions.ts | 11 +- .../hr/VacationManagement/actions.ts | 19 +- .../SupplierSearchModal.tsx | 200 ++----- .../material/ReceivingManagement/actions.ts | 9 +- .../material/StockStatus/actions.ts | 9 +- .../orders/QuotationSelectDialog.tsx | 233 +++----- src/components/orders/actions.ts | 13 +- .../SearchableSelectionModal.tsx | 252 +++++++++ .../SearchableSelectionModal/index.ts | 6 + .../SearchableSelectionModal/types.ts | 84 +++ .../useSearchableData.ts | 119 ++++ src/components/organisms/index.ts | 2 + .../outbound/ShipmentManagement/actions.ts | 9 +- src/components/process-management/actions.ts | 11 +- .../WorkOrders/SalesOrderSelectModal.tsx | 172 ++---- .../InspectionManagement/OrderSelectModal.tsx | 236 +++----- src/components/quotes/ItemSearchModal.tsx | 241 +++----- .../settings/AccountManagement/actions.ts | 9 +- .../PaymentHistoryManagement/actions.ts | 9 +- .../settings/PopupManagement/actions.ts | 15 +- .../settings/TitleManagement/actions.ts | 91 +-- src/lib/api/create-crud-service.ts | 45 +- src/lib/api/index.ts | 17 + src/lib/api/shared-lookups.ts | 86 +++ src/lib/api/types.ts | 47 ++ 42 files changed, 1683 insertions(+), 1144 deletions(-) create mode 100644 claudedocs/guides/[GUIDE] common-page-patterns.md create mode 100644 src/components/organisms/SearchableSelectionModal/SearchableSelectionModal.tsx create mode 100644 src/components/organisms/SearchableSelectionModal/index.ts create mode 100644 src/components/organisms/SearchableSelectionModal/types.ts create mode 100644 src/components/organisms/SearchableSelectionModal/useSearchableData.ts create mode 100644 src/lib/api/shared-lookups.ts create mode 100644 src/lib/api/types.ts diff --git a/CLAUDE.md b/CLAUDE.md index fb02e6c1..5e76d64b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -271,6 +271,27 @@ const [data, setData] = useState(() => { --- +## Common Component Usage Rules +**Priority**: 🔴 + +신규 페이지/모달 작업 시 **반드시** 공통 패턴 가이드를 먼저 읽고 기존 구조를 따를 것. + +**트리거 → 가이드 읽기:** +| 작업 유형 | 읽을 파일 | +|-----------|----------| +| 검색 모달/선택 팝업 | `claudedocs/guides/[GUIDE] common-page-patterns.md` → "검색 모달" 섹션 | +| 리스트/목록 페이지 | `claudedocs/guides/[GUIDE] common-page-patterns.md` → "리스트 페이지" 섹션 | +| 상세/수정/등록 페이지 | `claudedocs/guides/[GUIDE] common-page-patterns.md` → "상세/폼 페이지" 섹션 | +| 새 organisms 필요 | `src/components/organisms/index.ts` 먼저 확인 → 없으면 생성 | + +**핵심 원칙:** +- 새 파일 만들기 전 `organisms/`, `molecules/` export 목록 확인 +- 검색+선택 모달 → `SearchableSelectionModal` 사용 (직접 Dialog 조합 금지) +- 리스트 페이지 → `UniversalListPage` 또는 organisms 조합 +- 상세/폼 → Card + 기존 패턴 따르기 + +--- + ## User Environment **Priority**: 🟢 diff --git a/claudedocs/architecture/[PLAN-2026-02-06] refactoring-roadmap.md b/claudedocs/architecture/[PLAN-2026-02-06] refactoring-roadmap.md index 763a9c00..432fbd97 100644 --- a/claudedocs/architecture/[PLAN-2026-02-06] refactoring-roadmap.md +++ b/claudedocs/architecture/[PLAN-2026-02-06] refactoring-roadmap.md @@ -2,7 +2,7 @@ **작성일**: 2026-02-06 **목적**: 전체 코드베이스 리팩토링 포인트 점검 및 실행 계획 -**상태**: Phase 1 완료, Phase 3 프로토타입 검증 완료 +**상태**: Phase 1 완료, Phase 3 완료 (공용 유틸 추출), Phase 4 SearchableSelectionModal 완료 --- @@ -263,55 +263,99 @@ UniversalListPage 템플릿이 이미 내부 처리 → 불필요 판정. --- -### Phase 3: 액션 파일 제네릭화 (2-3주) `프론트 단독` `프로토타입 검증 완료` +### Phase 3: 액션 파일 공용 유틸 추출 ✅ 완료 (2026-02-10) -> CRUD 서비스 팩토리 생성 + actions.ts 점진적 마이그레이션 +> 전수 분석 → 팩토리 ROI 재평가 → 공용 유틸 추출로 전략 변경 -**프로토타입 결과** (2026-02-09): +**전수 분석 결과** (82개 action 파일): ``` -- [x] createCrudService 팩토리 구현 (src/lib/api/create-crud-service.ts) -- [x] RankManagement/actions.ts 마이그레이션 (111줄 → 77줄, -31%) -- [x] Server Action 호환성 검증 완료 (5/5 CRUD 정상 동작) -- [x] 래퍼 함수 방식 채택 (Next.js Server Action 인식 보장) +- 35개: executeServerAction 패턴 (Phase 1에서 통일) +- 15개: 모의 데이터 (mock, API 미연동) +- 13개: ApiClient 클래스 패턴 (건설 도메인) +- 나머지: 특수 도메인 로직 (견적, 수주, 품목 등) ``` -**검증된 사실**: -- Server Action + 팩토리 패턴 호환성 문제 없음 -- 래퍼 함수 필요 (직접 re-export는 미검증) -- Tier 분류: Tier 1 정형 CRUD (~60%, 100% 적용) / Tier 2 CRUD+특수 (~25%, 부분 적용) / Tier 3 복잡 도메인 (~15%, 미적용) - -**남은 작업**: +**팩토리 마이그레이션 ROI 재평가**: ``` -- [ ] Tier 1 settings 도메인 마이그레이션 (8개) -- [ ] Tier 1 기타 정형 CRUD 마이그레이션 (~40개) -- [ ] Tier 2 CRUD 부분 팩토리 적용 (~20개) -- [ ] PositionApiData 등 공통 API 타입 추출 +- createCrudService 팩토리: 2개(Rank, Title)만 적합 → ROI ~6% (너무 낮음) +- 대부분 파일: 페이지네이션, 커스텀 쿼리 파라미터, 도메인 특화 로직으로 팩토리 패턴 부적합 +- 결론: 팩토리 대량 마이그레이션 대신 공용 유틸 추출로 전략 전환 ``` -**예상 효과**: Tier 1 기준 ~31% 코드 감소, 새 도메인 추가 시간 30분→5분 +**실행 결과** (2026-02-10): +``` +Step 1: 공용 타입 추출 (src/lib/api/types.ts) + - [x] PaginatedApiResponse — 25+ 파일에서 중복 정의 제거 + - [x] PaginationMeta, PaginatedResult — 프론트엔드 표준 페이지네이션 타입 + - [x] toPaginationMeta() — snake_case → camelCase 변환 헬퍼 + - [x] SelectOption — 공용 선택 옵션 타입 + +Step 2: 공용 룩업 헬퍼 추출 (src/lib/api/shared-lookups.ts) + - [x] fetchVendorOptions() — 거래처 목록 조회 (4개 파일 중복 제거) + - [x] fetchBankAccountOptions() — 계좌 목록 조회 (심플) + - [x] fetchBankAccountDetailOptions() — 계좌 상세 조회 (bankName, accountNumber 포함) + - [x] BankAccountOption 타입 + +Step 3: PaginatedResponse 타입 마이그레이션 (~20개 파일) + - [x] 제네릭 패턴 (interface PaginatedResponse) → import PaginatedApiResponse + - [x] 도메인 패턴 (interface XxxPaginatedResponse) → type alias + - 스킵: VendorManagement/types.ts (page?/size? 비표준), PermissionManagement/types.ts (meta 래퍼) + +Step 4: 공용 룩업 헬퍼 마이그레이션 (4개 파일) + - [x] DepositManagement/actions.ts — getVendors + getBankAccounts 교체 + - [x] WithdrawalManagement/actions.ts — getVendors + getBankAccounts 교체 + - [x] PurchaseManagement/actions.ts — getVendors + getBankAccounts(상세) 교체 + - [x] ExpectedExpenseManagement/actions.ts — getBankAccounts(상세) 교체 + +Step 5: TypeScript 검증 통과 ✅ +``` + +**실측 효과**: +- PaginatedResponse 중복 제거: ~20개 파일, 파일당 ~7줄 = ~140줄 절감 +- 공용 룩업 헬퍼: 4개 파일, 파일당 ~20줄 = ~80줄 절감 +- 총 ~220줄 직접 절감 + 향후 새 파일에서 중복 방지 +- createCrudService + TitleManagement 마이그레이션: ~36줄 절감 (프로토타입 포함) + +**생성된 공용 파일**: +- `src/lib/api/types.ts` — 공용 API 타입 (PaginatedApiResponse, PaginationMeta 등) +- `src/lib/api/shared-lookups.ts` — 공용 룩업 헬퍼 (fetchVendorOptions 등) +- `src/lib/api/create-crud-service.ts` — CRUD 팩토리 (Rank, Title 2개 사용) --- -### Phase 4: 템플릿/패턴 통일 (2-3주) `프론트 단독` +### Phase 4: 템플릿/패턴 통일 (2-3주) `프론트 단독` `SearchableSelectionModal 완료` > UniversalListPage 확대 + 검증 표준화 + 모달 통합 -**작업 항목**: +**SearchableSelectionModal 완료** (2026-02-10): +``` +- [x] SearchableSelectionModal 공통 컴포넌트 생성 + - types.ts, useSearchableData.ts, SearchableSelectionModal.tsx, index.ts + - 단일선택(single) + 다중선택(multiple) + listWrapper(테이블용) 지원 +- [x] ItemSearchModal 교체 (212→113줄, -47%) +- [x] SupplierSearchModal 교체 (268→161줄, -40%) +- [x] SalesOrderSelectModal 교체 (163→101줄, -38%) +- [x] QuotationSelectDialog 교체 (196→113줄, -42%) +- [x] OrderSelectModal 교체 (220→107줄, -51%) +- [x] organisms/index.ts export 추가 +- [x] CLAUDE.md 공통 컴포넌트 사용 규칙 + claudedocs 가이드 문서 작성 +``` +**실측 효과**: 1,059줄 → 595줄 (464줄 절감, -44%) + 공통 컴포넌트 ~430줄 + +**남은 작업**: ``` - [ ] UniversalListPage 기능 보강 - 고급 필터 UI - 컬럼 커스터마이징 - 내보내기 기능 - [ ] 레거시 리스트 페이지 → UniversalListPage 마이그레이션 (우선 20개) -- [ ] SearchableSelectionModal 공통 컴포넌트 생성 - - ItemSearchModal, AssigneeSelectModal 등 5개 통합 - [ ] Zod 검증 스키마 라이브러리 구축 - lib/validations/common.ts (이메일, 전화, 사업자번호) - lib/validations/vendor.ts, order.ts, item.ts 등 - [ ] 수동 검증 50+ 폼 → Zod 마이그레이션 (우선 10개) ``` -**예상 효과**: ~5,000줄 절감, UX 일관성 +80% +**예상 효과**: ~5,000줄 절감 (SearchableSelectionModal ~464줄 달성), UX 일관성 +80% --- @@ -337,7 +381,8 @@ UniversalListPage 템플릿이 이미 내부 처리 → 불필요 판정. - dev/ 프로토타입 6건 (비프로덕션) - [ ] 공통 타입 라이브러리 정리 - types/shared/ 폴더 생성 - - ApiResponse, PaginatedResponse, FormState 등 + - PaginatedApiResponse ✅ Phase 3에서 완료 (src/lib/api/types.ts) + - FormState, SelectOption 등 추가 타입 ``` **예상 효과**: 리스트 렌더링 30-50% 개선, 타입 안전성 +60% @@ -346,12 +391,13 @@ UniversalListPage 템플릿이 이미 내부 처리 → 불필요 판정. ## 전체 예상 효과 요약 -| 지표 | Phase 1 ✅ | Phase 2 | Phase 3 | Phase 4 | Phase 5 | 합계 | +| 지표 | Phase 1 ✅ | Phase 2 | Phase 3 ✅ | Phase 4 | Phase 5 | 합계 | |------|-----------|---------|---------|---------|---------|------| -| 코드 절감 | ~3,750줄 (실측) | (구조 개선) | ~3,300줄 (실측 기반 추정) | ~5,000줄 | (품질 개선) | **~12,000줄+** | -| 패턴 일관성 | +60% | +50% | +40% | +80% | +60% | 종합 개선 | -| 유지보수성 | 높음 | 매우 높음 | 높음 | 중간 | 중간 | 종합 개선 | -| 위험도 | 낮음 | 중간 | 낮음 (검증됨) | 낮음 | 낮음 | - | +| 코드 절감 | ~3,750줄 (실측) | (구조 개선) | ~256줄 (실측) | ~5,000줄 | (품질 개선) | **~9,000줄+** | +| 중복 제거 | 82개 action 통일 | - | 25+ 타입 + 4 룩업 통합 | 5 모달 통합 | - | 종합 개선 | +| 패턴 일관성 | +60% | +50% | +30% (타입 표준화) | +80% | +60% | 종합 개선 | +| 유지보수성 | 높음 | 매우 높음 | 중간 (공용 유틸) | 중간 | 중간 | 종합 개선 | +| 위험도 | 낮음 | 중간 | 낮음 (완료) | 낮음 | 낮음 | - | --- @@ -359,15 +405,14 @@ UniversalListPage 템플릿이 이미 내부 처리 → 불필요 판정. ``` [완료] -├─ Phase 1 (공통 훅) ──→ ✅ 완료 (2026-02-09) +├─ Phase 1 (공통 훅) ──────→ ✅ 완료 (2026-02-09) +├─ Phase 3 (공용 유틸 추출) ──→ ✅ 완료 (2026-02-10) +├─ Phase 4 (SearchableSelectionModal) → ✅ 완료 (2026-02-10) │ [즉시 시작 가능] -├─ Phase 2 (God 컴포넌트 분리) ──→ Phase 1 훅 활용 -├─ Phase 3 (액션 제네릭화) ────→ 프로토타입 검증 완료, 본격 마이그레이션 가능 -├─ Phase 5 (성능/타입) ─────→ 일부 Phase 1에서 선처리됨 -│ -[Phase 3 완료 후] -└─ Phase 4 (템플릿 통일) ─────→ 훅 + 서비스 활용 +├─ Phase 2 (God 컴포넌트 분리) ──→ Phase 1 훅 + Phase 3 공용 타입 활용 +├─ Phase 4 남은 작업 (UniversalListPage 확대, Zod 검증) +├─ Phase 5 (성능/타입) ─────→ 일부 Phase 1/3에서 선처리됨 ``` --- @@ -393,6 +438,8 @@ UniversalListPage 템플릿이 이미 내부 처리 → 불필요 판정. | 2026-02-06 | 초기 작성 - 전체 코드베이스 분석 기반 5 Phase 로드맵 | | 2026-02-09 | Phase 1 완료 반영 - 실측 기반 효과 수치 보정 (8,500줄→3,750줄), executeServerAction/useDeleteDialog/useStatsLoader 3개 훅 생성 완료 | | 2026-02-09 | Phase 3 프로토타입 검증 완료 - createCrudService 팩토리 생성, RankManagement 5/5 CRUD 정상, Server Action 호환성 확인 | +| 2026-02-10 | Phase 4 SearchableSelectionModal 완료 - 5개 모달 통합, 464줄 절감(-44%), 가이드 문서 작성 | +| 2026-02-10 | Phase 3 완료 - 전수 분석 후 팩토리 ROI 재평가(~6%), 공용 유틸 추출로 전략 전환. PaginatedApiResponse 25+파일 타입 통합, 공용 룩업 헬퍼 4파일 중복 제거, ~256줄 절감 | --- diff --git a/claudedocs/guides/[GUIDE] common-page-patterns.md b/claudedocs/guides/[GUIDE] common-page-patterns.md new file mode 100644 index 00000000..067f76c7 --- /dev/null +++ b/claudedocs/guides/[GUIDE] common-page-patterns.md @@ -0,0 +1,522 @@ +# SAM 프로젝트 공통 페이지/컴포넌트 패턴 가이드 + +신규 페이지·모달 작업 시 이 문서를 참고하여 기존 구조와 일관성을 유지한다. + +--- + +## 목차 + +1. [공통 컴포넌트 맵](#1-공통-컴포넌트-맵) +2. [검색 모달 (SearchableSelectionModal)](#2-검색-모달) +3. [리스트 페이지](#3-리스트-페이지) +4. [상세/폼 페이지](#4-상세폼-페이지) +5. [API 연동 패턴](#5-api-연동-패턴) +6. [페이지 라우팅 구조](#6-페이지-라우팅-구조) + +--- + +## 1. 공통 컴포넌트 맵 + +### Organisms (`src/components/organisms/`) + +| 컴포넌트 | 용도 | 주요 Props | +|----------|------|-----------| +| `PageHeader` | 페이지 제목, 설명, 아이콘, 액션 버튼 | title, description, icon, actions | +| `PageLayout` | 최대 너비 래퍼 + 버전 정보 | children, maxWidth? | +| `StatCards` | 통계 카드 그리드 | stats[], onStatClick? | +| `SearchFilter` | 검색 입력 + 모바일 필터 | searchTerm, onSearchChange, placeholder | +| `DataTable` | 테이블 + 페이지네이션 + 정렬 | columns, renderRow, pagination | +| `MobileCard` / `ListMobileCard` | 모바일 카드 레이아웃 | id, title, infoGrid, badges | +| `EmptyState` | 빈 상태 (아이콘 + 메시지 + 액션) | icon, title, description, action | +| `FormSection` | 카드 래퍼 (아이콘 + 제목 + 설명) | icon, title, description | +| `FormFieldGrid` | 반응형 필드 그리드 (1~4열) | cols, children | +| `FormActions` | 저장/취소 버튼 그룹 | onSave, onCancel, isSaving | +| **`SearchableSelectionModal`** | **검색 → 목록 → 선택 모달** | **fetchData, renderItem, mode** | + +### Molecules (`src/components/molecules/`) + +| 컴포넌트 | 용도 | +|----------|------| +| `StatusBadge` | 상태 뱃지 (색상 자동) | +| `TableActions` | 테이블 행 액션 버튼 | +| `StandardDialog` / `ConfirmDialog` | 확인/경고 다이얼로그 | +| `YearQuarterFilter` | 연도/분기 필터 | +| `MobileFilter` | 모바일 필터 UI | + +### Templates (`src/components/templates/`) + +| 컴포넌트 | 용도 | +|----------|------| +| `UniversalListPage` | 리스트 페이지 올인원 템플릿 | + +--- + +## 2. 검색 모달 + +### 언제 사용하나 + +"검색 → 목록 → 선택" 패턴이 필요할 때 → `SearchableSelectionModal` 사용. +Dialog + Input + 리스트를 직접 조합하지 않는다. + +### 위치 + +``` +src/components/organisms/SearchableSelectionModal/ +├── SearchableSelectionModal.tsx — 메인 컴포넌트 +├── useSearchableData.ts — 검색+로딩 훅 +├── types.ts — Props 인터페이스 +└── index.ts +``` + +### 핵심 Props + +```typescript +SearchableSelectionModal + // 필수 + open: boolean + onOpenChange: (open: boolean) => void + title: ReactNode + fetchData: (query: string) => Promise // API 호출 위임 + keyExtractor: (item: T) => string + renderItem: (item: T, isSelected: boolean) => ReactNode + mode: 'single' | 'multiple' + onSelect: single → (item: T) | multiple → (items: T[]) + + // 검색 설정 + searchPlaceholder?: string + searchMode?: 'debounce' | 'enter' // 기본: debounce + validateSearch?: (q: string) => boolean // 유효성 검사 + loadOnOpen?: boolean // 열릴 때 자동 로드 + + // 메시지 + emptyQueryMessage?: string + invalidSearchMessage?: string + noResultMessage?: string + loadingMessage?: string + + // 레이아웃 + dialogClassName?: string + listContainerClassName?: string + listWrapper?: (children, selectState?) => ReactNode // Table 등 커스텀 구조 + infoText?: (items, isLoading) => ReactNode + + // 다중선택 전용 + confirmLabel?: string + allowSelectAll?: boolean +``` + +### 패턴별 예제 + +#### A. 단일선택 + 디바운스 검색 (가장 일반적) + +```tsx +// 품목 검색, 거래처 검색 등 + + open={open} + onOpenChange={setOpen} + title="품목 검색" + searchPlaceholder="품목코드 또는 품목명 검색..." + fetchData={async (q) => fetchItems({ search: q, per_page: 50 })} + keyExtractor={(item) => item.id} + validateSearch={(q) => /[a-zA-Z가-힣0-9]/.test(q)} + emptyQueryMessage="검색어를 입력하세요" + dialogClassName="sm:max-w-[500px]" + mode="single" + onSelect={(item) => { /* 선택 처리 */ }} + renderItem={(item) => ( +
+ {item.code} + {item.name} +
+ )} +/> +``` + +#### B. 단일선택 + 카드 UI + 열릴 때 자동 로드 + +```tsx +// 수주 선택, 견적 선택 등 + + open={open} + onOpenChange={setOpen} + title="수주 선택" + fetchData={async (q) => { /* API 호출 + toast 에러 처리 */ }} + keyExtractor={(order) => order.id} + loadOnOpen // ← 열릴 때 전체 로드 + dialogClassName="sm:max-w-lg" + listContainerClassName="max-h-[400px] overflow-y-auto space-y-2" + mode="single" + onSelect={onSelect} + renderItem={(order) => ( +
+ {/* 카드형 UI */} +
+ )} +/> +``` + +#### C. 다중선택 + Enter 검색 + 테이블 + +```tsx +// 수주 다중선택 (체크박스 테이블) + + open={open} + onOpenChange={setOpen} + title="수주 선택" + fetchData={handleFetchData} + keyExtractor={(item) => item.id} + searchMode="enter" // ← 수동 검색 + loadOnOpen + dialogClassName="sm:max-w-2xl" + listContainerClassName="max-h-[400px] overflow-y-auto border rounded-md" + mode="multiple" + onSelect={onSelect} + confirmLabel="선택" + allowSelectAll + listWrapper={(children, selectState) => ( // ← Table 구조 래핑 + + + + + {selectState && ( + + )} + + 수주번호 + 현장명 + + + {children} +
+ )} + renderItem={(item, isSelected) => ( + + e.stopPropagation()}> + + + {item.orderNumber} + {item.siteName} + + )} +/> +``` + +### 기존 모달 → 공통 컴포넌트 매핑 + +| 기존 모달 | 위치 | 패턴 | +|-----------|------|------| +| `ItemSearchModal` | quotes/ | A (단일 + 디바운스) | +| `SupplierSearchModal` | material/ReceivingManagement/ | A (단일 + 디바운스) | +| `SalesOrderSelectModal` | production/WorkOrders/ | B (단일 + 카드 + loadOnOpen) | +| `QuotationSelectDialog` | orders/ | B (단일 + 카드 + loadOnOpen) | +| `OrderSelectModal` | quality/InspectionManagement/ | C (다중 + Enter + 테이블) | + +--- + +## 3. 리스트 페이지 + +### 방법 1: UniversalListPage 템플릿 (권장) + +`src/components/templates/UniversalListPage`에 config 객체를 전달하는 올인원 방식. + +```tsx +'use client'; + +import { UniversalListPage } from '@/components/templates/UniversalListPage'; +import type { UniversalListPageConfig } from '@/components/templates/UniversalListPage'; + +export default function MyListPage() { + const config: UniversalListPageConfig = { + title: '목록 제목', + description: '설명', + icon: ListIcon, + basePath: '/path/to/list', + idField: 'id', + + // 통계 + stats: [ + { label: '전체', value: totalCount, icon: Users }, + ], + + // 탭 + tabs: [ + { value: 'all', label: '전체', count: totalCount }, + { value: 'active', label: '활성', count: activeCount }, + ], + + // 테이블 컬럼 + columns: [ + { key: 'name', label: '이름' }, + { key: 'status', label: '상태' }, + ], + + // 검색 + searchPlaceholder: '이름, 코드 검색...', + searchFilter: (item, q) => item.name.includes(q), + tabFilter: (item, tab) => tab === 'all' || item.status === tab, + + // 렌더링 + renderTableRow, + renderMobileCard, + headerActions: () => , + }; + + return ; +} +``` + +### 방법 2: Organisms 직접 조합 + +UniversalListPage가 맞지 않는 경우 organisms를 직접 조합. + +```tsx +'use client'; + +import { PageLayout, PageHeader, StatCards, SearchFilter, DataTable, EmptyState } from '@/components/organisms'; + +export function MyList() { + return ( + + 신규} /> + + + {data.length > 0 ? ( + + ) : ( + + )} + + ); +} +``` + +### 리스트 페이지 공통 규칙 + +- **검색 디바운스**: 300ms +- **테이블 컬럼 순서**: 체크박스 → 번호 → 데이터 컬럼 → 작업 +- **번호 계산**: `(currentPage - 1) * pageSize + index + 1` +- **모바일**: `ListMobileCard` 또는 `MobileCard` 사용 +- **빈 상태**: `EmptyState` 사용 (검색 결과 없음 vs 데이터 없음 구분) +- **삭제 확인**: `ConfirmDialog` 사용 (alert 금지) + +--- + +## 4. 상세/폼 페이지 + +### 표준 구조 + +```tsx +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { useRouter } from 'next/navigation'; +import { toast } from 'sonner'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; + +interface DetailProps { + id?: string; + mode: 'view' | 'edit' | 'new'; +} + +export function MyDetail({ id, mode }: DetailProps) { + const isViewMode = mode === 'view'; + const isNewMode = mode === 'new'; + const router = useRouter(); + + // 상태 (모든 hook은 최상단에 — 조건부 return 전에) + const [isLoading, setIsLoading] = useState(!isNewMode); + const [isSaving, setIsSaving] = useState(false); + const [formData, setFormData] = useState({ name: '', code: '' }); + + // 데이터 로드 (view/edit) + useEffect(() => { + if (!id || isNewMode) { setIsLoading(false); return; } + getDetail(id).then(data => { + setFormData(data); + setIsLoading(false); + }); + }, [id, isNewMode]); + + // 저장 + const handleSubmit = async () => { + setIsSaving(true); + try { + if (isNewMode) await create(formData); + else await update(id!, formData); + toast.success('저장되었습니다.'); + router.back(); + } catch { + toast.error('저장에 실패했습니다.'); + } finally { + setIsSaving(false); + } + }; + + if (isLoading) return ; + + return ( +
+ {/* 헤더 */} +
+

+ {isNewMode ? '신규 등록' : isViewMode ? '상세 보기' : '수정'} +

+
+ + {/* 섹션 1 */} + + 기본 정보 + +
+
+ + setFormData(prev => ({ ...prev, name: e.target.value }))} + disabled={isViewMode} + /> +
+
+
+
+ + {/* 하단 버튼 */} +
+ + {isViewMode ? ( + + ) : ( + + )} +
+
+ ); +} +``` + +### 상세/폼 페이지 공통 규칙 + +- **모드**: `view` | `edit` | `new` 3가지 +- **Hook 규칙**: 모든 hook은 최상단, 조건부 return은 그 아래 +- **레이아웃**: `Card > CardHeader + CardContent` 섹션 단위 +- **필드 그리드**: `grid grid-cols-1 md:grid-cols-2 gap-4` +- **disabled**: view 모드에서 모든 입력 비활성화 +- **알림**: `toast.success()` / `toast.error()` (sonner) +- **네비게이션**: `router.back()` 또는 `router.push()` +- **로딩**: Skeleton 컴포넌트 사용 +- **Select 버그 대응**: ` setSearchQuery(e.target.value)} - className="pl-10 pr-10" - /> - {searchQuery && ( - - )} - - - {/* 거래처 목록 */} -
- {isLoading ? ( -
- - 거래처 검색 중... -
- ) : error ? ( -
- {error} -
- ) : suppliers.length === 0 ? ( -
- {!searchQuery - ? '한글 1자(완성형) 또는 영문 2자 이상 입력하세요' - : !isValidSearchQuery(searchQuery) - ? '한글 1자(완성형) 또는 영문 2자 이상 입력하세요' - : '검색 결과가 없습니다'} -
- ) : ( -
- {suppliers.map((supplier, index) => ( -
handleSelect(supplier)} - className="p-3 hover:bg-blue-50 cursor-pointer transition-colors" - > -
- {supplier.name} - {supplier.clientCode && ( - - {supplier.clientCode} - - )} -
- {supplier.contactPerson && ( -

담당: {supplier.contactPerson}

- )} -
- ))} -
- )} -
- - {/* 거래처 개수 표시 */} - {!isLoading && !error && ( -
- 총 {suppliers.length}개 거래처 + + open={open} + onOpenChange={onOpenChange} + title="발주처 검색" + searchPlaceholder="거래처명 검색..." + fetchData={handleFetchData} + keyExtractor={(s) => `${s.id}`} + validateSearch={isValidSearchQuery} + invalidSearchMessage="한글 1자(완성형) 또는 영문 2자 이상 입력하세요" + emptyQueryMessage="한글 1자(완성형) 또는 영문 2자 이상 입력하세요" + loadingMessage="거래처 검색 중..." + dialogClassName="sm:max-w-[500px]" + infoText={(items, isLoading) => + !isLoading ? ( + + 총 {items.length}개 거래처 + + ) : null + } + mode="single" + onSelect={handleSelect} + renderItem={(supplier) => ( +
+
+ {supplier.name} + {supplier.clientCode && ( + + {supplier.clientCode} + + )}
- )} - - + {supplier.contactPerson && ( +

담당: {supplier.contactPerson}

+ )} +
+ )} + /> ); -} \ No newline at end of file +} diff --git a/src/components/material/ReceivingManagement/actions.ts b/src/components/material/ReceivingManagement/actions.ts index bc74d8ac..5e17abbb 100644 --- a/src/components/material/ReceivingManagement/actions.ts +++ b/src/components/material/ReceivingManagement/actions.ts @@ -18,6 +18,7 @@ const USE_MOCK_DATA = false; import { serverFetch } from '@/lib/api/fetch-wrapper'; +import type { PaginatedApiResponse } from '@/lib/api/types'; import { isNextRedirectError } from '@/lib/utils/redirect-error'; import { executeServerAction } from '@/lib/api/execute-server-action'; @@ -360,13 +361,7 @@ interface ReceivingApiData { has_inspection_template?: boolean; } -interface ReceivingApiPaginatedResponse { - data: ReceivingApiData[]; - current_page: number; - last_page: number; - per_page: number; - total: number; -} +type ReceivingApiPaginatedResponse = PaginatedApiResponse; interface ReceivingApiStatsResponse { receiving_pending_count: number; diff --git a/src/components/material/StockStatus/actions.ts b/src/components/material/StockStatus/actions.ts index 72d74926..ea912018 100644 --- a/src/components/material/StockStatus/actions.ts +++ b/src/components/material/StockStatus/actions.ts @@ -12,6 +12,7 @@ import { executeServerAction } from '@/lib/api/execute-server-action'; +import type { PaginatedApiResponse } from '@/lib/api/types'; import type { StockItem, StockDetail, @@ -84,13 +85,7 @@ interface StockLotApiData { updated_at?: string; } -interface ItemApiPaginatedResponse { - data: ItemApiData[]; - current_page: number; - last_page: number; - per_page: number; - total: number; -} +type ItemApiPaginatedResponse = PaginatedApiResponse; interface StockApiStatsResponse { total_items: number; diff --git a/src/components/orders/QuotationSelectDialog.tsx b/src/components/orders/QuotationSelectDialog.tsx index acb4c90c..55d07412 100644 --- a/src/components/orders/QuotationSelectDialog.tsx +++ b/src/components/orders/QuotationSelectDialog.tsx @@ -1,25 +1,19 @@ -"use client"; - /** * 견적 선택 팝업 * - * 확정된 견적 목록에서 수주 전환할 견적을 선택하는 다이얼로그 - * API 연동: getQuotesForSelect (FINALIZED 상태 견적만 조회) + * SearchableSelectionModal 공통 컴포넌트 기반 + * 확정된 견적 목록에서 수주 전환할 견적을 선택 */ -import { useState, useEffect, useCallback } from "react"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { Badge } from "@/components/ui/badge"; -import { Search, FileText, Check, Loader2 } from "lucide-react"; -import { formatAmount } from "@/utils/formatAmount"; -import { cn } from "@/lib/utils"; -import { getQuotesForSelect, type QuotationForSelect } from "./actions"; +'use client'; + +import { useCallback } from 'react'; +import { FileText, Check } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { formatAmount } from '@/utils/formatAmount'; +import { cn } from '@/lib/utils'; +import { SearchableSelectionModal } from '@/components/organisms/SearchableSelectionModal'; +import { getQuotesForSelect, type QuotationForSelect } from './actions'; interface QuotationSelectDialogProps { open: boolean; @@ -31,13 +25,13 @@ interface QuotationSelectDialogProps { // 등급 배지 컴포넌트 function GradeBadge({ grade }: { grade: string }) { const config: Record = { - A: { label: "A (우량)", className: "bg-green-100 text-green-700 border-green-200" }, - B: { label: "B (관리)", className: "bg-yellow-100 text-yellow-700 border-yellow-200" }, - C: { label: "C (주의)", className: "bg-red-100 text-red-700 border-red-200" }, + A: { label: 'A (우량)', className: 'bg-green-100 text-green-700 border-green-200' }, + B: { label: 'B (관리)', className: 'bg-yellow-100 text-yellow-700 border-yellow-200' }, + C: { label: 'C (주의)', className: 'bg-red-100 text-red-700 border-red-200' }, }; const cfg = config[grade] || config.B; return ( - + {cfg.label} ); @@ -49,148 +43,71 @@ export function QuotationSelectDialog({ onSelect, selectedId, }: QuotationSelectDialogProps) { - const [searchTerm, setSearchTerm] = useState(""); - const [quotations, setQuotations] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - - // 견적 목록 조회 - const fetchQuotations = useCallback(async (query?: string) => { - setIsLoading(true); - setError(null); - try { - const result = await getQuotesForSelect({ q: query, size: 50 }); - if (result.success && result.data) { - setQuotations(result.data.items); - } else { - setError(result.error || "견적 목록 조회에 실패했습니다."); - setQuotations([]); - } - } catch { - setError("서버 오류가 발생했습니다."); - setQuotations([]); - } finally { - setIsLoading(false); + const handleFetchData = useCallback(async (query: string) => { + const result = await getQuotesForSelect({ q: query || undefined, size: 50 }); + if (result.success && result.data) { + return result.data.items; } + throw new Error(result.error || '견적 목록 조회에 실패했습니다.'); }, []); - // 다이얼로그 열릴 때 데이터 로드 + 검색어 변경 시 디바운스 적용 - useEffect(() => { - if (!open) return; - - // 검색어가 빈 문자열이면 즉시 호출 (다이얼로그 열림 시) - // 검색어가 있으면 디바운스 적용 - const delay = searchTerm === "" ? 0 : 300; - - const timer = setTimeout(() => { - fetchQuotations(searchTerm || undefined); - }, delay); - - return () => clearTimeout(timer); - }, [searchTerm, open, fetchQuotations]); - - const handleSelect = (quotation: QuotationForSelect) => { - onSelect(quotation); - onOpenChange(false); - }; - return ( - - - - - - 견적 선택 - - - - {/* 검색창 */} -
- - setSearchTerm(e.target.value)} - className="pl-9" - /> -
- - {/* 안내 문구 */} -
- {isLoading ? ( - - - 견적 목록을 불러오는 중... - - ) : error ? ( - {error} - ) : ( - `전환 가능한 견적 ${quotations.length}건 (최종확정 상태)` + + open={open} + onOpenChange={onOpenChange} + title={ + + + 견적 선택 + + } + searchPlaceholder="견적번호, 거래처, 현장명 검색..." + fetchData={handleFetchData} + keyExtractor={(q) => q.id} + loadOnOpen + dialogClassName="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col" + listContainerClassName="flex-1 overflow-y-auto space-y-3 pr-2" + infoText={(items, isLoading) => + isLoading + ? null + : `전환 가능한 견적 ${items.length}건 (최종확정 상태)` + } + mode="single" + onSelect={onSelect} + renderItem={(quotation) => ( +
- - {/* 견적 목록 */} -
- {isLoading ? ( -
- + > +
+
+ + {quotation.quoteNumber} + +
- ) : ( - <> - {quotations.map((quotation) => ( -
handleSelect(quotation)} - className={cn( - "p-4 border rounded-lg cursor-pointer transition-colors", - "hover:bg-muted/50 hover:border-primary/50", - selectedId === quotation.id && "border-primary bg-primary/5" - )} - > - {/* 상단: 견적번호 + 등급 */} -
-
- - {quotation.quoteNumber} - - -
- {selectedId === quotation.id && ( - - )} -
- - {/* 발주처 */} -
- {quotation.client} -
- - {/* 현장명 + 금액 */} -
- - [{quotation.siteName}] - - - {formatAmount(quotation.amount)}원 - -
- - {/* 품목 수 */} -
- {quotation.itemCount}개 품목 -
-
- ))} - - {quotations.length === 0 && !error && ( -
- 검색 결과가 없습니다. -
- )} - - )} + {selectedId === quotation.id && ( + + )} +
+
+ {quotation.client} +
+
+ + [{quotation.siteName}] + + + {formatAmount(quotation.amount)}원 + +
+
+ {quotation.itemCount}개 품목 +
- -
+ )} + /> ); -} \ No newline at end of file +} diff --git a/src/components/orders/actions.ts b/src/components/orders/actions.ts index c09dbed9..1e005723 100644 --- a/src/components/orders/actions.ts +++ b/src/components/orders/actions.ts @@ -1,6 +1,7 @@ 'use server'; import { executeServerAction } from '@/lib/api/execute-server-action'; +import type { PaginatedApiResponse } from '@/lib/api/types'; // ============================================================================ // API 타입 정의 @@ -219,14 +220,6 @@ interface ApiResponse { data: T; } -interface PaginatedResponse { - current_page: number; - data: T[]; - last_page: number; - per_page: number; - total: number; -} - // ============================================================================ // Frontend 타입 정의 // ============================================================================ @@ -815,7 +808,7 @@ export async function getOrders(params?: { if (params?.date_from) searchParams.set('date_from', params.date_from); if (params?.date_to) searchParams.set('date_to', params.date_to); - const result = await executeServerAction>({ + const result = await executeServerAction>({ url: `${API_URL}/api/v1/orders?${searchParams.toString()}`, errorMessage: '목록 조회에 실패했습니다.', }); @@ -1179,7 +1172,7 @@ export async function getQuotesForSelect(params?: { if (params?.page) searchParams.set('page', String(params.page)); if (params?.size) searchParams.set('size', String(params.size || 50)); - const result = await executeServerAction>({ + const result = await executeServerAction>({ url: `${API_URL}/api/v1/quotes?${searchParams.toString()}`, errorMessage: '견적 목록 조회에 실패했습니다.', }); diff --git a/src/components/organisms/SearchableSelectionModal/SearchableSelectionModal.tsx b/src/components/organisms/SearchableSelectionModal/SearchableSelectionModal.tsx new file mode 100644 index 00000000..394625be --- /dev/null +++ b/src/components/organisms/SearchableSelectionModal/SearchableSelectionModal.tsx @@ -0,0 +1,252 @@ +'use client'; + +import { useState, useCallback, useEffect } from 'react'; +import { Search, X, Loader2 } from 'lucide-react'; + +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; + +import { useSearchableData } from './useSearchableData'; +import type { SearchableSelectionModalProps } from './types'; + +export function SearchableSelectionModal(props: SearchableSelectionModalProps) { + const { + open, + onOpenChange, + title, + searchPlaceholder = '검색...', + fetchData, + keyExtractor, + renderItem, + searchMode = 'debounce', + debounceDelay = 300, + validateSearch, + invalidSearchMessage, + loadOnOpen = false, + emptyQueryMessage = '검색어를 입력하세요', + noResultMessage = '검색 결과가 없습니다.', + loadingMessage = '검색 중...', + dialogClassName, + listContainerClassName = 'max-h-[400px] overflow-y-auto border rounded-lg', + listWrapper, + infoText, + mode, + } = props; + + const { + searchQuery, + setSearchQuery, + items, + isLoading, + error, + triggerSearch, + handleSearchKeyDown, + } = useSearchableData({ + open, + fetchData, + searchMode, + debounceDelay, + validateSearch, + loadOnOpen, + }); + + // 다중선택 상태 + const [selectedIds, setSelectedIds] = useState>(new Set()); + + // 모달 열릴 때 선택 초기화 + useEffect(() => { + if (open) { + setSelectedIds(new Set()); + } + }, [open]); + + // 단일선택 핸들러 + const handleSingleSelect = useCallback((item: T) => { + if (mode === 'single') { + (props as { onSelect: (item: T) => void }).onSelect(item); + onOpenChange(false); + } + }, [mode, props, onOpenChange]); + + // 다중선택 토글 + const handleToggle = useCallback((id: string) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }, []); + + // 전체선택 토글 + const handleToggleAll = useCallback(() => { + setSelectedIds((prev) => { + if (prev.size === items.length) { + return new Set(); + } + return new Set(items.map((item) => keyExtractor(item))); + }); + }, [items, keyExtractor]); + + // 다중선택 확인 + const handleConfirm = useCallback(() => { + if (mode === 'multiple') { + const selectedItems = items.filter((item) => selectedIds.has(keyExtractor(item))); + (props as { onSelect: (items: T[]) => void }).onSelect(selectedItems); + onOpenChange(false); + } + }, [mode, items, selectedIds, keyExtractor, props, onOpenChange]); + + // 클릭 핸들러: 모드에 따라 분기 + const handleItemClick = useCallback((item: T) => { + if (mode === 'single') { + handleSingleSelect(item); + } else { + handleToggle(keyExtractor(item)); + } + }, [mode, handleSingleSelect, handleToggle, keyExtractor]); + + const isAllSelected = items.length > 0 && selectedIds.size === items.length; + const isSelected = (item: T) => selectedIds.has(keyExtractor(item)); + + // 빈 상태 메시지 결정 + const getEmptyMessage = () => { + if (error) return null; // error는 별도 표시 + if (!searchQuery && !loadOnOpen) return emptyQueryMessage; + if (searchQuery && validateSearch && !validateSearch(searchQuery)) { + return invalidSearchMessage || emptyQueryMessage; + } + return noResultMessage; + }; + + // 리스트 콘텐츠 렌더링 + const renderListContent = () => { + if (isLoading) { + return ( +
+ + {loadingMessage} +
+ ); + } + + if (error) { + return ( +
+ {error} +
+ ); + } + + if (items.length === 0) { + return ( +
+ {getEmptyMessage()} +
+ ); + } + + const itemElements = items.map((item) => ( +
handleItemClick(item)} className="cursor-pointer"> + {renderItem(item, isSelected(item))} +
+ )); + + if (listWrapper) { + const selectState = mode === 'multiple' + ? { isAllSelected, onToggleAll: handleToggleAll } + : undefined; + return listWrapper(<>{itemElements}, selectState); + } + + return
{itemElements}
; + }; + + const multiProps = mode === 'multiple' ? props as Extract : null; + + return ( + + + + {title} + + + {/* 검색 입력 */} + {searchMode === 'enter' ? ( +
+
+ + setSearchQuery(e.target.value)} + onKeyDown={handleSearchKeyDown} + placeholder={searchPlaceholder} + className="pl-9" + /> +
+ +
+ ) : ( +
+ + setSearchQuery(e.target.value)} + className="pl-10 pr-10" + /> + {searchQuery && ( + + )} +
+ )} + + {/* 정보 텍스트 */} + {infoText && ( +
+ {infoText(items, isLoading)} +
+ )} + + {/* 다중선택 헤더 (전체선택 등) */} + {mode === 'multiple' && multiProps?.renderHeader && ( + multiProps.renderHeader({ isAllSelected, onToggleAll: handleToggleAll }) + )} + + {/* 리스트 */} +
+ {renderListContent()} +
+ + {/* 다중선택 푸터 */} + {mode === 'multiple' && ( + + + + + )} +
+
+ ); +} diff --git a/src/components/organisms/SearchableSelectionModal/index.ts b/src/components/organisms/SearchableSelectionModal/index.ts new file mode 100644 index 00000000..5caa3922 --- /dev/null +++ b/src/components/organisms/SearchableSelectionModal/index.ts @@ -0,0 +1,6 @@ +export { SearchableSelectionModal } from './SearchableSelectionModal'; +export type { + SearchableSelectionModalProps, + SingleSelectProps, + MultipleSelectProps, +} from './types'; diff --git a/src/components/organisms/SearchableSelectionModal/types.ts b/src/components/organisms/SearchableSelectionModal/types.ts new file mode 100644 index 00000000..8e42df8c --- /dev/null +++ b/src/components/organisms/SearchableSelectionModal/types.ts @@ -0,0 +1,84 @@ +import { ReactNode } from 'react'; + +// ============================================================================= +// 공통 Props +// ============================================================================= + +interface BaseProps { + /** 모달 열림 상태 */ + open: boolean; + /** 모달 열림/닫힘 제어 */ + onOpenChange: (open: boolean) => void; + /** 모달 제목 */ + title: ReactNode; + /** 검색 placeholder */ + searchPlaceholder?: string; + /** 데이터 조회 함수 (검색어 → 결과 배열) */ + fetchData: (query: string) => Promise; + /** 고유 키 추출 */ + keyExtractor: (item: T) => string; + /** 아이템 렌더링 */ + renderItem: (item: T, isSelected: boolean) => ReactNode; + + // 검색 설정 + /** 검색 모드: debounce(자동) vs enter(수동) */ + searchMode?: 'debounce' | 'enter'; + /** 디바운스 딜레이 (ms) - searchMode='debounce'일 때 */ + debounceDelay?: number; + /** 검색어 유효성 검사 (false면 검색 안 함) */ + validateSearch?: (query: string) => boolean; + /** 유효하지 않은 검색어일 때 메시지 */ + invalidSearchMessage?: string; + /** 모달 열릴 때 자동 로드 여부 */ + loadOnOpen?: boolean; + /** 검색어 없을 때 안내 메시지 */ + emptyQueryMessage?: string; + /** 검색 결과 없을 때 메시지 */ + noResultMessage?: string; + /** 로딩 메시지 */ + loadingMessage?: string; + + // 레이아웃 + /** Dialog 최대 너비 클래스 */ + dialogClassName?: string; + /** 리스트 컨테이너 클래스 */ + listContainerClassName?: string; + /** 리스트 래퍼 (Table 헤더 등 커스텀 구조) */ + listWrapper?: (children: ReactNode, selectState?: { + isAllSelected: boolean; + onToggleAll: () => void; + }) => ReactNode; + /** 푸터 상단 정보 영역 (예: "총 X건") */ + infoText?: (items: T[], isLoading: boolean) => ReactNode; +} + +// ============================================================================= +// 단일 선택 +// ============================================================================= + +export interface SingleSelectProps extends BaseProps { + mode: 'single'; + onSelect: (item: T) => void; +} + +// ============================================================================= +// 다중 선택 +// ============================================================================= + +export interface MultipleSelectProps extends BaseProps { + mode: 'multiple'; + onSelect: (items: T[]) => void; + /** 확인 버튼 라벨 (기본: "선택") */ + confirmLabel?: string; + /** 전체선택 허용 */ + allowSelectAll?: boolean; + /** 헤더 영역 (전체선택 체크박스 등) */ + renderHeader?: (params: { + isAllSelected: boolean; + onToggleAll: () => void; + }) => ReactNode; +} + +export type SearchableSelectionModalProps = + | SingleSelectProps + | MultipleSelectProps; diff --git a/src/components/organisms/SearchableSelectionModal/useSearchableData.ts b/src/components/organisms/SearchableSelectionModal/useSearchableData.ts new file mode 100644 index 00000000..654670e2 --- /dev/null +++ b/src/components/organisms/SearchableSelectionModal/useSearchableData.ts @@ -0,0 +1,119 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; + +interface UseSearchableDataOptions { + open: boolean; + fetchData: (query: string) => Promise; + searchMode: 'debounce' | 'enter'; + debounceDelay: number; + validateSearch?: (query: string) => boolean; + loadOnOpen: boolean; +} + +interface UseSearchableDataReturn { + searchQuery: string; + setSearchQuery: (query: string) => void; + items: T[]; + isLoading: boolean; + error: string | null; + triggerSearch: () => void; + handleSearchKeyDown: (e: React.KeyboardEvent) => void; +} + +export function useSearchableData({ + open, + fetchData, + searchMode, + debounceDelay, + validateSearch, + loadOnOpen, +}: UseSearchableDataOptions): UseSearchableDataReturn { + const [searchQuery, setSearchQuery] = useState(''); + const [items, setItems] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const prevOpenRef = useRef(false); + + // 실제 API 호출 + const doFetch = useCallback(async (query: string) => { + setIsLoading(true); + setError(null); + try { + const data = await fetchData(query); + setItems(data); + } catch (err) { + console.error('[useSearchableData] fetch error:', err); + setError('데이터를 불러오는데 실패했습니다.'); + setItems([]); + } finally { + setIsLoading(false); + } + }, [fetchData]); + + // 모달 열릴 때 초기화 + loadOnOpen + useEffect(() => { + if (open && !prevOpenRef.current) { + // 방금 열림 + setSearchQuery(''); + setError(null); + if (loadOnOpen) { + doFetch(''); + } else { + setItems([]); + } + } + if (!open && prevOpenRef.current) { + // 방금 닫힘 + setItems([]); + setSearchQuery(''); + setError(null); + } + prevOpenRef.current = open; + }, [open, loadOnOpen, doFetch]); + + // 디바운스 모드: 검색어 변경 시 자동 검색 + useEffect(() => { + if (!open || searchMode !== 'debounce') return; + + // 검색어가 비어있고 loadOnOpen이면 이미 로드됨 → 스킵 + if (!searchQuery && loadOnOpen) return; + + // 검색어가 비어있고 loadOnOpen이 아니면 → 결과 초기화 + if (!searchQuery && !loadOnOpen) { + setItems([]); + return; + } + + // 유효성 검사 + if (validateSearch && !validateSearch(searchQuery)) { + setItems([]); + return; + } + + const timer = setTimeout(() => { + doFetch(searchQuery); + }, debounceDelay); + + return () => clearTimeout(timer); + }, [searchQuery, open, searchMode, debounceDelay, validateSearch, doFetch, loadOnOpen]); + + // 수동 검색 트리거 (enter 모드) + const triggerSearch = useCallback(() => { + doFetch(searchQuery); + }, [searchQuery, doFetch]); + + const handleSearchKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + triggerSearch(); + } + }, [triggerSearch]); + + return { + searchQuery, + setSearchQuery, + items, + isLoading, + error, + triggerSearch, + handleSearchKeyDown, + }; +} diff --git a/src/components/organisms/index.ts b/src/components/organisms/index.ts index 4051ef19..19dd1193 100644 --- a/src/components/organisms/index.ts +++ b/src/components/organisms/index.ts @@ -8,3 +8,5 @@ export { MobileCard, ListMobileCard, InfoField } from "./MobileCard"; export type { MobileCardProps, InfoFieldProps } from "./MobileCard"; export { EmptyState } from "./EmptyState"; export { ScreenVersionHistory } from "./ScreenVersionHistory"; +export { SearchableSelectionModal } from "./SearchableSelectionModal"; +export type { SearchableSelectionModalProps, SingleSelectProps, MultipleSelectProps } from "./SearchableSelectionModal"; diff --git a/src/components/outbound/ShipmentManagement/actions.ts b/src/components/outbound/ShipmentManagement/actions.ts index 50b59976..9dc54c7a 100644 --- a/src/components/outbound/ShipmentManagement/actions.ts +++ b/src/components/outbound/ShipmentManagement/actions.ts @@ -19,6 +19,7 @@ import { executeServerAction } from '@/lib/api/execute-server-action'; +import type { PaginatedApiResponse } from '@/lib/api/types'; import type { ShipmentItem, ShipmentDetail, @@ -111,13 +112,7 @@ interface ShipmentItemApiData { remarks?: string; } -interface ShipmentApiPaginatedResponse { - data: ShipmentApiData[]; - current_page: number; - last_page: number; - per_page: number; - total: number; -} +type ShipmentApiPaginatedResponse = PaginatedApiResponse; interface ShipmentApiStatsResponse { today_shipment_count: number; diff --git a/src/components/process-management/actions.ts b/src/components/process-management/actions.ts index 14c1bcaa..bd32738c 100644 --- a/src/components/process-management/actions.ts +++ b/src/components/process-management/actions.ts @@ -2,6 +2,7 @@ import { executeServerAction } from '@/lib/api/execute-server-action'; +import type { PaginatedApiResponse } from '@/lib/api/types'; import type { Process, ProcessFormData, ClassificationRule, IndividualItem, ProcessStep } from '@/types/process'; // ============================================================================ @@ -65,14 +66,6 @@ interface ApiResponse { data: T; } -interface PaginatedResponse { - current_page: number; - data: T[]; - last_page: number; - per_page: number; - total: number; -} - // ============================================================================ // 데이터 변환 함수 // ============================================================================ @@ -228,7 +221,7 @@ export async function getProcessList(params?: { if (params?.status) searchParams.set('status', params.status); if (params?.process_type) searchParams.set('process_type', params.process_type); - const result = await executeServerAction>({ + const result = await executeServerAction>({ url: `${API_URL}/api/v1/processes?${searchParams.toString()}`, errorMessage: '공정 목록 조회에 실패했습니다.', }); diff --git a/src/components/production/WorkOrders/SalesOrderSelectModal.tsx b/src/components/production/WorkOrders/SalesOrderSelectModal.tsx index 3740ac11..8e69acdb 100644 --- a/src/components/production/WorkOrders/SalesOrderSelectModal.tsx +++ b/src/components/production/WorkOrders/SalesOrderSelectModal.tsx @@ -1,38 +1,19 @@ -'use client'; - /** * 수주 선택 모달 - * API 연동 완료 (2025-12-26) + * + * SearchableSelectionModal 공통 컴포넌트 기반 */ -import { useState, useEffect, useCallback } from 'react'; -import { Search, FileText } from 'lucide-react'; -import { ContentSkeleton } from '@/components/ui/skeleton'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; -import { Input } from '@/components/ui/input'; +'use client'; + +import { useCallback } from 'react'; import { Badge } from '@/components/ui/badge'; import { toast } from 'sonner'; import { isNextRedirectError } from '@/lib/utils/redirect-error'; +import { SearchableSelectionModal } from '@/components/organisms/SearchableSelectionModal'; import { getSalesOrdersForWorkOrder } from './actions'; import type { SalesOrder } from './types'; -// Debounce 훅 -function useDebounce(value: T, delay: number): T { - const [debouncedValue, setDebouncedValue] = useState(value); - - useEffect(() => { - const timer = setTimeout(() => setDebouncedValue(value), delay); - return () => clearTimeout(timer); - }, [value, delay]); - - return debouncedValue; -} - interface SalesOrderSelectModalProps { open: boolean; onOpenChange: (open: boolean) => void; @@ -44,23 +25,13 @@ export function SalesOrderSelectModal({ onOpenChange, onSelect, }: SalesOrderSelectModalProps) { - const [searchTerm, setSearchTerm] = useState(''); - const [salesOrders, setSalesOrders] = useState([]); - const [isLoading, setIsLoading] = useState(false); - - // 디바운스된 검색어 (300ms 딜레이) - const debouncedSearchTerm = useDebounce(searchTerm, 300); - - // API로 수주 목록 로드 - const loadSalesOrders = useCallback(async () => { - setIsLoading(true); + const handleFetchData = useCallback(async (query: string) => { try { const result = await getSalesOrdersForWorkOrder({ - q: debouncedSearchTerm || undefined, + q: query || undefined, }); if (result.success) { - // API 응답을 SalesOrder 타입으로 변환 - const orders: SalesOrder[] = result.data.map((item) => ({ + return result.data.map((item) => ({ id: String(item.id), orderNo: item.orderNo, client: item.client, @@ -70,94 +41,61 @@ export function SalesOrderSelectModal({ itemCount: item.itemCount, splitCount: item.splitCount, })); - setSalesOrders(orders); - } else { - toast.error(result.error || '수주 목록 조회에 실패했습니다.'); } + toast.error(result.error || '수주 목록 조회에 실패했습니다.'); + return []; } catch (error) { if (isNextRedirectError(error)) throw error; console.error('[SalesOrderSelectModal] loadSalesOrders error:', error); toast.error('수주 목록 로드 중 오류가 발생했습니다.'); - } finally { - setIsLoading(false); + return []; } - }, [debouncedSearchTerm]); - - // 모달이 열릴 때 데이터 로드 - useEffect(() => { - if (open) { - loadSalesOrders(); - } - }, [open, loadSalesOrders]); - - const handleSelect = (order: SalesOrder) => { - onSelect(order); - onOpenChange(false); - }; + }, []); return ( - - - - 수주 선택 - - - {/* 검색 */} -
- - setSearchTerm(e.target.value)} - className="pl-9" - /> -
- - {/* 안내 문구 */} -

- 작업지시 가능한 수주 {salesOrders.length}건 (등록 상태 & 생산지시 미생성) -

- - {/* 수주 목록 */} -
- {isLoading ? ( - - ) : salesOrders.map((order) => ( -
handleSelect(order)} - className="p-4 border rounded-lg cursor-pointer hover:bg-muted/50 transition-colors" - > -
-
- {order.orderNo} - - {order.status} - -
-
- 납기: - {order.dueDate} -
-
-
- {order.client} -
-
{order.projectName}
-
- {order.itemCount}개 품목 - 분할 {order.splitCount}건 -
+ + open={open} + onOpenChange={onOpenChange} + title="수주 선택" + searchPlaceholder="수주번호, 거래처, 현장명 검색..." + fetchData={handleFetchData} + keyExtractor={(order) => order.id} + loadOnOpen + dialogClassName="sm:max-w-lg" + listContainerClassName="max-h-[400px] overflow-y-auto space-y-2" + noResultMessage="" + infoText={(items, isLoading) => + !isLoading ? ( + 작업지시 가능한 수주 {items.length}건 (등록 상태 & 생산지시 미생성) + ) : null + } + mode="single" + onSelect={onSelect} + renderItem={(order) => ( +
+
+
+ {order.orderNo} + + {order.status} +
- ))} - {!isLoading && salesOrders.length === 0 && ( -
- -

검색 결과가 없습니다.

+
+ 납기: + {order.dueDate}
- )} +
+
+ {order.client} +
+
{order.projectName}
+
+ {order.itemCount}개 품목 + 분할 {order.splitCount}건 +
- -
+ )} + listWrapper={undefined} + /> ); -} \ No newline at end of file +} diff --git a/src/components/quality/InspectionManagement/OrderSelectModal.tsx b/src/components/quality/InspectionManagement/OrderSelectModal.tsx index e82da4dd..4821eceb 100644 --- a/src/components/quality/InspectionManagement/OrderSelectModal.tsx +++ b/src/components/quality/InspectionManagement/OrderSelectModal.tsx @@ -1,26 +1,15 @@ -'use client'; - /** - * 수주 선택 모달 + * 수주 선택 모달 (다중선택) * - * 기획서 기반 신규 생성: - * - 검색 입력 + * SearchableSelectionModal 공통 컴포넌트 기반 * - 체크박스 테이블 (수주번호, 현장명, 납품일, 개소) - * - 취소/선택 버튼 + * - 전체선택/개별선택 */ -import { useState, useCallback, useEffect } from 'react'; -import { Search, Loader2 } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; +'use client'; + +import { useCallback } from 'react'; import { Checkbox } from '@/components/ui/checkbox'; -import { - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; import { Table, TableBody, @@ -30,6 +19,7 @@ import { TableRow, } from '@/components/ui/table'; import { toast } from 'sonner'; +import { SearchableSelectionModal } from '@/components/organisms/SearchableSelectionModal'; import { getOrderSelectList } from './actions'; import { isNextRedirectError } from '@/lib/utils/redirect-error'; import type { OrderSelectItem } from './types'; @@ -48,173 +38,73 @@ export function OrderSelectModal({ onSelect, excludeIds = [], }: OrderSelectModalProps) { - const [searchTerm, setSearchTerm] = useState(''); - const [items, setItems] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const [selectedIds, setSelectedIds] = useState>(new Set()); - - // 데이터 로드 - const loadItems = useCallback(async (q?: string) => { - setIsLoading(true); + const handleFetchData = useCallback(async (query: string) => { try { - const result = await getOrderSelectList({ q: q || undefined }); + const result = await getOrderSelectList({ q: query || undefined }); if (result.success) { - // 이미 선택된 항목 제외 - const filtered = result.data.filter((item) => !excludeIds.includes(item.id)); - setItems(filtered); - } else { - toast.error(result.error || '수주 목록을 불러오는데 실패했습니다.'); + return result.data.filter((item) => !excludeIds.includes(item.id)); } + toast.error(result.error || '수주 목록을 불러오는데 실패했습니다.'); + return []; } catch (error) { if (isNextRedirectError(error)) throw error; console.error('[OrderSelectModal] loadItems error:', error); toast.error('수주 목록 로드 중 오류가 발생했습니다.'); - } finally { - setIsLoading(false); + return []; } }, [excludeIds]); - // 모달 열릴 때 데이터 로드 & 상태 초기화 - useEffect(() => { - if (open) { - setSearchTerm(''); - setSelectedIds(new Set()); - loadItems(); - } - }, [open, loadItems]); - - // 검색 - const handleSearch = useCallback(() => { - loadItems(searchTerm); - }, [searchTerm, loadItems]); - - const handleSearchKeyDown = useCallback((e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - handleSearch(); - } - }, [handleSearch]); - - // 체크박스 토글 - const handleToggle = useCallback((id: string) => { - setSelectedIds((prev) => { - const next = new Set(prev); - if (next.has(id)) { - next.delete(id); - } else { - next.add(id); - } - return next; - }); - }, []); - - // 전체 선택/해제 - const handleToggleAll = useCallback(() => { - setSelectedIds((prev) => { - if (prev.size === items.length) { - return new Set(); - } - return new Set(items.map((item) => item.id)); - }); - }, [items]); - - // 선택 확인 - const handleConfirm = useCallback(() => { - const selectedItems = items.filter((item) => selectedIds.has(item.id)); - onSelect(selectedItems); - onOpenChange(false); - }, [items, selectedIds, onSelect, onOpenChange]); - - const isAllSelected = items.length > 0 && selectedIds.size === items.length; - return ( - - - - 수주 선택 - - - {/* 검색 */} -
-
- - setSearchTerm(e.target.value)} - onKeyDown={handleSearchKeyDown} - placeholder="수주번호, 현장명 검색..." - className="pl-9" - /> -
- -
- - {/* 테이블 */} -
- {isLoading ? ( -
- -
- ) : ( - - - - - - - 수주번호 - 현장명 - 납품일 - 개소 - - - - {items.map((item) => ( - handleToggle(item.id)} - > - e.stopPropagation()}> - handleToggle(item.id)} - /> - - {item.orderNumber} - {item.siteName} - {item.deliveryDate} - {item.locationCount} - - ))} - {items.length === 0 && ( - - - {searchTerm ? '검색 결과가 없습니다.' : '수주 데이터가 없습니다.'} - - + + open={open} + onOpenChange={onOpenChange} + title="수주 선택" + searchPlaceholder="수주번호, 현장명 검색..." + fetchData={handleFetchData} + keyExtractor={(item) => item.id} + searchMode="enter" + loadOnOpen + dialogClassName="sm:max-w-2xl" + listContainerClassName="max-h-[400px] overflow-y-auto border rounded-md" + mode="multiple" + onSelect={onSelect} + confirmLabel="선택" + allowSelectAll + listWrapper={(children, selectState) => ( +
+ + + + {selectState && ( + )} - -
- )} -
- - - - - -
-
+ + 수주번호 + 현장명 + 납품일 + 개소 + + + + {children} + {/* 빈 상태는 공통 컴포넌트에서 처리 */} + + + )} + renderItem={(item, isSelected) => ( + + e.stopPropagation()}> + + + {item.orderNumber} + {item.siteName} + {item.deliveryDate} + {item.locationCount} + + )} + /> ); } diff --git a/src/components/quotes/ItemSearchModal.tsx b/src/components/quotes/ItemSearchModal.tsx index 1992b9f2..31d811bd 100644 --- a/src/components/quotes/ItemSearchModal.tsx +++ b/src/components/quotes/ItemSearchModal.tsx @@ -1,28 +1,18 @@ /** * 품목 검색 모달 * - * - 품목 코드/이름으로 검색 - * - 품목 목록에서 선택 - * - API 연동 + * SearchableSelectionModal 공통 컴포넌트 기반 */ -"use client"; +'use client'; -import { useState, useEffect, useMemo, useCallback } from "react"; -import { Search, X, Loader2 } from "lucide-react"; - -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from "../ui/dialog"; -import { Input } from "../ui/input"; -import { fetchItems } from "@/lib/api/items"; -import type { ItemMaster, ItemType } from "@/types/item"; +import { useCallback } from 'react'; +import { SearchableSelectionModal } from '@/components/organisms/SearchableSelectionModal'; +import { fetchItems } from '@/lib/api/items'; +import type { ItemMaster, ItemType } from '@/types/item'; // ============================================================================= -// Props +// Props (기존과 동일 — 사용처 변경 없음) // ============================================================================= interface ItemSearchModalProps { @@ -34,6 +24,12 @@ interface ItemSearchModalProps { itemType?: string; } +// 검색어 유효성: 영문, 한글, 숫자 1자 이상 +const isValidSearchQuery = (query: string) => { + if (!query || !query.trim()) return false; + return /[a-zA-Z가-힣ㄱ-ㅎㅏ-ㅣ0-9]/.test(query); +}; + // ============================================================================= // 컴포넌트 // ============================================================================= @@ -45,168 +41,73 @@ export function ItemSearchModal({ tabLabel, itemType, }: ItemSearchModalProps) { - const [searchQuery, setSearchQuery] = useState(""); - const [items, setItems] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - - // 품목 목록 조회 - const loadItems = useCallback(async (search?: string) => { - setIsLoading(true); - setError(null); - try { - const data = await fetchItems({ - search: search || undefined, - itemType: itemType as ItemType | undefined, - per_page: 50, - }); - setItems(data); - } catch (err) { - console.error("[ItemSearchModal] 품목 조회 오류:", err); - setError("품목 목록을 불러오는데 실패했습니다."); - setItems([]); - } finally { - setIsLoading(false); - } + const handleFetchData = useCallback(async (query: string) => { + const data = await fetchItems({ + search: query || undefined, + itemType: itemType as ItemType | undefined, + per_page: 50, + }); + return data; }, [itemType]); - // 검색어 유효성 검사: 영문, 한글, 숫자 1자 이상 - const isValidSearchQuery = useCallback((query: string) => { - if (!query || !query.trim()) return false; - return /[a-zA-Z가-힣ㄱ-ㅎㅏ-ㅣ0-9]/.test(query); - }, []); - - // 모달 열릴 때 초기화 (자동 로드 안함) - useEffect(() => { - if (open) { - setItems([]); - setError(null); - } - }, [open]); - - // 검색어 변경 시 디바운스 검색 (유효한 검색어만) - useEffect(() => { - if (!open) return; - - // 검색어가 유효하지 않으면 결과 초기화 - if (!isValidSearchQuery(searchQuery)) { - setItems([]); - return; - } - - const timer = setTimeout(() => { - loadItems(searchQuery); - }, 300); - - return () => clearTimeout(timer); - }, [searchQuery, open, loadItems, isValidSearchQuery]); - - // 검색 결과 그대로 사용 (서버에서 이미 필터링됨) - const filteredItems = items; - - const handleSelect = (item: ItemMaster) => { + const handleSelect = useCallback((item: ItemMaster) => { onSelectItem({ code: item.itemCode, name: item.itemName, specification: item.specification || undefined, }); - onOpenChange(false); - setSearchQuery(""); - }; - - const handleClose = () => { - onOpenChange(false); - setSearchQuery(""); - }; + }, [onSelectItem]); return ( - - - - - 품목 검색 - {tabLabel && ({tabLabel})} - - - - {/* 검색 입력 */} -
- - setSearchQuery(e.target.value)} - className="pl-10 pr-10" - /> - {searchQuery && ( - - )} -
- - {/* 품목 목록 */} -
- {isLoading ? ( -
- - 품목 검색 중... + + open={open} + onOpenChange={onOpenChange} + title={ + <> + 품목 검색 + {tabLabel && ({tabLabel})} + + } + searchPlaceholder="품목코드 또는 품목명 검색..." + fetchData={handleFetchData} + keyExtractor={(item) => item.id?.toString() ?? item.itemCode} + validateSearch={isValidSearchQuery} + invalidSearchMessage="영문, 한글 또는 숫자 1자 이상 입력하세요" + emptyQueryMessage="품목코드 또는 품목명을 입력하세요" + loadingMessage="품목 검색 중..." + dialogClassName="sm:max-w-[500px]" + infoText={(items, isLoading) => + !isLoading ? ( + + 총 {items.length}개 품목 + + ) : null + } + mode="single" + onSelect={handleSelect} + renderItem={(item) => ( +
+
+
+ {item.itemCode} + {item.itemName} + {item.hasInspectionTemplate && ( + + 수입검사 + + )}
- ) : error ? ( -
- {error} -
- ) : filteredItems.length === 0 ? ( -
- {!searchQuery - ? "품목코드 또는 품목명을 입력하세요" - : !isValidSearchQuery(searchQuery) - ? "영문, 한글 또는 숫자 1자 이상 입력하세요" - : "검색 결과가 없습니다"} -
- ) : ( -
- {filteredItems.map((item, index) => ( -
handleSelect(item)} - className="p-3 hover:bg-blue-50 cursor-pointer transition-colors" - > -
-
- {item.itemCode} - {item.itemName} - {item.hasInspectionTemplate && ( - - 수입검사 - - )} -
- {item.unit && ( - - {item.unit} - - )} -
- {item.specification && ( -

{item.specification}

- )} -
- ))} -
- )} -
- - {/* 품목 개수 표시 */} - {!isLoading && !error && ( -
- 총 {filteredItems.length}개 품목 + {item.unit && ( + + {item.unit} + + )}
- )} - -
+ {item.specification && ( +

{item.specification}

+ )} +
+ )} + /> ); -} \ No newline at end of file +} diff --git a/src/components/settings/AccountManagement/actions.ts b/src/components/settings/AccountManagement/actions.ts index f6111c6a..c72d5656 100644 --- a/src/components/settings/AccountManagement/actions.ts +++ b/src/components/settings/AccountManagement/actions.ts @@ -2,6 +2,7 @@ import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action'; +import type { PaginatedApiResponse } from '@/lib/api/types'; import { isNextRedirectError } from '@/lib/utils/redirect-error'; import type { Account, AccountFormData, AccountStatus } from './types'; import { BANK_LABELS } from './types'; @@ -23,13 +24,7 @@ interface BankAccountApiData { updated_at?: string; } -interface BankAccountPaginatedResponse { - data: BankAccountApiData[]; - current_page: number; - last_page: number; - per_page: number; - total: number; -} +type BankAccountPaginatedResponse = PaginatedApiResponse; // ===== 데이터 변환 ===== function transformApiToFrontend(apiData: BankAccountApiData): Account { diff --git a/src/components/settings/PaymentHistoryManagement/actions.ts b/src/components/settings/PaymentHistoryManagement/actions.ts index 2c1de5d6..19686684 100644 --- a/src/components/settings/PaymentHistoryManagement/actions.ts +++ b/src/components/settings/PaymentHistoryManagement/actions.ts @@ -2,19 +2,14 @@ import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action'; +import type { PaginatedApiResponse } from '@/lib/api/types'; import type { PaymentApiData, PaymentHistory } from './types'; import { transformApiToFrontend } from './utils'; const API_URL = process.env.NEXT_PUBLIC_API_URL; // ===== API 응답 타입 ===== -interface PaymentPaginatedResponse { - data: PaymentApiData[]; - current_page: number; - last_page: number; - per_page: number; - total: number; -} +type PaymentPaginatedResponse = PaginatedApiResponse; interface PaymentStatementApiData { statement_no: string; diff --git a/src/components/settings/PopupManagement/actions.ts b/src/components/settings/PopupManagement/actions.ts index 194aa757..90c904a3 100644 --- a/src/components/settings/PopupManagement/actions.ts +++ b/src/components/settings/PopupManagement/actions.ts @@ -14,23 +14,12 @@ import { isNextRedirectError } from '@/lib/utils/redirect-error'; import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action'; +import type { PaginatedApiResponse } from '@/lib/api/types'; import type { Popup, PopupFormData } from './types'; import { transformApiToFrontend, transformFrontendToApi, type PopupApiData } from './utils'; const API_URL = process.env.NEXT_PUBLIC_API_URL; -// ============================================ -// API 응답 타입 정의 -// ============================================ - -interface PaginatedResponse { - current_page: number; - data: T[]; - total: number; - per_page: number; - last_page: number; -} - // ============================================ // API 함수 // ============================================ @@ -52,7 +41,7 @@ export async function getPopups(params?: { const result = await executeServerAction({ url: `${API_URL}/api/v1/popups?${searchParams.toString()}`, - transform: (data: PaginatedResponse) => data.data.map(transformApiToFrontend), + transform: (data: PaginatedApiResponse) => data.data.map(transformApiToFrontend), errorMessage: '팝업 목록 조회에 실패했습니다.', }); return result.data || []; diff --git a/src/components/settings/TitleManagement/actions.ts b/src/components/settings/TitleManagement/actions.ts index a1a2ce5d..2a01f020 100644 --- a/src/components/settings/TitleManagement/actions.ts +++ b/src/components/settings/TitleManagement/actions.ts @@ -1,11 +1,8 @@ 'use server'; - -import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action'; +import { createCrudService, type ActionResult } from '@/lib/api/create-crud-service'; import type { Title } from './types'; -const API_URL = process.env.NEXT_PUBLIC_API_URL; - // ===== API 응답 타입 ===== interface PositionApiData { id: number; @@ -18,60 +15,43 @@ interface PositionApiData { updated_at?: string; } -// ===== 데이터 변환: API → Frontend ===== -function transformApiToFrontend(apiData: PositionApiData): Title { - return { - id: apiData.id, - name: apiData.name, - order: apiData.sort_order, - isActive: apiData.is_active, - createdAt: apiData.created_at, - updatedAt: apiData.updated_at, - }; -} +// ===== CRUD 서비스 생성 ===== +const titleService = createCrudService({ + basePath: '/api/v1/positions', + transform: (api) => ({ + id: api.id, + name: api.name, + order: api.sort_order, + isActive: api.is_active, + createdAt: api.created_at, + updatedAt: api.updated_at, + }), + entityName: '직책', + defaultQueryParams: { type: 'title' }, + defaultCreateBody: { type: 'title' }, +}); + +// ===== Server Action 래퍼 ===== -// ===== 직책 목록 조회 ===== export async function getTitles(params?: { is_active?: boolean; q?: string; }): Promise> { - const searchParams = new URLSearchParams(); - searchParams.set('type', 'title'); - if (params?.is_active !== undefined) { - searchParams.set('is_active', params.is_active.toString()); - } - if (params?.q) { - searchParams.set('q', params.q); - } - - return executeServerAction({ - url: `${API_URL}/api/v1/positions?${searchParams.toString()}`, - transform: (data: PositionApiData[]) => data.map(transformApiToFrontend), - errorMessage: '직책 목록 조회에 실패했습니다.', - }); + return titleService.getList(params); } -// ===== 직책 생성 ===== export async function createTitle(data: { name: string; sort_order?: number; is_active?: boolean; }): Promise> { - return executeServerAction({ - url: `${API_URL}/api/v1/positions`, - method: 'POST', - body: { - type: 'title', - name: data.name, - sort_order: data.sort_order, - is_active: data.is_active ?? true, - }, - transform: transformApiToFrontend, - errorMessage: '직책 생성에 실패했습니다.', + return titleService.create({ + name: data.name, + sort_order: data.sort_order, + is_active: data.is_active ?? true, }); } -// ===== 직책 수정 ===== export async function updateTitle( id: number, data: { @@ -80,32 +60,15 @@ export async function updateTitle( is_active?: boolean; } ): Promise> { - return executeServerAction({ - url: `${API_URL}/api/v1/positions/${id}`, - method: 'PUT', - body: data, - transform: transformApiToFrontend, - errorMessage: '직책 수정에 실패했습니다.', - }); + return titleService.update(id, data); } -// ===== 직책 삭제 ===== export async function deleteTitle(id: number): Promise { - return executeServerAction({ - url: `${API_URL}/api/v1/positions/${id}`, - method: 'DELETE', - errorMessage: '직책 삭제에 실패했습니다.', - }); + return titleService.remove(id); } -// ===== 직책 순서 변경 ===== export async function reorderTitles( items: { id: number; sort_order: number }[] ): Promise { - return executeServerAction({ - url: `${API_URL}/api/v1/positions/reorder`, - method: 'PUT', - body: { items }, - errorMessage: '순서 변경에 실패했습니다.', - }); -} \ No newline at end of file + return titleService.reorder(items); +} diff --git a/src/lib/api/create-crud-service.ts b/src/lib/api/create-crud-service.ts index ba9aa440..07a2a8c9 100644 --- a/src/lib/api/create-crud-service.ts +++ b/src/lib/api/create-crud-service.ts @@ -3,7 +3,7 @@ * * 정형적인 CRUD actions.ts 파일의 보일러플레이트를 제거합니다. * executeServerAction 위에 한 단계 더 추상화하여 - * getList / create / update / remove / reorder 함수를 자동 생성합니다. + * getList / getById / create / update / remove / bulkDelete / reorder 함수를 자동 생성합니다. * * 주의: 이 파일은 'use server'가 아닙니다. * 각 도메인의 actions.ts ('use server')에서 import하여 사용합니다. @@ -20,10 +20,12 @@ * defaultCreateBody: { type: 'rank' }, * }); * export async function getRanks(params?) { return service.getList(params); } + * export async function getRankById(id) { return service.getById(id); } * ``` */ import { executeServerAction, type ActionResult } from './execute-server-action'; +import { isNextRedirectError } from '@/lib/utils/redirect-error'; // ===== 설정 타입 ===== export interface CrudServiceConfig { @@ -37,6 +39,8 @@ export interface CrudServiceConfig { defaultQueryParams?: Record; /** 생성 시 body에 추가할 기본값 (예: { type: 'rank' }) */ defaultCreateBody?: Record; + /** 수정 시 HTTP 메서드 (기본: 'PUT') */ + updateMethod?: 'PUT' | 'PATCH'; } // ===== 서비스 반환 타입 ===== @@ -46,14 +50,18 @@ export interface CrudService { q?: string; }): Promise>; + getById(id: number | string): Promise>; + create(body: Record): Promise>; update( - id: number, + id: number | string, body: Record ): Promise>; - remove(id: number): Promise; + remove(id: number | string): Promise; + + bulkDelete(ids: (number | string)[]): Promise; reorder( items: { id: number; sort_order: number }[] @@ -70,6 +78,7 @@ export function createCrudService( entityName, defaultQueryParams, defaultCreateBody, + updateMethod = 'PUT', } = config; // API URL은 호출 시점에 resolve (SSR 안전) @@ -97,6 +106,14 @@ export function createCrudService( }); }, + async getById(id) { + return executeServerAction({ + url: `${getBaseUrl()}/${id}`, + transform, + errorMessage: `${entityName} 조회에 실패했습니다.`, + }); + }, + async create(body) { return executeServerAction({ url: getBaseUrl(), @@ -110,7 +127,7 @@ export function createCrudService( async update(id, body) { return executeServerAction({ url: `${getBaseUrl()}/${id}`, - method: 'PUT', + method: updateMethod, body, transform, errorMessage: `${entityName} 수정에 실패했습니다.`, @@ -125,6 +142,26 @@ export function createCrudService( }); }, + async bulkDelete(ids) { + try { + const results = await Promise.all(ids.map((id) => + executeServerAction({ + url: `${getBaseUrl()}/${id}`, + method: 'DELETE', + errorMessage: `${entityName} 삭제에 실패했습니다.`, + }) + )); + const failed = results.filter((r) => !r.success); + if (failed.length > 0) { + return { success: false, error: `${failed.length}개 ${entityName} 삭제에 실패했습니다.` }; + } + return { success: true }; + } catch (error) { + if (isNextRedirectError(error)) throw error; + return { success: false, error: `${entityName} 일괄 삭제에 실패했습니다.` }; + } + }, + async reorder(items) { return executeServerAction({ url: `${getBaseUrl()}/reorder`, diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 308d6941..d540f62c 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -5,6 +5,23 @@ export { ApiClient, withTokenRefresh } from './client'; export { serverFetch } from './fetch-wrapper'; export { AUTH_CONFIG } from './auth/auth-config'; +// 공용 API 타입 및 페이지네이션 유틸리티 +export { + toPaginationMeta, + type PaginatedApiResponse, + type PaginationMeta, + type PaginatedResult, + type SelectOption, +} from './types'; + +// 공용 룩업 헬퍼 (거래처/계좌 조회) +export { + fetchVendorOptions, + fetchBankAccountOptions, + fetchBankAccountDetailOptions, + type BankAccountOption, +} from './shared-lookups'; + // 공통 코드 타입 및 유틸리티 export { toCommonCodeOptions, diff --git a/src/lib/api/shared-lookups.ts b/src/lib/api/shared-lookups.ts new file mode 100644 index 00000000..b3aee95f --- /dev/null +++ b/src/lib/api/shared-lookups.ts @@ -0,0 +1,86 @@ +/** + * 공용 룩업(셀렉트 옵션) 조회 헬퍼 + * + * 여러 도메인(입금/출금/매입/예상비용)에서 동일하게 사용하는 + * 거래처/계좌 조회 로직을 하나로 통합합니다. + * + * 주의: 이 파일은 'use server'가 아닙니다. + * 각 도메인의 actions.ts ('use server')에서 import하여 사용합니다. + */ + +import { executeServerAction, type ActionResult } from './execute-server-action'; +import type { SelectOption } from './types'; + +// ===== 계좌 상세 옵션 ===== +export interface BankAccountOption { + id: string; + bankName: string; + accountName: string; + accountNumber: string; +} + +// ===== API 내부 타입 ===== +interface ClientApiItem { + id: number; + name: string; +} + +interface BankAccountApiItem { + id: number; + bank_name: string; + account_name: string; + account_number: string; +} + +type PaginatedOrArray = { data?: T[] } | T[]; + +function extractArray(data: PaginatedOrArray): T[] { + return Array.isArray(data) ? data : (data as { data?: T[] })?.data || []; +} + +// ===== 거래처 목록 조회 ===== +export async function fetchVendorOptions(): Promise> { + const API_URL = process.env.NEXT_PUBLIC_API_URL; + const result = await executeServerAction({ + url: `${API_URL}/api/v1/clients?per_page=100`, + transform: (data: PaginatedOrArray) => { + const clients = extractArray(data); + return clients.map(c => ({ id: String(c.id), name: c.name })); + }, + errorMessage: '거래처 조회에 실패했습니다.', + }); + return { success: result.success, data: result.data || [], error: result.error }; +} + +// ===== 계좌 목록 조회 (간단: id + name) ===== +export async function fetchBankAccountOptions(): Promise> { + const API_URL = process.env.NEXT_PUBLIC_API_URL; + const result = await executeServerAction({ + url: `${API_URL}/api/v1/bank-accounts?per_page=100`, + transform: (data: PaginatedOrArray) => { + const accounts = extractArray(data); + return accounts.map(a => ({ id: String(a.id), name: `${a.bank_name} ${a.account_name}` })); + }, + errorMessage: '계좌 조회에 실패했습니다.', + }); + return { success: result.success, data: result.data || [], error: result.error }; +} + +// ===== 계좌 목록 조회 (상세: bankName, accountName, accountNumber) ===== +export async function fetchBankAccountDetailOptions(): Promise> { + const API_URL = process.env.NEXT_PUBLIC_API_URL; + const result = await executeServerAction({ + url: `${API_URL}/api/v1/bank-accounts?per_page=100`, + transform: (data: PaginatedOrArray) => { + const accounts = extractArray(data); + return accounts.map(a => ({ + id: String(a.id), + bankName: a.bank_name, + accountName: a.account_name, + accountNumber: a.account_number, + })); + }, + errorMessage: '은행 계좌 조회에 실패했습니다.', + }); + return { success: result.success, data: result.data || [], error: result.error }; +} diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts new file mode 100644 index 00000000..16fc3a5a --- /dev/null +++ b/src/lib/api/types.ts @@ -0,0 +1,47 @@ +/** + * 공용 API 타입 및 페이지네이션 유틸리티 + * + * 25+ 개 action 파일에서 반복 정의되던 PaginatedResponse 타입과 + * 페이지네이션 변환 헬퍼를 하나로 통합합니다. + */ + +// ===== API 페이지네이션 응답 (Laravel 표준) ===== +export interface PaginatedApiResponse { + data: T[]; + current_page: number; + last_page: number; + per_page: number; + total: number; +} + +// ===== 프론트엔드 페이지네이션 메타 (camelCase) ===== +export interface PaginationMeta { + currentPage: number; + lastPage: number; + perPage: number; + total: number; +} + +// ===== 프론트엔드 페이지네이션 결과 ===== +export interface PaginatedResult { + items: T[]; + pagination: PaginationMeta; +} + +// ===== 페이지네이션 변환 헬퍼 ===== +export function toPaginationMeta( + data: { current_page?: number; last_page?: number; per_page?: number; total?: number } | undefined | null +): PaginationMeta { + return { + currentPage: data?.current_page || 1, + lastPage: data?.last_page || 1, + perPage: data?.per_page || 20, + total: data?.total || 0, + }; +} + +// ===== 셀렉트 옵션 (공용 룩업용) ===== +export interface SelectOption { + id: string; + name: string; +}