- 법인차량 관리 3개 페이지 (차량등록, 운행일지, 정비이력) - MES 데이터 정합성 분석 보고서 v1/v2 - sam-docs 프론트엔드 기술문서 v1 (9개 챕터) - claudedocs 가이드/테스트URL 업데이트
9.6 KiB
9.6 KiB
공통 컴포넌트 가이드
컴포넌트 계층 요약
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>)
필수 적용 항목:
- 컬럼 설정 (
useColumnSettings+ColumnSettingsPopover) - 모바일 카드 (
renderMobileCard) - 체크박스 (
selectedItems: Set<string>) - 테이블 내 필터 (
tableHeaderActions)
기본 사용법:
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
<PageHeader
title="품목 관리"
description="품목 목록을 관리합니다"
icon={Package}
actions={<Button onClick={handleCreate}>등록</Button>}
/>
| Prop | 타입 | 설명 |
|---|---|---|
title |
string | ReactNode | 페이지 제목 (필수) |
description? |
string | 부제목 |
icon? |
LucideIcon | 좌측 아이콘 |
actions? |
ReactNode | 우측 액션 버튼 |
PageLayout
<PageLayout maxWidth="full">
{children}
</PageLayout>
| Prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
maxWidth? |
"sm"|"md"|"lg"|"xl"|"2xl"|"full" | "full" | 최대 너비 |
StatCards
<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
<SearchFilter
searchValue={search}
onSearchChange={setSearch}
searchPlaceholder="검색어 입력"
extraActions={<DatePicker value={date} onChange={setDate} />}
/>
DataTable
<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 조합 금지.
<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
<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
<EmptyState message="데이터가 없습니다" />
<TableEmptyState colSpan={5} message="검색 결과가 없습니다" />
Molecules
경로: src/components/molecules/
FormField (신규 폼 필수)
Label + Input + Error 수동 조합 대신 사용.
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
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과 함께 사용:
<ColumnSettingsPopover
columns={allColumnsWithVisibility}
onToggle={toggleColumnVisibility}
onReset={resetSettings}
hasHiddenColumns={hasHiddenColumns}
/>
StandardDialog
<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