- 법인차량 관리 3개 페이지 (차량등록, 운행일지, 정비이력) - MES 데이터 정합성 분석 보고서 v1/v2 - sam-docs 프론트엔드 기술문서 v1 (9개 챕터) - claudedocs 가이드/테스트URL 업데이트
347 lines
9.6 KiB
Markdown
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`
|