Files
sam-react-prod/sam-docs/frontend/v1/05-common-components.md
유병철 c309ac479f feat: [vehicle] 법인차량 관리 모듈 + MES 분석 보고서 + 프론트엔드 문서
- 법인차량 관리 3개 페이지 (차량등록, 운행일지, 정비이력)
- MES 데이터 정합성 분석 보고서 v1/v2
- sam-docs 프론트엔드 기술문서 v1 (9개 챕터)
- claudedocs 가이드/테스트URL 업데이트
2026-03-13 17:52:57 +09:00

347 lines
9.6 KiB
Markdown

# 공통 컴포넌트 가이드
## 컴포넌트 계층 요약
```
Templates → 페이지 전체 (IntegratedListTemplateV2)
Organisms → 페이지 블록 (PageHeader, DataTable, SearchFilter ...)
Molecules → 조합 단위 (FormField, StatusBadge, StandardDialog ...)
UI → 원자 단위 (Button, Input, Select ...)
```
---
## Templates
### IntegratedListTemplateV2
리스트 페이지를 위한 **올인원 템플릿**. 새 리스트 페이지 생성 시 이 템플릿 사용을 우선 검토합니다.
**경로**: `src/components/templates/IntegratedListTemplateV2.tsx`
**포함 기능**:
- PageLayout + PageHeader (아이콘/제목/설명)
- 검색 + 필터 + 날짜 선택 헤더
- 통계 카드 (StatCards)
- 테이블 + 컬럼 설정 + 페이지네이션
- 모바일 카드 자동 전환 (반응형)
- 체크박스 선택 (`Set<string>`)
**필수 적용 항목**:
1. 컬럼 설정 (`useColumnSettings` + `ColumnSettingsPopover`)
2. 모바일 카드 (`renderMobileCard`)
3. 체크박스 (`selectedItems: Set<string>`)
4. 테이블 내 필터 (`tableHeaderActions`)
**기본 사용법**:
```tsx
import IntegratedListTemplateV2 from '@/components/templates/IntegratedListTemplateV2';
import { useColumnSettings } from '@/hooks/useColumnSettings';
import { ColumnSettingsPopover } from '@/components/molecules/ColumnSettingsPopover';
import { MobileCard, InfoField } from '@/components/organisms';
const columns = [
{ key: 'itemName', label: '품목명', width: '200px' },
{ key: 'itemCode', label: '품목코드', width: '150px' },
{ key: 'status', label: '상태', width: '100px' },
];
export default function ItemListPage() {
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const { visibleColumns, allColumnsWithVisibility, columnWidths, setColumnWidth,
toggleColumnVisibility, resetSettings, hasHiddenColumns } =
useColumnSettings({ pageId: 'item-list', columns });
return (
<IntegratedListTemplateV2
title="품목 관리"
icon={Package}
description="품목 목록을 관리합니다"
// 검색
searchValue={search}
onSearchChange={setSearch}
searchPlaceholder="품목명 또는 코드로 검색"
// 테이블
tableColumns={visibleColumns}
columnSettings={{
columnWidths,
onColumnResize: setColumnWidth,
settingsPopover: (
<ColumnSettingsPopover
columns={allColumnsWithVisibility}
onToggle={toggleColumnVisibility}
onReset={resetSettings}
hasHiddenColumns={hasHiddenColumns}
/>
),
}}
data={items}
// 체크박스
selectedItems={selectedItems}
onToggleSelection={(id) => {
setSelectedItems(prev => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}}
onToggleSelectAll={() => { /* 전체 선택/해제 */ }}
getItemId={(item) => item.id}
// 테이블 행
renderTableRow={(item, index, globalIndex, isSelected, onToggle) => (
<tr key={item.id} className={isSelected ? 'bg-blue-50' : ''}>
<td><Checkbox checked={isSelected} onCheckedChange={onToggle} /></td>
<td>{globalIndex}</td>
<td>{item.itemName}</td>
<td>{item.itemCode}</td>
</tr>
)}
// 모바일 카드 (반응형)
renderMobileCard={(item, index, globalIndex, isSelected, onToggle) => (
<MobileCard
title={item.itemName}
subtitle={item.itemCode}
isSelected={isSelected}
onToggleSelection={onToggle}
details={[
{ label: '상태', value: item.status },
]}
/>
)}
// 페이지네이션
pagination={{
currentPage: pagination.currentPage,
totalPages: pagination.lastPage,
totalItems: pagination.total,
itemsPerPage: pagination.perPage,
onPageChange: (page) => fetchData({ page }),
}}
isLoading={isLoading}
// 등록 버튼
createButton={{ label: '품목 등록', onClick: () => router.push('?mode=new') }}
/>
);
}
```
---
## Organisms
**경로**: `src/components/organisms/`
**import**: `import { PageHeader, DataTable, ... } from '@/components/organisms'`
### PageHeader
```tsx
<PageHeader
title="품목 관리"
description="품목 목록을 관리합니다"
icon={Package}
actions={<Button onClick={handleCreate}>등록</Button>}
/>
```
| Prop | 타입 | 설명 |
|------|------|------|
| `title` | string \| ReactNode | 페이지 제목 (필수) |
| `description?` | string | 부제목 |
| `icon?` | LucideIcon | 좌측 아이콘 |
| `actions?` | ReactNode | 우측 액션 버튼 |
### PageLayout
```tsx
<PageLayout maxWidth="full">
{children}
</PageLayout>
```
| Prop | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| `maxWidth?` | "sm"\|"md"\|"lg"\|"xl"\|"2xl"\|"full" | "full" | 최대 너비 |
### StatCards
```tsx
<StatCards stats={[
{ label: '전체', value: 100, icon: Package },
{ label: '활성', value: 80, icon: CheckCircle, iconColor: 'text-green-500' },
{ label: '비활성', value: 20, icon: XCircle, iconColor: 'text-red-500' },
]} />
```
### SearchFilter
```tsx
<SearchFilter
searchValue={search}
onSearchChange={setSearch}
searchPlaceholder="검색어 입력"
extraActions={<DatePicker value={date} onChange={setDate} />}
/>
```
### DataTable
```tsx
<DataTable
columns={[
{ key: 'name', label: '이름', sortable: true },
{ key: 'status', label: '상태', type: 'badge' },
{ key: 'amount', label: '금액', type: 'currency', align: 'right' },
{ key: 'actions', label: '', type: 'custom',
render: (_, row) => <Button size="sm">수정</Button> },
]}
data={items}
keyField="id"
onRowClick={(row) => router.push(`/items/${row.id}`)}
pagination={{ currentPage, totalPages, onPageChange }}
/>
```
**Column type 종류**: `text`, `number`, `currency`, `date`, `datetime`, `status`, `badge`, `icon`, `actions`, `custom`
### SearchableSelectionModal
검색+선택 팝업이 필요할 때 사용. **직접 Dialog 조합 금지**.
```tsx
<SearchableSelectionModal<Vendor>
open={isOpen}
onOpenChange={setIsOpen}
title="거래처 검색"
fetchData={async (query) => {
const result = await searchVendors({ search: query });
return result.success ? result.data : [];
}}
keyExtractor={(vendor) => vendor.id}
mode="single"
onSelect={(vendor) => handleVendorSelect(vendor)}
searchPlaceholder="거래처명으로 검색"
renderItem={(vendor, isSelected) => (
<div className={cn('p-3', isSelected && 'bg-blue-50')}>
<div className="font-medium">{vendor.name}</div>
<div className="text-sm text-muted-foreground">{vendor.code}</div>
</div>
)}
/>
```
| Prop | 필수 | 설명 |
|------|:---:|------|
| `open` | O | 모달 열기 상태 |
| `onOpenChange` | O | 상태 변경 |
| `title` | O | 모달 제목 |
| `fetchData` | O | `(query: string) => Promise<T[]>` |
| `keyExtractor` | O | `(item: T) => string` |
| `mode` | O | `'single'` \| `'multiple'` |
| `onSelect` | O | 선택 콜백 |
| `renderItem` | O | 아이템 렌더링 |
| `searchMode?` | | `'debounce'`(기본) \| `'enter'` |
| `loadOnOpen?` | | 열릴 때 자동 로드 |
| `listWrapper?` | | 리스트 래퍼 (테이블 구조 등) |
### MobileCard / InfoField
```tsx
<MobileCard
title="품목A"
subtitle="P-001"
isSelected={isSelected}
onToggleSelection={onToggle}
details={[
{ label: '규격', value: '100x200' },
{ label: '단가', value: '10,000원' },
]}
onClick={() => router.push(`/items/${item.id}`)}
/>
```
### EmptyState / TableEmptyState
```tsx
<EmptyState message="데이터가 없습니다" />
<TableEmptyState colSpan={5} message="검색 결과가 없습니다" />
```
---
## Molecules
**경로**: `src/components/molecules/`
### FormField (신규 폼 필수)
`Label + Input + Error` 수동 조합 대신 사용.
```tsx
import { FormField } from '@/components/molecules/FormField';
<FormField
label="회사명"
required
type="text"
value={formData.companyName}
onChange={(value) => handleChange('companyName', value)}
placeholder="회사명을 입력하세요"
disabled={mode === 'view'}
error={errors.companyName}
/>
```
**지원 type**: `text`, `number`, `date`, `select`, `textarea`, `custom`, `password`, `phone`, `businessNumber`, `personalNumber`, `currency`, `quantity`
**FormField로 대체하지 않는 경우**:
- Select, DatePicker, ImageUpload 등 특수 컴포넌트
- 주소 검색(버튼+입력) 등 복합 레이아웃
- 편집/읽기 모드가 다른 커스텀 인터랙션
### StatusBadge
```tsx
import { StatusBadge } from '@/components/molecules/StatusBadge';
<StatusBadge label="승인" variant="success" />
<StatusBadge label="대기" variant="warning" showDot />
<StatusBadge label="반려" variant="danger" />
```
**variant**: `default`, `success`, `warning`, `danger`, `info`, `secondary`, `outline`
### ColumnSettingsPopover
`useColumnSettings` hook과 함께 사용:
```tsx
<ColumnSettingsPopover
columns={allColumnsWithVisibility}
onToggle={toggleColumnVisibility}
onReset={resetSettings}
hasHiddenColumns={hasHiddenColumns}
/>
```
### StandardDialog
```tsx
<StandardDialog
open={isOpen}
onOpenChange={setIsOpen}
title="확인"
description="정말 삭제하시겠습니까?"
size="md"
footer={
<>
<Button variant="outline" onClick={() => setIsOpen(false)}>취소</Button>
<Button variant="destructive" onClick={handleDelete}>삭제</Button>
</>
}
>
<p> 작업은 되돌릴 없습니다.</p>
</StandardDialog>
```
**size**: `sm`, `md`, `lg`, `xl`, `full`