refactor(WEB): SearchableSelectionModal 공통화 및 actions lookup 통합
- SearchableSelectionModal<T> 제네릭 컴포넌트 추출 (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 <noreply@anthropic.com>
This commit is contained in:
21
CLAUDE.md
21
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<T>` 사용 (직접 Dialog 조합 금지)
|
||||
- 리스트 페이지 → `UniversalListPage` 또는 organisms 조합
|
||||
- 상세/폼 → Card + 기존 패턴 따르기
|
||||
|
||||
---
|
||||
|
||||
## User Environment
|
||||
**Priority**: 🟢
|
||||
|
||||
|
||||
@@ -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<T> — 25+ 파일에서 중복 정의 제거
|
||||
- [x] PaginationMeta, PaginatedResult<T> — 프론트엔드 표준 페이지네이션 타입
|
||||
- [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<T>) → 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<T> 공통 컴포넌트 생성
|
||||
- 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<T> 공통 컴포넌트 생성
|
||||
- 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<T>, PaginatedResponse<T>, FormState<T> 등
|
||||
- PaginatedApiResponse<T> ✅ Phase 3에서 완료 (src/lib/api/types.ts)
|
||||
- FormState<T>, 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줄 절감 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
522
claudedocs/guides/[GUIDE] common-page-patterns.md
Normal file
522
claudedocs/guides/[GUIDE] common-page-patterns.md
Normal file
@@ -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<T>` 사용.
|
||||
Dialog + Input + 리스트를 직접 조합하지 않는다.
|
||||
|
||||
### 위치
|
||||
|
||||
```
|
||||
src/components/organisms/SearchableSelectionModal/
|
||||
├── SearchableSelectionModal.tsx — 메인 컴포넌트
|
||||
├── useSearchableData.ts — 검색+로딩 훅
|
||||
├── types.ts — Props 인터페이스
|
||||
└── index.ts
|
||||
```
|
||||
|
||||
### 핵심 Props
|
||||
|
||||
```typescript
|
||||
SearchableSelectionModal<T>
|
||||
// 필수
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
title: ReactNode
|
||||
fetchData: (query: string) => Promise<T[]> // 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
|
||||
// 품목 검색, 거래처 검색 등
|
||||
<SearchableSelectionModal<ItemType>
|
||||
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) => (
|
||||
<div className="p-3 hover:bg-blue-50 transition-colors">
|
||||
<span className="font-semibold">{item.code}</span>
|
||||
<span className="text-sm text-gray-600 ml-2">{item.name}</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
```
|
||||
|
||||
#### B. 단일선택 + 카드 UI + 열릴 때 자동 로드
|
||||
|
||||
```tsx
|
||||
// 수주 선택, 견적 선택 등
|
||||
<SearchableSelectionModal<OrderType>
|
||||
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) => (
|
||||
<div className="p-4 border rounded-lg hover:bg-muted/50 transition-colors">
|
||||
{/* 카드형 UI */}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
```
|
||||
|
||||
#### C. 다중선택 + Enter 검색 + 테이블
|
||||
|
||||
```tsx
|
||||
// 수주 다중선택 (체크박스 테이블)
|
||||
<SearchableSelectionModal<OrderSelectItem>
|
||||
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 구조 래핑
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-10">
|
||||
{selectState && (
|
||||
<Checkbox
|
||||
checked={selectState.isAllSelected}
|
||||
onCheckedChange={selectState.onToggleAll}
|
||||
/>
|
||||
)}
|
||||
</TableHead>
|
||||
<TableHead>수주번호</TableHead>
|
||||
<TableHead>현장명</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>{children}</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
renderItem={(item, isSelected) => (
|
||||
<TableRow className="cursor-pointer hover:bg-muted/50">
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={isSelected} />
|
||||
</TableCell>
|
||||
<TableCell>{item.orderNumber}</TableCell>
|
||||
<TableCell>{item.siteName}</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
/>
|
||||
```
|
||||
|
||||
### 기존 모달 → 공통 컴포넌트 매핑
|
||||
|
||||
| 기존 모달 | 위치 | 패턴 |
|
||||
|-----------|------|------|
|
||||
| `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<MyItem> = {
|
||||
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: () => <Button>신규</Button>,
|
||||
};
|
||||
|
||||
return <UniversalListPage config={config} initialData={data} />;
|
||||
}
|
||||
```
|
||||
|
||||
### 방법 2: Organisms 직접 조합
|
||||
|
||||
UniversalListPage가 맞지 않는 경우 organisms를 직접 조합.
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { PageLayout, PageHeader, StatCards, SearchFilter, DataTable, EmptyState } from '@/components/organisms';
|
||||
|
||||
export function MyList() {
|
||||
return (
|
||||
<PageLayout>
|
||||
<PageHeader title="제목" description="설명" actions={<Button>신규</Button>} />
|
||||
<StatCards stats={stats} />
|
||||
<SearchFilter searchTerm={q} onSearchChange={setQ} placeholder="검색..." />
|
||||
{data.length > 0 ? (
|
||||
<DataTable columns={columns} renderRow={renderRow} pagination={pagination} />
|
||||
) : (
|
||||
<EmptyState icon={FileX} title="데이터 없음" />
|
||||
)}
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 리스트 페이지 공통 규칙
|
||||
|
||||
- **검색 디바운스**: 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 <Skeleton />;
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-6 max-w-4xl">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold">
|
||||
{isNewMode ? '신규 등록' : isViewMode ? '상세 보기' : '수정'}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* 섹션 1 */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader><CardTitle>기본 정보</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>이름</Label>
|
||||
<Input
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 하단 버튼 */}
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="outline" onClick={() => router.back()}>취소</Button>
|
||||
{isViewMode ? (
|
||||
<Button onClick={() => router.push(`?mode=edit`)}>수정</Button>
|
||||
) : (
|
||||
<Button onClick={handleSubmit} disabled={isSaving}>
|
||||
{isSaving ? '저장 중...' : '저장'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 상세/폼 페이지 공통 규칙
|
||||
|
||||
- **모드**: `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 버그 대응**: `<Select key={...}>` 패턴 (CLAUDE.md 참조)
|
||||
|
||||
---
|
||||
|
||||
## 5. API 연동 패턴
|
||||
|
||||
### Server Action 파일 구조
|
||||
|
||||
```
|
||||
src/components/[domain]/[feature]/
|
||||
├── index.tsx — 메인 컴포넌트 (또는 리스트)
|
||||
├── [Feature]Detail.tsx — 상세/폼
|
||||
├── actions.ts — Server Actions (API 호출)
|
||||
└── types.ts — 타입 정의
|
||||
```
|
||||
|
||||
### Server Action 패턴
|
||||
|
||||
```typescript
|
||||
'use server';
|
||||
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
const API_BASE = process.env.BACKEND_API_URL;
|
||||
|
||||
export async function getList(params?: { q?: string; page?: number; size?: number }) {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get('access_token')?.value;
|
||||
if (!token) redirect('/login');
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.q) searchParams.set('q', params.q);
|
||||
// ...
|
||||
|
||||
const res = await fetch(`${API_BASE}/endpoint?${searchParams}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
return { success: true, data: data.data };
|
||||
}
|
||||
```
|
||||
|
||||
### 클라이언트에서 호출
|
||||
|
||||
```typescript
|
||||
// useEffect에서 호출
|
||||
useEffect(() => {
|
||||
getList({ q: searchTerm })
|
||||
.then(result => {
|
||||
if (result.success) setData(result.data);
|
||||
else toast.error(result.error);
|
||||
});
|
||||
}, [searchTerm]);
|
||||
|
||||
// SearchableSelectionModal의 fetchData에서 호출
|
||||
const handleFetchData = useCallback(async (query: string) => {
|
||||
const result = await getList({ q: query });
|
||||
if (result.success) return result.data;
|
||||
toast.error(result.error);
|
||||
return [];
|
||||
}, []);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 페이지 라우팅 구조
|
||||
|
||||
```
|
||||
src/app/[locale]/(protected)/[domain]/
|
||||
├── [list-page]/
|
||||
│ └── page.tsx → <ListComponent />
|
||||
├── [detail-page]/
|
||||
│ ├── [id]/
|
||||
│ │ └── page.tsx → <DetailComponent id={id} mode="view|edit" />
|
||||
│ └── new/
|
||||
│ └── page.tsx → <DetailComponent mode="new" />
|
||||
```
|
||||
|
||||
### page.tsx 패턴
|
||||
|
||||
```typescript
|
||||
// 리스트
|
||||
'use client';
|
||||
import { MyList } from '@/components/[domain]/[Feature]';
|
||||
export default function Page() { return <MyList />; }
|
||||
|
||||
// 상세 (view/edit)
|
||||
'use client';
|
||||
import { useParams, useSearchParams } from 'next/navigation';
|
||||
export default function Page() {
|
||||
const { id } = useParams();
|
||||
const mode = useSearchParams().get('mode') === 'edit' ? 'edit' : 'view';
|
||||
return <MyDetail id={id as string} mode={mode} />;
|
||||
}
|
||||
|
||||
// 신규
|
||||
'use client';
|
||||
export default function Page() { return <MyDetail mode="new" />; }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 변경 이력
|
||||
|
||||
| 날짜 | 내용 |
|
||||
|------|------|
|
||||
| 2026-02-10 | 초기 작성: 검색 모달, 리스트, 상세/폼, API 패턴 |
|
||||
@@ -16,20 +16,13 @@
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
|
||||
import type { PaginatedApiResponse } from '@/lib/api/types';
|
||||
import type { BadDebtRecord, BadDebtItem, CollectionStatus } from './types';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||
|
||||
// ===== API 응답 타입 =====
|
||||
|
||||
interface PaginatedResponse<T> {
|
||||
current_page: number;
|
||||
data: T[];
|
||||
total: number;
|
||||
per_page: number;
|
||||
last_page: number;
|
||||
}
|
||||
|
||||
interface BadDebtItemApiData {
|
||||
id: number;
|
||||
debt_amount: number;
|
||||
@@ -176,7 +169,7 @@ export async function getBadDebts(params?: {
|
||||
|
||||
const result = await executeServerAction({
|
||||
url: `${API_URL}/api/v1/bad-debts?${searchParams.toString()}`,
|
||||
transform: (data: PaginatedResponse<BadDebtApiData>) => data.data.map(transformApiToFrontend),
|
||||
transform: (data: PaginatedApiResponse<BadDebtApiData>) => data.data.map(transformApiToFrontend),
|
||||
errorMessage: '악성채권 목록 조회에 실패했습니다.',
|
||||
});
|
||||
return result.data || [];
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
|
||||
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
|
||||
import type { PaginatedApiResponse } from '@/lib/api/types';
|
||||
import type { BankTransaction, TransactionKind } from './types';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||
@@ -34,13 +35,7 @@ interface BankTransactionApiSummary {
|
||||
withdrawal_unset_count: number;
|
||||
}
|
||||
|
||||
interface BankTransactionPaginatedResponse {
|
||||
data: BankTransactionApiItem[];
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
}
|
||||
type BankTransactionPaginatedResponse = PaginatedApiResponse<BankTransactionApiItem>;
|
||||
|
||||
interface FrontendPagination { currentPage: number; lastPage: number; perPage: number; total: number }
|
||||
const DEFAULT_PAGINATION: FrontendPagination = { currentPage: 1, lastPage: 1, perPage: 20, total: 0 };
|
||||
|
||||
@@ -2,19 +2,14 @@
|
||||
|
||||
|
||||
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
|
||||
import type { PaginatedApiResponse } from '@/lib/api/types';
|
||||
import type { BillRecord, BillApiData, BillStatus } from './types';
|
||||
import { transformApiToFrontend, transformFrontendToApi } from './types';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||
|
||||
// ===== API 응답 타입 =====
|
||||
interface BillPaginatedResponse {
|
||||
data: BillApiData[];
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
}
|
||||
type BillPaginatedResponse = PaginatedApiResponse<BillApiData>;
|
||||
|
||||
interface BillSummaryApiData {
|
||||
total_amount: number;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
|
||||
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
|
||||
import type { PaginatedApiResponse } from '@/lib/api/types';
|
||||
import type { CardTransaction } from './types';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||
@@ -35,13 +36,7 @@ interface CardTransactionApiSummary {
|
||||
total_amount: number;
|
||||
}
|
||||
|
||||
interface CardPaginatedResponse {
|
||||
data: CardTransactionApiItem[];
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
}
|
||||
type CardPaginatedResponse = PaginatedApiResponse<CardTransactionApiItem>;
|
||||
|
||||
interface FrontendPagination { currentPage: number; lastPage: number; perPage: number; total: number }
|
||||
const DEFAULT_PAGINATION: FrontendPagination = { currentPage: 1, lastPage: 1, perPage: 20, total: 0 };
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
|
||||
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
|
||||
import type { PaginatedApiResponse } from '@/lib/api/types';
|
||||
import { fetchVendorOptions, fetchBankAccountOptions } from '@/lib/api/shared-lookups';
|
||||
import type { DepositRecord, DepositType, DepositStatus } from './types';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||
@@ -26,13 +28,7 @@ interface DepositApiData {
|
||||
bank_account?: { id: number; bank_name: string; account_name: string } | null;
|
||||
}
|
||||
|
||||
interface DepositPaginatedResponse {
|
||||
data: DepositApiData[];
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
}
|
||||
type DepositPaginatedResponse = PaginatedApiResponse<DepositApiData>;
|
||||
|
||||
interface FrontendPagination { currentPage: number; lastPage: number; perPage: number; total: number }
|
||||
const DEFAULT_PAGINATION: FrontendPagination = { currentPage: 1, lastPage: 1, perPage: 20, total: 0 };
|
||||
@@ -162,15 +158,7 @@ export async function updateDeposit(id: string, data: Partial<DepositRecord>): P
|
||||
export async function getVendors(): Promise<{
|
||||
success: boolean; data: { id: string; name: string }[]; error?: string;
|
||||
}> {
|
||||
const result = await executeServerAction({
|
||||
url: `${API_URL}/api/v1/clients?per_page=100`,
|
||||
transform: (data: { data?: { id: number; name: string }[] } | { id: number; name: string }[]) => {
|
||||
type ClientApi = { id: number; name: string };
|
||||
const clients: ClientApi[] = Array.isArray(data) ? data : (data as { data?: ClientApi[] })?.data || [];
|
||||
return clients.map(c => ({ id: String(c.id), name: c.name }));
|
||||
},
|
||||
errorMessage: '거래처 조회에 실패했습니다.',
|
||||
});
|
||||
const result = await fetchVendorOptions();
|
||||
return { success: result.success, data: result.data || [], error: result.error };
|
||||
}
|
||||
|
||||
@@ -178,14 +166,6 @@ export async function getVendors(): Promise<{
|
||||
export async function getBankAccounts(): Promise<{
|
||||
success: boolean; data: { id: string; name: string }[]; error?: string;
|
||||
}> {
|
||||
const result = await executeServerAction({
|
||||
url: `${API_URL}/api/v1/bank-accounts?per_page=100`,
|
||||
transform: (data: { data?: { id: number; account_name: string; bank_name: string }[] } | { id: number; account_name: string; bank_name: string }[]) => {
|
||||
type AccountApi = { id: number; account_name: string; bank_name: string };
|
||||
const accounts: AccountApi[] = Array.isArray(data) ? data : (data as { data?: AccountApi[] })?.data || [];
|
||||
return accounts.map(a => ({ id: String(a.id), name: `${a.bank_name} ${a.account_name}` }));
|
||||
},
|
||||
errorMessage: '계좌 조회에 실패했습니다.',
|
||||
});
|
||||
const result = await fetchBankAccountOptions();
|
||||
return { success: result.success, data: result.data || [], error: result.error };
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
|
||||
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
|
||||
import type { PaginatedApiResponse } from '@/lib/api/types';
|
||||
import { fetchBankAccountDetailOptions } from '@/lib/api/shared-lookups';
|
||||
import type { ExpectedExpenseRecord, TransactionType, PaymentStatus, ApprovalStatus } from './types';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||
@@ -27,13 +29,7 @@ interface ExpectedExpenseApiData {
|
||||
bank_account?: { id: number; bank_name: string; account_name: string } | null;
|
||||
}
|
||||
|
||||
interface ExpensePaginatedResponse {
|
||||
data: ExpectedExpenseApiData[];
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
}
|
||||
type ExpensePaginatedResponse = PaginatedApiResponse<ExpectedExpenseApiData>;
|
||||
|
||||
interface SummaryData {
|
||||
total_amount: number;
|
||||
@@ -217,14 +213,6 @@ export async function getClients(): Promise<{
|
||||
export async function getBankAccounts(): Promise<{
|
||||
success: boolean; data: { id: string; bankName: string; accountName: string; accountNumber: string }[]; error?: string;
|
||||
}> {
|
||||
const result = await executeServerAction({
|
||||
url: `${API_URL}/api/v1/bank-accounts?per_page=100`,
|
||||
transform: (data: { data?: { id: number; bank_name: string; account_name: string; account_number: string }[] } | { id: number; bank_name: string; account_name: string; account_number: string }[]) => {
|
||||
type AccountApi = { id: number; bank_name: string; account_name: string; account_number: string };
|
||||
const accounts: AccountApi[] = Array.isArray(data) ? data : (data as { data?: AccountApi[] })?.data || [];
|
||||
return accounts.map(a => ({ id: String(a.id), bankName: a.bank_name, accountName: a.account_name, accountNumber: a.account_number }));
|
||||
},
|
||||
errorMessage: '은행 계좌 조회에 실패했습니다.',
|
||||
});
|
||||
const result = await fetchBankAccountDetailOptions();
|
||||
return { success: result.success, data: result.data || [], error: result.error };
|
||||
}
|
||||
@@ -15,6 +15,8 @@
|
||||
|
||||
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 { fetchVendorOptions, fetchBankAccountDetailOptions } from '@/lib/api/shared-lookups';
|
||||
import type { PurchaseRecord, PurchaseType } from './types';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||
@@ -46,13 +48,7 @@ interface PurchaseApiData {
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
interface PurchaseApiPaginatedResponse {
|
||||
data: PurchaseApiData[];
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
}
|
||||
type PurchaseApiPaginatedResponse = PaginatedApiResponse<PurchaseApiData>;
|
||||
|
||||
// ===== 변환 함수 =====
|
||||
|
||||
@@ -199,17 +195,7 @@ export async function getBankAccounts(): Promise<{
|
||||
data: { id: string; bankName: string; accountName: string; accountNumber: string }[];
|
||||
error?: string;
|
||||
}> {
|
||||
const result = await executeServerAction({
|
||||
url: `${API_URL}/api/v1/bank-accounts?per_page=100`,
|
||||
transform: (data: { data?: { id: number; bank_name: string; account_name: string; account_number: string }[] } | { id: number; bank_name: string; account_name: string; account_number: string }[]) => {
|
||||
type BankAccountApi = { id: number; bank_name: string; account_name: string; account_number: string };
|
||||
const accounts: BankAccountApi[] = Array.isArray(data) ? data : (data as { data?: BankAccountApi[] })?.data || [];
|
||||
return accounts.map(a => ({
|
||||
id: String(a.id), bankName: a.bank_name, accountName: a.account_name, accountNumber: a.account_number,
|
||||
}));
|
||||
},
|
||||
errorMessage: '은행 계좌 조회에 실패했습니다.',
|
||||
});
|
||||
const result = await fetchBankAccountDetailOptions();
|
||||
return { success: result.success, data: result.data || [], error: result.error };
|
||||
}
|
||||
|
||||
@@ -219,14 +205,6 @@ export async function getVendors(): Promise<{
|
||||
data: { id: string; name: string }[];
|
||||
error?: string;
|
||||
}> {
|
||||
const result = await executeServerAction({
|
||||
url: `${API_URL}/api/v1/clients?per_page=100`,
|
||||
transform: (data: { data?: { id: number; name: string }[] } | { id: number; name: string }[]) => {
|
||||
type ClientApi = { id: number; name: string };
|
||||
const clients: ClientApi[] = Array.isArray(data) ? data : (data as { data?: ClientApi[] })?.data || [];
|
||||
return clients.map(c => ({ id: String(c.id), name: c.name }));
|
||||
},
|
||||
errorMessage: '거래처 조회에 실패했습니다.',
|
||||
});
|
||||
const result = await fetchVendorOptions();
|
||||
return { success: result.success, data: result.data || [], error: result.error };
|
||||
}
|
||||
@@ -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 { cookies } from 'next/headers';
|
||||
import type { VendorLedgerItem, VendorLedgerDetail, VendorLedgerSummary, TransactionEntry } from './types';
|
||||
@@ -64,13 +65,7 @@ interface VendorLedgerApiDetail {
|
||||
transactions: VendorLedgerApiTransaction[];
|
||||
}
|
||||
|
||||
interface VendorLedgerPaginatedResponse {
|
||||
data: VendorLedgerApiItem[];
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
}
|
||||
type VendorLedgerPaginatedResponse = PaginatedApiResponse<VendorLedgerApiItem>;
|
||||
|
||||
interface FrontendPagination { currentPage: number; lastPage: number; perPage: number; total: number }
|
||||
const DEFAULT_PAGINATION: FrontendPagination = { currentPage: 1, lastPage: 1, perPage: 20, total: 0 };
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
|
||||
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
|
||||
import type { PaginatedApiResponse } from '@/lib/api/types';
|
||||
import { fetchVendorOptions, fetchBankAccountOptions } from '@/lib/api/shared-lookups';
|
||||
import type { WithdrawalRecord, WithdrawalType } from './types';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||
@@ -30,13 +32,7 @@ interface WithdrawalApiData {
|
||||
card?: { id: number; card_name: string } | null;
|
||||
}
|
||||
|
||||
interface WithdrawalPaginatedResponse {
|
||||
data: WithdrawalApiData[];
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
}
|
||||
type WithdrawalPaginatedResponse = PaginatedApiResponse<WithdrawalApiData>;
|
||||
|
||||
interface FrontendPagination { currentPage: number; lastPage: number; perPage: number; total: number }
|
||||
const DEFAULT_PAGINATION: FrontendPagination = { currentPage: 1, lastPage: 1, perPage: 20, total: 0 };
|
||||
@@ -165,15 +161,7 @@ export async function updateWithdrawal(id: string, data: Partial<WithdrawalRecor
|
||||
export async function getVendors(): Promise<{
|
||||
success: boolean; data: { id: string; name: string }[]; error?: string;
|
||||
}> {
|
||||
const result = await executeServerAction({
|
||||
url: `${API_URL}/api/v1/clients?per_page=100`,
|
||||
transform: (data: { data?: { id: number; name: string }[] } | { id: number; name: string }[]) => {
|
||||
type ClientApi = { id: number; name: string };
|
||||
const clients: ClientApi[] = Array.isArray(data) ? data : (data as { data?: ClientApi[] })?.data || [];
|
||||
return clients.map(c => ({ id: String(c.id), name: c.name }));
|
||||
},
|
||||
errorMessage: '거래처 조회에 실패했습니다.',
|
||||
});
|
||||
const result = await fetchVendorOptions();
|
||||
return { success: result.success, data: result.data || [], error: result.error };
|
||||
}
|
||||
|
||||
@@ -181,14 +169,6 @@ export async function getVendors(): Promise<{
|
||||
export async function getBankAccounts(): Promise<{
|
||||
success: boolean; data: { id: string; name: string }[]; error?: string;
|
||||
}> {
|
||||
const result = await executeServerAction({
|
||||
url: `${API_URL}/api/v1/bank-accounts?per_page=100`,
|
||||
transform: (data: { data?: { id: number; account_name: string; bank_name: string }[] } | { id: number; account_name: string; bank_name: string }[]) => {
|
||||
type AccountApi = { id: number; account_name: string; bank_name: string };
|
||||
const accounts: AccountApi[] = Array.isArray(data) ? data : (data as { data?: AccountApi[] })?.data || [];
|
||||
return accounts.map(a => ({ id: String(a.id), name: `${a.bank_name} ${a.account_name}` }));
|
||||
},
|
||||
errorMessage: '계좌 조회에 실패했습니다.',
|
||||
});
|
||||
const result = await fetchBankAccountOptions();
|
||||
return { success: result.success, data: result.data || [], error: result.error };
|
||||
}
|
||||
|
||||
@@ -12,20 +12,13 @@
|
||||
|
||||
|
||||
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
|
||||
import type { PaginatedApiResponse } from '@/lib/api/types';
|
||||
import type { ApprovalRecord, ApprovalType, ApprovalStatus } from './types';
|
||||
|
||||
// ============================================
|
||||
// API 응답 타입 정의
|
||||
// ============================================
|
||||
|
||||
interface PaginatedResponse<T> {
|
||||
current_page: number;
|
||||
data: T[];
|
||||
total: number;
|
||||
per_page: number;
|
||||
last_page: number;
|
||||
}
|
||||
|
||||
interface InboxSummary {
|
||||
total: number;
|
||||
pending: number;
|
||||
@@ -134,7 +127,7 @@ export async function getInbox(params?: {
|
||||
if (params?.sort_by) searchParams.set('sort_by', params.sort_by);
|
||||
if (params?.sort_dir) searchParams.set('sort_dir', params.sort_dir);
|
||||
|
||||
const result = await executeServerAction<PaginatedResponse<InboxApiData>>({
|
||||
const result = await executeServerAction<PaginatedApiResponse<InboxApiData>>({
|
||||
url: `${API_URL}/api/v1/approvals/inbox?${searchParams.toString()}`,
|
||||
errorMessage: '결재함 목록 조회에 실패했습니다.',
|
||||
});
|
||||
|
||||
@@ -14,20 +14,13 @@
|
||||
|
||||
|
||||
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
|
||||
import type { PaginatedApiResponse } from '@/lib/api/types';
|
||||
import type { DraftRecord, DocumentStatus, Approver } from './types';
|
||||
|
||||
// ============================================
|
||||
// API 응답 타입 정의
|
||||
// ============================================
|
||||
|
||||
interface PaginatedResponse<T> {
|
||||
current_page: number;
|
||||
data: T[];
|
||||
total: number;
|
||||
per_page: number;
|
||||
last_page: number;
|
||||
}
|
||||
|
||||
interface DraftsSummary {
|
||||
total: number;
|
||||
draft: number;
|
||||
@@ -183,7 +176,7 @@ export async function getDrafts(params?: {
|
||||
if (params?.sort_by) searchParams.set('sort_by', params.sort_by);
|
||||
if (params?.sort_dir) searchParams.set('sort_dir', params.sort_dir);
|
||||
|
||||
const result = await executeServerAction<PaginatedResponse<ApprovalApiData>>({
|
||||
const result = await executeServerAction<PaginatedApiResponse<ApprovalApiData>>({
|
||||
url: `${API_URL}/api/v1/approvals/drafts?${searchParams.toString()}`,
|
||||
errorMessage: '기안함 목록 조회에 실패했습니다.',
|
||||
});
|
||||
|
||||
@@ -12,20 +12,13 @@
|
||||
|
||||
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import type { PaginatedApiResponse } from '@/lib/api/types';
|
||||
import type { ReferenceRecord, ApprovalType, DocumentStatus } from './types';
|
||||
|
||||
// ============================================
|
||||
// API 응답 타입 정의
|
||||
// ============================================
|
||||
|
||||
interface PaginatedResponse<T> {
|
||||
current_page: number;
|
||||
data: T[];
|
||||
total: number;
|
||||
per_page: number;
|
||||
last_page: number;
|
||||
}
|
||||
|
||||
interface ReferenceApiData {
|
||||
id: number;
|
||||
document_number: string;
|
||||
@@ -108,7 +101,7 @@ export async function getReferences(params?: {
|
||||
if (params?.sort_by) searchParams.set('sort_by', params.sort_by);
|
||||
if (params?.sort_dir) searchParams.set('sort_dir', params.sort_dir);
|
||||
|
||||
const result = await executeServerAction<PaginatedResponse<ReferenceApiData>>({
|
||||
const result = await executeServerAction<PaginatedApiResponse<ReferenceApiData>>({
|
||||
url: `${API_URL}/api/v1/approvals/reference?${searchParams.toString()}`,
|
||||
errorMessage: '참조 목록 조회에 실패했습니다.',
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
|
||||
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
|
||||
import type { PaginatedApiResponse } from '@/lib/api/types';
|
||||
import { getTodayString } from '@/utils/date';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||
@@ -37,13 +38,7 @@ export interface AttendanceRecord {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface AttendancePaginatedResponse {
|
||||
current_page: number;
|
||||
data: Record<string, unknown>[];
|
||||
total: number;
|
||||
per_page: number;
|
||||
last_page: number;
|
||||
}
|
||||
type AttendancePaginatedResponse = PaginatedApiResponse<Record<string, unknown>>;
|
||||
|
||||
// ===== 변환 =====
|
||||
function transformApiToFrontend(apiData: Record<string, unknown>): AttendanceRecord {
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
|
||||
import { executeServerAction } from '@/lib/api/execute-server-action';
|
||||
import type { PaginatedApiResponse } from '@/lib/api/types';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { cookies } from 'next/headers';
|
||||
import type {
|
||||
@@ -26,18 +27,6 @@ import type {
|
||||
EmployeeOption,
|
||||
} from './types';
|
||||
|
||||
// ============================================
|
||||
// API 응답 타입 정의
|
||||
// ============================================
|
||||
|
||||
interface PaginatedResponse<T> {
|
||||
current_page: number;
|
||||
data: T[];
|
||||
total: number;
|
||||
per_page: number;
|
||||
last_page: number;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 헬퍼 함수
|
||||
// ============================================
|
||||
@@ -171,7 +160,7 @@ interface EmployeeApiData {
|
||||
}
|
||||
|
||||
export async function getEmployeesForAttendance(): Promise<EmployeeOption[]> {
|
||||
const result = await executeServerAction<PaginatedResponse<EmployeeApiData>>({
|
||||
const result = await executeServerAction<PaginatedApiResponse<EmployeeApiData>>({
|
||||
url: `${API_URL}/v1/employees?per_page=100&status=active`,
|
||||
errorMessage: '사원 목록 조회에 실패했습니다.',
|
||||
});
|
||||
@@ -208,7 +197,7 @@ export async function getAttendances(params?: {
|
||||
if (params?.sort_by) searchParams.set('sort_by', params.sort_by);
|
||||
if (params?.sort_dir) searchParams.set('sort_dir', params.sort_dir);
|
||||
|
||||
const result = await executeServerAction<PaginatedResponse<AttendanceApiData>>({
|
||||
const result = await executeServerAction<PaginatedApiResponse<AttendanceApiData>>({
|
||||
url: `${API_URL}/v1/attendances?${searchParams.toString()}`,
|
||||
errorMessage: '근태 목록 조회에 실패했습니다.',
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
|
||||
|
||||
import { executeServerAction } from '@/lib/api/execute-server-action';
|
||||
import type { PaginatedApiResponse } from '@/lib/api/types';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { cookies } from 'next/headers';
|
||||
import type { Employee, EmployeeFormData, EmployeeStats } from './types';
|
||||
@@ -32,14 +33,6 @@ interface ApiResponse<T> {
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface PaginatedResponse<T> {
|
||||
current_page: number;
|
||||
data: T[];
|
||||
total: number;
|
||||
per_page: number;
|
||||
last_page: number;
|
||||
}
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||
|
||||
// ============================================
|
||||
@@ -61,7 +54,7 @@ export async function getEmployees(params?: {
|
||||
if (params?.sort_by) searchParams.set('sort_by', params.sort_by);
|
||||
if (params?.sort_dir) searchParams.set('sort_dir', params.sort_dir);
|
||||
|
||||
const result = await executeServerAction<PaginatedResponse<EmployeeApiData>>({
|
||||
const result = await executeServerAction<PaginatedApiResponse<EmployeeApiData>>({
|
||||
url: `${API_URL}/api/v1/employees?${searchParams.toString()}`,
|
||||
errorMessage: '직원 목록 조회에 실패했습니다.',
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
|
||||
|
||||
import { executeServerAction } from '@/lib/api/execute-server-action';
|
||||
import type { PaginatedApiResponse } from '@/lib/api/types';
|
||||
|
||||
// ============================================
|
||||
// 타입 정의
|
||||
@@ -164,14 +165,6 @@ interface ApiResponse<T> {
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface PaginatedResponse<T> {
|
||||
current_page: number;
|
||||
data: T[];
|
||||
total: number;
|
||||
per_page: number;
|
||||
last_page: number;
|
||||
}
|
||||
|
||||
// API URL
|
||||
const API_URL = `${process.env.NEXT_PUBLIC_API_URL}/api`;
|
||||
|
||||
@@ -259,7 +252,7 @@ export async function getLeaves(params?: GetLeavesParams): Promise<{
|
||||
if (params.page) searchParams.append('page', params.page.toString());
|
||||
}
|
||||
const queryString = searchParams.toString();
|
||||
const result = await executeServerAction<PaginatedResponse<Record<string, unknown>>>({
|
||||
const result = await executeServerAction<PaginatedApiResponse<Record<string, unknown>>>({
|
||||
url: `${API_URL}/v1/leaves${queryString ? `?${queryString}` : ''}`,
|
||||
errorMessage: '휴가 목록 조회에 실패했습니다.',
|
||||
});
|
||||
@@ -420,7 +413,7 @@ export async function getLeaveBalances(params?: GetLeaveBalancesParams): Promise
|
||||
if (params.page) searchParams.append('page', params.page.toString());
|
||||
}
|
||||
const queryString = searchParams.toString();
|
||||
const result = await executeServerAction<PaginatedResponse<Record<string, unknown>>>({
|
||||
const result = await executeServerAction<PaginatedApiResponse<Record<string, unknown>>>({
|
||||
url: `${API_URL}/v1/leaves/balances${queryString ? `?${queryString}` : ''}`,
|
||||
errorMessage: '휴가 사용현황 조회에 실패했습니다.',
|
||||
});
|
||||
@@ -539,7 +532,7 @@ export async function getLeaveGrants(params?: GetLeaveGrantsParams): Promise<{
|
||||
if (params.page) searchParams.append('page', params.page.toString());
|
||||
}
|
||||
const queryString = searchParams.toString();
|
||||
const result = await executeServerAction<PaginatedResponse<Record<string, unknown>>>({
|
||||
const result = await executeServerAction<PaginatedApiResponse<Record<string, unknown>>>({
|
||||
url: `${API_URL}/v1/leaves/grants${queryString ? `?${queryString}` : ''}`,
|
||||
errorMessage: '휴가 부여 이력 조회에 실패했습니다.',
|
||||
});
|
||||
@@ -620,8 +613,8 @@ export interface EmployeeOption {
|
||||
|
||||
/** 활성 직원 목록 조회 (휴가 신청/부여용) */
|
||||
export async function getActiveEmployees(): Promise<{ success: boolean; data?: EmployeeOption[]; error?: string }> {
|
||||
interface EmployeePaginatedResponse { data: Record<string, unknown>[]; total: number }
|
||||
const result = await executeServerAction<EmployeePaginatedResponse>({
|
||||
interface EmployeePaginatedApiResponse { data: Record<string, unknown>[]; total: number }
|
||||
const result = await executeServerAction<EmployeePaginatedApiResponse>({
|
||||
url: `${API_URL}/v1/employees?status=active&per_page=100`,
|
||||
errorMessage: '직원 목록 조회에 실패했습니다.',
|
||||
});
|
||||
|
||||
@@ -1,24 +1,15 @@
|
||||
/**
|
||||
* 발주처(매입 거래처) 검색 모달
|
||||
*
|
||||
* - 거래처명으로 검색
|
||||
* SearchableSelectionModal 공통 컴포넌트 기반
|
||||
* - 매입 가능 거래처만 표시 (client_type: PURCHASE, BOTH)
|
||||
* - ItemSearchModal과 동일한 Dialog + 클라이언트 프록시 패턴
|
||||
* - 최소 입력 조건: 한글 완성형 1자 또는 영문 2자 이상
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Search, X, Loader2 } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useCallback } from 'react';
|
||||
import { SearchableSelectionModal } from '@/components/organisms/SearchableSelectionModal';
|
||||
|
||||
// =============================================================================
|
||||
// 타입
|
||||
@@ -40,7 +31,7 @@ interface SupplierSearchModalProps {
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// API 응답 변환
|
||||
// API
|
||||
// =============================================================================
|
||||
|
||||
interface ApiClientResponse {
|
||||
@@ -63,20 +54,13 @@ function transformClientFromApi(apiClient: ApiClientResponse): SupplierItem {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 매입 가능 거래처 조회 (클라이언트 프록시 경유)
|
||||
* client_type: PURCHASE 또는 BOTH
|
||||
*/
|
||||
async function fetchPurchaseClients(search?: string): Promise<SupplierItem[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (search) params.set('q', search);
|
||||
params.set('size', '50');
|
||||
// 매입 가능 거래처만 (PURCHASE, BOTH)
|
||||
params.set('client_type', 'PURCHASE,BOTH');
|
||||
|
||||
const url = `/api/proxy/clients?${params.toString()}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
const response = await fetch(`/api/proxy/clients?${params.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
@@ -87,7 +71,6 @@ async function fetchPurchaseClients(search?: string): Promise<SupplierItem[]> {
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
let rawItems: ApiClientResponse[] = [];
|
||||
|
||||
if (result.success && result.data) {
|
||||
@@ -107,9 +90,6 @@ async function fetchPurchaseClients(search?: string): Promise<SupplierItem[]> {
|
||||
// 유효성 검사
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* 검색어 유효성: 한글 완성형 1자 이상 또는 영문 2자 이상
|
||||
*/
|
||||
function isValidSearchQuery(query: string): boolean {
|
||||
if (!query || !query.trim()) return false;
|
||||
const trimmed = query.trim();
|
||||
@@ -130,139 +110,51 @@ export function SupplierSearchModal({
|
||||
onOpenChange,
|
||||
onSelectSupplier,
|
||||
}: SupplierSearchModalProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [suppliers, setSuppliers] = useState<SupplierItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 거래처 목록 조회
|
||||
const loadSuppliers = useCallback(async (search?: string) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await fetchPurchaseClients(search);
|
||||
setSuppliers(data);
|
||||
} catch (err) {
|
||||
console.error('[SupplierSearchModal] 거래처 조회 오류:', err);
|
||||
setError('거래처 목록을 불러오는데 실패했습니다.');
|
||||
setSuppliers([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
const handleFetchData = useCallback(async (query: string) => {
|
||||
return fetchPurchaseClients(query || undefined);
|
||||
}, []);
|
||||
|
||||
// 모달 열릴 때 초기화
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSuppliers([]);
|
||||
setError(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// 검색어 변경 시 디바운스 검색
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
if (!isValidSearchQuery(searchQuery)) {
|
||||
setSuppliers([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
loadSuppliers(searchQuery);
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery, open, loadSuppliers]);
|
||||
|
||||
const handleSelect = (supplier: SupplierItem) => {
|
||||
const handleSelect = useCallback((supplier: SupplierItem) => {
|
||||
onSelectSupplier({ name: supplier.name, code: supplier.clientCode });
|
||||
onOpenChange(false);
|
||||
setSearchQuery('');
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
onOpenChange(false);
|
||||
setSearchQuery('');
|
||||
};
|
||||
}, [onSelectSupplier]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>발주처 검색</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 검색 입력 */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="거래처명 검색..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 pr-10"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2"
|
||||
>
|
||||
<X className="h-4 w-4 text-gray-400 hover:text-gray-600" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 거래처 목록 */}
|
||||
<div className="max-h-[400px] overflow-y-auto border rounded-lg">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center p-8 text-gray-500">
|
||||
<Loader2 className="h-5 w-5 animate-spin mr-2" />
|
||||
<span>거래처 검색 중...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-4 text-center text-red-500 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
) : suppliers.length === 0 ? (
|
||||
<div className="p-4 text-center text-gray-500 text-sm">
|
||||
{!searchQuery
|
||||
? '한글 1자(완성형) 또는 영문 2자 이상 입력하세요'
|
||||
: !isValidSearchQuery(searchQuery)
|
||||
? '한글 1자(완성형) 또는 영문 2자 이상 입력하세요'
|
||||
: '검색 결과가 없습니다'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{suppliers.map((supplier, index) => (
|
||||
<div
|
||||
key={`${supplier.id}-${index}`}
|
||||
onClick={() => handleSelect(supplier)}
|
||||
className="p-3 hover:bg-blue-50 cursor-pointer transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-gray-900">{supplier.name}</span>
|
||||
{supplier.clientCode && (
|
||||
<span className="text-xs text-gray-400 bg-gray-100 px-2 py-0.5 rounded">
|
||||
{supplier.clientCode}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{supplier.contactPerson && (
|
||||
<p className="text-xs text-gray-400 mt-1">담당: {supplier.contactPerson}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 거래처 개수 표시 */}
|
||||
{!isLoading && !error && (
|
||||
<div className="text-xs text-gray-400 text-right">
|
||||
총 {suppliers.length}개 거래처
|
||||
<SearchableSelectionModal<SupplierItem>
|
||||
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 ? (
|
||||
<span className="text-xs text-gray-400 text-right block">
|
||||
총 {items.length}개 거래처
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
mode="single"
|
||||
onSelect={handleSelect}
|
||||
renderItem={(supplier) => (
|
||||
<div className="p-3 hover:bg-blue-50 transition-colors">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-gray-900">{supplier.name}</span>
|
||||
{supplier.clientCode && (
|
||||
<span className="text-xs text-gray-400 bg-gray-100 px-2 py-0.5 rounded">
|
||||
{supplier.clientCode}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{supplier.contactPerson && (
|
||||
<p className="text-xs text-gray-400 mt-1">담당: {supplier.contactPerson}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ReceivingApiData>;
|
||||
|
||||
interface ReceivingApiStatsResponse {
|
||||
receiving_pending_count: number;
|
||||
|
||||
@@ -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<ItemApiData>;
|
||||
|
||||
interface StockApiStatsResponse {
|
||||
total_items: number;
|
||||
|
||||
@@ -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<string, { label: string; className: string }> = {
|
||||
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 (
|
||||
<Badge variant="outline" className={cn("text-xs", cfg.className)}>
|
||||
<Badge variant="outline" className={cn('text-xs', cfg.className)}>
|
||||
{cfg.label}
|
||||
</Badge>
|
||||
);
|
||||
@@ -49,148 +43,71 @@ export function QuotationSelectDialog({
|
||||
onSelect,
|
||||
selectedId,
|
||||
}: QuotationSelectDialogProps) {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [quotations, setQuotations] = useState<QuotationForSelect[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
견적 선택
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 검색창 */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="견적번호, 거래처, 현장명 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 안내 문구 */}
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{isLoading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
견적 목록을 불러오는 중...
|
||||
</span>
|
||||
) : error ? (
|
||||
<span className="text-red-500">{error}</span>
|
||||
) : (
|
||||
`전환 가능한 견적 ${quotations.length}건 (최종확정 상태)`
|
||||
<SearchableSelectionModal<QuotationForSelect>
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
견적 선택
|
||||
</span>
|
||||
}
|
||||
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) => (
|
||||
<div
|
||||
className={cn(
|
||||
'p-4 border rounded-lg hover:bg-muted/50 hover:border-primary/50 transition-colors',
|
||||
selectedId === quotation.id && 'border-primary bg-primary/5'
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 견적 목록 */}
|
||||
<div className="flex-1 overflow-y-auto space-y-3 pr-2">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-sm font-mono bg-gray-100 px-2 py-0.5 rounded">
|
||||
{quotation.quoteNumber}
|
||||
</code>
|
||||
<GradeBadge grade={quotation.grade} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{quotations.map((quotation) => (
|
||||
<div
|
||||
key={quotation.id}
|
||||
onClick={() => 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"
|
||||
)}
|
||||
>
|
||||
{/* 상단: 견적번호 + 등급 */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-sm font-mono bg-gray-100 px-2 py-0.5 rounded">
|
||||
{quotation.quoteNumber}
|
||||
</code>
|
||||
<GradeBadge grade={quotation.grade} />
|
||||
</div>
|
||||
{selectedId === quotation.id && (
|
||||
<Check className="h-5 w-5 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 발주처 */}
|
||||
<div className="font-medium text-base mb-1">
|
||||
{quotation.client}
|
||||
</div>
|
||||
|
||||
{/* 현장명 + 금액 */}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
[{quotation.siteName}]
|
||||
</span>
|
||||
<span className="font-medium text-green-600">
|
||||
{formatAmount(quotation.amount)}원
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 품목 수 */}
|
||||
<div className="text-xs text-muted-foreground mt-1 text-right">
|
||||
{quotation.itemCount}개 품목
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{quotations.length === 0 && !error && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
검색 결과가 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{selectedId === quotation.id && (
|
||||
<Check className="h-5 w-5 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
<div className="font-medium text-base mb-1">
|
||||
{quotation.client}
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
[{quotation.siteName}]
|
||||
</span>
|
||||
<span className="font-medium text-green-600">
|
||||
{formatAmount(quotation.amount)}원
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1 text-right">
|
||||
{quotation.itemCount}개 품목
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<T> {
|
||||
data: T;
|
||||
}
|
||||
|
||||
interface PaginatedResponse<T> {
|
||||
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<PaginatedResponse<ApiOrder>>({
|
||||
const result = await executeServerAction<PaginatedApiResponse<ApiOrder>>({
|
||||
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<PaginatedResponse<ApiQuoteForSelect>>({
|
||||
const result = await executeServerAction<PaginatedApiResponse<ApiQuoteForSelect>>({
|
||||
url: `${API_URL}/api/v1/quotes?${searchParams.toString()}`,
|
||||
errorMessage: '견적 목록 조회에 실패했습니다.',
|
||||
});
|
||||
|
||||
@@ -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<T>(props: SearchableSelectionModalProps<T>) {
|
||||
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<T>({
|
||||
open,
|
||||
fetchData,
|
||||
searchMode,
|
||||
debounceDelay,
|
||||
validateSearch,
|
||||
loadOnOpen,
|
||||
});
|
||||
|
||||
// 다중선택 상태
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(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 (
|
||||
<div className="flex items-center justify-center p-8 text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin mr-2" />
|
||||
<span>{loadingMessage}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-4 text-center text-red-500 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="p-4 text-center text-muted-foreground text-sm">
|
||||
{getEmptyMessage()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const itemElements = items.map((item) => (
|
||||
<div key={keyExtractor(item)} onClick={() => handleItemClick(item)} className="cursor-pointer">
|
||||
{renderItem(item, isSelected(item))}
|
||||
</div>
|
||||
));
|
||||
|
||||
if (listWrapper) {
|
||||
const selectState = mode === 'multiple'
|
||||
? { isAllSelected, onToggleAll: handleToggleAll }
|
||||
: undefined;
|
||||
return listWrapper(<>{itemElements}</>, selectState);
|
||||
}
|
||||
|
||||
return <div className="divide-y">{itemElements}</div>;
|
||||
};
|
||||
|
||||
const multiProps = mode === 'multiple' ? props as Extract<typeof props, { mode: 'multiple' }> : null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className={dialogClassName}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 검색 입력 */}
|
||||
{searchMode === 'enter' ? (
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
placeholder={searchPlaceholder}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" onClick={triggerSearch}>
|
||||
검색
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 pr-10"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2"
|
||||
>
|
||||
<X className="h-4 w-4 text-muted-foreground hover:text-foreground" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 정보 텍스트 */}
|
||||
{infoText && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{infoText(items, isLoading)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 다중선택 헤더 (전체선택 등) */}
|
||||
{mode === 'multiple' && multiProps?.renderHeader && (
|
||||
multiProps.renderHeader({ isAllSelected, onToggleAll: handleToggleAll })
|
||||
)}
|
||||
|
||||
{/* 리스트 */}
|
||||
<div className={listContainerClassName}>
|
||||
{renderListContent()}
|
||||
</div>
|
||||
|
||||
{/* 다중선택 푸터 */}
|
||||
{mode === 'multiple' && (
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} disabled={selectedIds.size === 0}>
|
||||
{multiProps?.confirmLabel || '선택'} ({selectedIds.size}건)
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { SearchableSelectionModal } from './SearchableSelectionModal';
|
||||
export type {
|
||||
SearchableSelectionModalProps,
|
||||
SingleSelectProps,
|
||||
MultipleSelectProps,
|
||||
} from './types';
|
||||
84
src/components/organisms/SearchableSelectionModal/types.ts
Normal file
84
src/components/organisms/SearchableSelectionModal/types.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
// =============================================================================
|
||||
// 공통 Props
|
||||
// =============================================================================
|
||||
|
||||
interface BaseProps<T> {
|
||||
/** 모달 열림 상태 */
|
||||
open: boolean;
|
||||
/** 모달 열림/닫힘 제어 */
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** 모달 제목 */
|
||||
title: ReactNode;
|
||||
/** 검색 placeholder */
|
||||
searchPlaceholder?: string;
|
||||
/** 데이터 조회 함수 (검색어 → 결과 배열) */
|
||||
fetchData: (query: string) => Promise<T[]>;
|
||||
/** 고유 키 추출 */
|
||||
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<T> extends BaseProps<T> {
|
||||
mode: 'single';
|
||||
onSelect: (item: T) => void;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 다중 선택
|
||||
// =============================================================================
|
||||
|
||||
export interface MultipleSelectProps<T> extends BaseProps<T> {
|
||||
mode: 'multiple';
|
||||
onSelect: (items: T[]) => void;
|
||||
/** 확인 버튼 라벨 (기본: "선택") */
|
||||
confirmLabel?: string;
|
||||
/** 전체선택 허용 */
|
||||
allowSelectAll?: boolean;
|
||||
/** 헤더 영역 (전체선택 체크박스 등) */
|
||||
renderHeader?: (params: {
|
||||
isAllSelected: boolean;
|
||||
onToggleAll: () => void;
|
||||
}) => ReactNode;
|
||||
}
|
||||
|
||||
export type SearchableSelectionModalProps<T> =
|
||||
| SingleSelectProps<T>
|
||||
| MultipleSelectProps<T>;
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
|
||||
interface UseSearchableDataOptions<T> {
|
||||
open: boolean;
|
||||
fetchData: (query: string) => Promise<T[]>;
|
||||
searchMode: 'debounce' | 'enter';
|
||||
debounceDelay: number;
|
||||
validateSearch?: (query: string) => boolean;
|
||||
loadOnOpen: boolean;
|
||||
}
|
||||
|
||||
interface UseSearchableDataReturn<T> {
|
||||
searchQuery: string;
|
||||
setSearchQuery: (query: string) => void;
|
||||
items: T[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
triggerSearch: () => void;
|
||||
handleSearchKeyDown: (e: React.KeyboardEvent) => void;
|
||||
}
|
||||
|
||||
export function useSearchableData<T>({
|
||||
open,
|
||||
fetchData,
|
||||
searchMode,
|
||||
debounceDelay,
|
||||
validateSearch,
|
||||
loadOnOpen,
|
||||
}: UseSearchableDataOptions<T>): UseSearchableDataReturn<T> {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [items, setItems] = useState<T[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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,
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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<ShipmentApiData>;
|
||||
|
||||
interface ShipmentApiStatsResponse {
|
||||
today_shipment_count: number;
|
||||
|
||||
@@ -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<T> {
|
||||
data: T;
|
||||
}
|
||||
|
||||
interface PaginatedResponse<T> {
|
||||
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<PaginatedResponse<ApiProcess>>({
|
||||
const result = await executeServerAction<PaginatedApiResponse<ApiProcess>>({
|
||||
url: `${API_URL}/api/v1/processes?${searchParams.toString()}`,
|
||||
errorMessage: '공정 목록 조회에 실패했습니다.',
|
||||
});
|
||||
|
||||
@@ -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<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(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<SalesOrder[]>([]);
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>수주 선택</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 검색 */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="수주번호, 거래처, 현장명 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 안내 문구 */}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
작업지시 가능한 수주 {salesOrders.length}건 (등록 상태 & 생산지시 미생성)
|
||||
</p>
|
||||
|
||||
{/* 수주 목록 */}
|
||||
<div className="max-h-[400px] overflow-y-auto space-y-2">
|
||||
{isLoading ? (
|
||||
<ContentSkeleton type="cards" rows={4} />
|
||||
) : salesOrders.map((order) => (
|
||||
<div
|
||||
key={order.id}
|
||||
onClick={() => handleSelect(order)}
|
||||
className="p-4 border rounded-lg cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold">{order.orderNo}</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{order.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-sm text-right">
|
||||
<span className="text-muted-foreground">납기: </span>
|
||||
<span>{order.dueDate}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground mb-1">
|
||||
{order.client}
|
||||
</div>
|
||||
<div className="text-sm mb-2">{order.projectName}</div>
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span>{order.itemCount}개 품목</span>
|
||||
<span>분할 {order.splitCount}건</span>
|
||||
</div>
|
||||
<SearchableSelectionModal<SalesOrder>
|
||||
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 ? (
|
||||
<span>작업지시 가능한 수주 {items.length}건 (등록 상태 & 생산지시 미생성)</span>
|
||||
) : null
|
||||
}
|
||||
mode="single"
|
||||
onSelect={onSelect}
|
||||
renderItem={(order) => (
|
||||
<div className="p-4 border rounded-lg hover:bg-muted/50 transition-colors">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold">{order.orderNo}</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{order.status}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
{!isLoading && salesOrders.length === 0 && (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
<FileText className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||||
<p>검색 결과가 없습니다.</p>
|
||||
<div className="text-sm text-right">
|
||||
<span className="text-muted-foreground">납기: </span>
|
||||
<span>{order.dueDate}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground mb-1">
|
||||
{order.client}
|
||||
</div>
|
||||
<div className="text-sm mb-2">{order.projectName}</div>
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span>{order.itemCount}개 품목</span>
|
||||
<span>분할 {order.splitCount}건</span>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
listWrapper={undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<OrderSelectItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>수주 선택</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 검색 */}
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
placeholder="수주번호, 현장명 검색..."
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" onClick={handleSearch}>
|
||||
검색
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="max-h-[400px] overflow-y-auto border rounded-md">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-10">
|
||||
<Checkbox
|
||||
checked={isAllSelected}
|
||||
onCheckedChange={handleToggleAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>수주번호</TableHead>
|
||||
<TableHead>현장명</TableHead>
|
||||
<TableHead className="text-center">납품일</TableHead>
|
||||
<TableHead className="text-center">개소</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((item) => (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleToggle(item.id)}
|
||||
>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={selectedIds.has(item.id)}
|
||||
onCheckedChange={() => handleToggle(item.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{item.orderNumber}</TableCell>
|
||||
<TableCell>{item.siteName}</TableCell>
|
||||
<TableCell className="text-center">{item.deliveryDate}</TableCell>
|
||||
<TableCell className="text-center">{item.locationCount}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{items.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center text-muted-foreground py-8">
|
||||
{searchTerm ? '검색 결과가 없습니다.' : '수주 데이터가 없습니다.'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<SearchableSelectionModal<OrderSelectItem>
|
||||
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) => (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-10">
|
||||
{selectState && (
|
||||
<Checkbox
|
||||
checked={selectState.isAllSelected}
|
||||
onCheckedChange={selectState.onToggleAll}
|
||||
/>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={selectedIds.size === 0}
|
||||
>
|
||||
선택 ({selectedIds.size}건)
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</TableHead>
|
||||
<TableHead>수주번호</TableHead>
|
||||
<TableHead>현장명</TableHead>
|
||||
<TableHead className="text-center">납품일</TableHead>
|
||||
<TableHead className="text-center">개소</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{children}
|
||||
{/* 빈 상태는 공통 컴포넌트에서 처리 */}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
renderItem={(item, isSelected) => (
|
||||
<TableRow className="cursor-pointer hover:bg-muted/50">
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={isSelected} />
|
||||
</TableCell>
|
||||
<TableCell>{item.orderNumber}</TableCell>
|
||||
<TableCell>{item.siteName}</TableCell>
|
||||
<TableCell className="text-center">{item.deliveryDate}</TableCell>
|
||||
<TableCell className="text-center">{item.locationCount}</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<ItemMaster[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
품목 검색
|
||||
{tabLabel && <span className="text-sm font-normal text-gray-500 ml-2">({tabLabel})</span>}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 검색 입력 */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="품목코드 또는 품목명 검색..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 pr-10"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery("")}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2"
|
||||
>
|
||||
<X className="h-4 w-4 text-gray-400 hover:text-gray-600" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 품목 목록 */}
|
||||
<div className="max-h-[400px] overflow-y-auto border rounded-lg">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center p-8 text-gray-500">
|
||||
<Loader2 className="h-5 w-5 animate-spin mr-2" />
|
||||
<span>품목 검색 중...</span>
|
||||
<SearchableSelectionModal<ItemMaster>
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={
|
||||
<>
|
||||
품목 검색
|
||||
{tabLabel && <span className="text-sm font-normal text-gray-500 ml-2">({tabLabel})</span>}
|
||||
</>
|
||||
}
|
||||
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 ? (
|
||||
<span className="text-xs text-gray-400 text-right block">
|
||||
총 {items.length}개 품목
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
mode="single"
|
||||
onSelect={handleSelect}
|
||||
renderItem={(item) => (
|
||||
<div className="p-3 hover:bg-blue-50 transition-colors">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-gray-900">{item.itemCode}</span>
|
||||
<span className="text-sm text-gray-600">{item.itemName}</span>
|
||||
{item.hasInspectionTemplate && (
|
||||
<span className="text-xs text-white bg-green-500 px-1.5 py-0.5 rounded">
|
||||
수입검사
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-4 text-center text-red-500 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
) : filteredItems.length === 0 ? (
|
||||
<div className="p-4 text-center text-gray-500 text-sm">
|
||||
{!searchQuery
|
||||
? "품목코드 또는 품목명을 입력하세요"
|
||||
: !isValidSearchQuery(searchQuery)
|
||||
? "영문, 한글 또는 숫자 1자 이상 입력하세요"
|
||||
: "검색 결과가 없습니다"}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{filteredItems.map((item, index) => (
|
||||
<div
|
||||
key={item.id ?? `${item.itemCode}-${index}`}
|
||||
onClick={() => handleSelect(item)}
|
||||
className="p-3 hover:bg-blue-50 cursor-pointer transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-gray-900">{item.itemCode}</span>
|
||||
<span className="text-sm text-gray-600">{item.itemName}</span>
|
||||
{item.hasInspectionTemplate && (
|
||||
<span className="text-xs text-white bg-green-500 px-1.5 py-0.5 rounded">
|
||||
수입검사
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{item.unit && (
|
||||
<span className="text-xs text-gray-400 bg-gray-100 px-2 py-0.5 rounded">
|
||||
{item.unit}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{item.specification && (
|
||||
<p className="text-xs text-gray-400 mt-1">{item.specification}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 품목 개수 표시 */}
|
||||
{!isLoading && !error && (
|
||||
<div className="text-xs text-gray-400 text-right">
|
||||
총 {filteredItems.length}개 품목
|
||||
{item.unit && (
|
||||
<span className="text-xs text-gray-400 bg-gray-100 px-2 py-0.5 rounded">
|
||||
{item.unit}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{item.specification && (
|
||||
<p className="text-xs text-gray-400 mt-1">{item.specification}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<BankAccountApiData>;
|
||||
|
||||
// ===== 데이터 변환 =====
|
||||
function transformApiToFrontend(apiData: BankAccountApiData): Account {
|
||||
|
||||
@@ -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<PaymentApiData>;
|
||||
|
||||
interface PaymentStatementApiData {
|
||||
statement_no: string;
|
||||
|
||||
@@ -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<T> {
|
||||
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<PopupApiData>) => data.data.map(transformApiToFrontend),
|
||||
transform: (data: PaginatedApiResponse<PopupApiData>) => data.data.map(transformApiToFrontend),
|
||||
errorMessage: '팝업 목록 조회에 실패했습니다.',
|
||||
});
|
||||
return result.data || [];
|
||||
|
||||
@@ -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<PositionApiData, Title>({
|
||||
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<ActionResult<Title[]>> {
|
||||
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<ActionResult<Title>> {
|
||||
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<ActionResult<Title>> {
|
||||
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<ActionResult> {
|
||||
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<ActionResult> {
|
||||
return executeServerAction({
|
||||
url: `${API_URL}/api/v1/positions/reorder`,
|
||||
method: 'PUT',
|
||||
body: { items },
|
||||
errorMessage: '순서 변경에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
return titleService.reorder(items);
|
||||
}
|
||||
|
||||
@@ -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<TApi, TFrontend> {
|
||||
@@ -37,6 +39,8 @@ export interface CrudServiceConfig<TApi, TFrontend> {
|
||||
defaultQueryParams?: Record<string, string>;
|
||||
/** 생성 시 body에 추가할 기본값 (예: { type: 'rank' }) */
|
||||
defaultCreateBody?: Record<string, unknown>;
|
||||
/** 수정 시 HTTP 메서드 (기본: 'PUT') */
|
||||
updateMethod?: 'PUT' | 'PATCH';
|
||||
}
|
||||
|
||||
// ===== 서비스 반환 타입 =====
|
||||
@@ -46,14 +50,18 @@ export interface CrudService<TFrontend> {
|
||||
q?: string;
|
||||
}): Promise<ActionResult<TFrontend[]>>;
|
||||
|
||||
getById(id: number | string): Promise<ActionResult<TFrontend>>;
|
||||
|
||||
create(body: Record<string, unknown>): Promise<ActionResult<TFrontend>>;
|
||||
|
||||
update(
|
||||
id: number,
|
||||
id: number | string,
|
||||
body: Record<string, unknown>
|
||||
): Promise<ActionResult<TFrontend>>;
|
||||
|
||||
remove(id: number): Promise<ActionResult>;
|
||||
remove(id: number | string): Promise<ActionResult>;
|
||||
|
||||
bulkDelete(ids: (number | string)[]): Promise<ActionResult>;
|
||||
|
||||
reorder(
|
||||
items: { id: number; sort_order: number }[]
|
||||
@@ -70,6 +78,7 @@ export function createCrudService<TApi, TFrontend>(
|
||||
entityName,
|
||||
defaultQueryParams,
|
||||
defaultCreateBody,
|
||||
updateMethod = 'PUT',
|
||||
} = config;
|
||||
|
||||
// API URL은 호출 시점에 resolve (SSR 안전)
|
||||
@@ -97,6 +106,14 @@ export function createCrudService<TApi, TFrontend>(
|
||||
});
|
||||
},
|
||||
|
||||
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<TApi, TFrontend>(
|
||||
async update(id, body) {
|
||||
return executeServerAction({
|
||||
url: `${getBaseUrl()}/${id}`,
|
||||
method: 'PUT',
|
||||
method: updateMethod,
|
||||
body,
|
||||
transform,
|
||||
errorMessage: `${entityName} 수정에 실패했습니다.`,
|
||||
@@ -125,6 +142,26 @@ export function createCrudService<TApi, TFrontend>(
|
||||
});
|
||||
},
|
||||
|
||||
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`,
|
||||
|
||||
@@ -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,
|
||||
|
||||
86
src/lib/api/shared-lookups.ts
Normal file
86
src/lib/api/shared-lookups.ts
Normal file
@@ -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<T> = { data?: T[] } | T[];
|
||||
|
||||
function extractArray<T>(data: PaginatedOrArray<T>): T[] {
|
||||
return Array.isArray(data) ? data : (data as { data?: T[] })?.data || [];
|
||||
}
|
||||
|
||||
// ===== 거래처 목록 조회 =====
|
||||
export async function fetchVendorOptions(): Promise<ActionResult<SelectOption[]>> {
|
||||
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<ClientApiItem>) => {
|
||||
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<ActionResult<SelectOption[]>> {
|
||||
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<BankAccountApiItem>) => {
|
||||
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<ActionResult<BankAccountOption[]>> {
|
||||
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<BankAccountApiItem>) => {
|
||||
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 };
|
||||
}
|
||||
47
src/lib/api/types.ts
Normal file
47
src/lib/api/types.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 공용 API 타입 및 페이지네이션 유틸리티
|
||||
*
|
||||
* 25+ 개 action 파일에서 반복 정의되던 PaginatedResponse 타입과
|
||||
* 페이지네이션 변환 헬퍼를 하나로 통합합니다.
|
||||
*/
|
||||
|
||||
// ===== API 페이지네이션 응답 (Laravel 표준) =====
|
||||
export interface PaginatedApiResponse<T> {
|
||||
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<T> {
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user