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:
@@ -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 패턴 |
|
||||
Reference in New Issue
Block a user