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:
유병철
2026-02-10 16:01:23 +09:00
parent 0643d56194
commit 437d5f6834
42 changed files with 1683 additions and 1144 deletions

View 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 패턴 |