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

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>)

필수 적용 항목:

  1. 컬럼 설정 (useColumnSettings + ColumnSettingsPopover)
  2. 모바일 카드 (renderMobileCard)
  3. 체크박스 (selectedItems: Set<string>)
  4. 테이블 내 필터 (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