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

28 KiB
Raw Blame History

SAM 프로젝트 공통 페이지/컴포넌트 패턴 가이드

신규 페이지·모달 작업 시 이 문서를 참고하여 기존 구조와 일관성을 유지한다.


목차

  1. 공통 컴포넌트 맵
  2. 검색 모달 (SearchableSelectionModal)
  3. 리스트 페이지
  4. IntegratedListTemplateV2 표준 적용
  5. 상세/폼 페이지
  6. API 연동 패턴
  7. 페이지 라우팅 구조

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

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. 단일선택 + 디바운스 검색 (가장 일반적)

// 품목 검색, 거래처 검색 등
<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 + 열릴 때 자동 로드

// 수주 선택, 견적 선택 등
<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 검색 + 테이블

// 수주 다중선택 (체크박스 테이블)
<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 객체를 전달하는 올인원 방식.

'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를 직접 조합.

'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. IntegratedListTemplateV2 표준 적용

개요

IntegratedListTemplateV2는 프로젝트의 표준 리스트 페이지 템플릿으로, 아래 기능을 한 번에 제공한다:

  • PageLayout + PageHeader (아이콘/제목/설명)
  • 날짜/검색/버튼 헤더 영역
  • 통계 카드
  • 테이블 (체크박스/번호/데이터/작업) + 페이지네이션
  • 모바일 카드 뷰 자동 전환
  • 컬럼 설정 (표시/숨기기/리사이즈)

위치: src/components/templates/IntegratedListTemplateV2.tsx

🔴 적용 시 필수 체크리스트

IntegratedListTemplateV2를 사용하는 페이지를 만들거나 리팩토링할 때, 아래 항목을 반드시 확인한다.

# 항목 설명 필수
1 컬럼 설정 useColumnSettings + ColumnSettingsPopover + columnSettings prop
2 검색 searchValue + onSearchChange + searchPlaceholder
3 체크박스 선택 selectedItems (Set<string>) + onToggleSelection + onToggleSelectAll + getItemId
4 페이지네이션 pagination (currentPage, totalPages, totalItems, itemsPerPage, onPageChange)
5 모바일 카드 renderMobileCard + MobileCard / InfoField 사용
6 테이블 행 renderTableRow (TableRow + TableCell 조합)
7 헤더 레이아웃 순서: [검색] [날짜/연월] --- [액션버튼] [등록버튼]
8 통계 카드 stats 배열 (label, value, icon, iconColor) 권장
9 테이블 내 필터 filterConfig 통합 필터 사용 (PC: 인라인, 모바일: 바텀시트 자동 분기). tableHeaderActions에 Select 직접 넣기 금지
10 tabsContent (커스텀) 또는 tabs + activeTab + onTabChange 필요 시

컬럼 설정 (필수 패턴)

매번 빠뜨리지 않도록 3가지 세트로 기억한다:

// 1⃣ Hook 선언
import { useColumnSettings } from '@/hooks/useColumnSettings';
import { ColumnSettingsPopover } from '@/components/molecules/ColumnSettingsPopover';

const TABLE_COLUMNS: TableColumn[] = [
  { key: 'no', label: '번호', className: 'text-center w-[60px]' },
  { key: 'name', label: '이름', copyable: true },
  // ...
  { key: 'actions', label: '작업', className: 'text-center w-[80px]' },
];

const {
  visibleColumns,          // → tableColumns prop에 전달
  allColumnsWithVisibility, // → ColumnSettingsPopover에 전달
  columnWidths,            // → columnSettings.columnWidths
  setColumnWidth,          // → columnSettings.onColumnResize
  toggleColumnVisibility,  // → ColumnSettingsPopover.onToggle
  resetSettings,           // → ColumnSettingsPopover.onReset
  hasHiddenColumns,        // → ColumnSettingsPopover.hasHiddenColumns
} = useColumnSettings({
  pageId: 'my-page-id',           // Zustand 저장 키 (고유값)
  columns: TABLE_COLUMNS,
  alwaysVisibleKeys: ['no', 'name', 'actions'],  // 숨기기 불가 컬럼
});
// 2⃣ 템플릿에 전달
<IntegratedListTemplateV2
  tableColumns={visibleColumns}     // ← TABLE_COLUMNS 아닌 visibleColumns!
  columnSettings={{
    columnWidths,
    onColumnResize: setColumnWidth,
    settingsPopover: (
      <ColumnSettingsPopover
        columns={allColumnsWithVisibility}
        onToggle={toggleColumnVisibility}
        onReset={resetSettings}
        hasHiddenColumns={hasHiddenColumns}
      />
    ),
  }}
  // ...
/>

헤더 레이아웃 순서

표준 레이아웃은 아래 순서를 따른다:

[아이콘] 페이지 제목
        설명 텍스트

[검색창] [날짜/연월 셀렉트] --- [액션버튼들] [+ 등록 버튼]

[탭: 목록 | 설정]      (tabsContent, 필요 시)

[통계카드 ...]          (stats)

[전체 N건 | N개 선택됨]  [부서 필터] [상태 필터] [컬럼 설정]  (tableHeaderActions)
[테이블]
[페이지네이션]

날짜 대신 연월 셀렉트가 필요한 경우:

dateRangeSelector={{
  enabled: true,
  hideDateInputs: true,   // 날짜 입력 숨김
  showPresets: false,      // 프리셋 버튼 숨김
  extraActions: (          // 대신 연월 셀렉트 배치
    <div className="flex items-center gap-2">
      <Select value={String(year)} onValueChange={...}>...</Select>
      <Select value={String(month)} onValueChange={...}>...</Select>
    </div>
  ),
}}

🔴 테이블 내 필터 — filterConfig 통합 방식 (필수)

테이블 카드 내부 필터는 반드시 filterConfig 통합 필터 시스템을 사용한다.

  • PC(xl 이상): 인라인 Select로 자동 렌더링
  • 모바일/태블릿(xl 미만): 바텀시트(MobileFilter)로 자동 분기

금지 패턴: tableHeaderActions에 직접 Select를 넣으면 모바일에서 필터가 보이지 않는다.

import {
  IntegratedListTemplateV2,
  type TableColumn,
  type FilterFieldConfig,
  type FilterValues,
} from '@/components/templates/IntegratedListTemplateV2';

// 1⃣ filterConfig 정의
const filterConfig: FilterFieldConfig[] = useMemo(() => [
  {
    key: 'department',
    label: '부서',
    type: 'single',
    options: departments.map(d => ({ value: d, label: d })),
    allOptionLabel: '전체 부서',
  },
  {
    key: 'status',
    label: '상태',
    type: 'single',
    options: [
      { value: 'draft', label: '작성중' },
      { value: 'confirmed', label: '확정' },
    ],
    allOptionLabel: '전체 상태',
  },
], [departments]);

// 2⃣ filterValues 상태 연결
const filterValues: FilterValues = useMemo(() => ({
  department: filterDepartment,
  status: filterStatus,
}), [filterDepartment, filterStatus]);

const handleFilterChange = useCallback((key: string, value: string | string[]) => {
  if (key === 'department') { setFilterDepartment(value as string); setCurrentPage(1); }
  if (key === 'status') { setFilterStatus(value as string); setCurrentPage(1); }
}, []);

const handleFilterReset = useCallback(() => {
  setFilterDepartment('all');
  setFilterStatus('all');
  setCurrentPage(1);
}, []);

// 3⃣ tableHeaderActions에는 필터 외 액션만 (엑셀 등)
const tableHeaderActions = useMemo(() => (
  <Button variant="outline" size="sm" onClick={handleExcelDownload}>
    <Download className="mr-1 h-4 w-4" />
    엑셀
  </Button>
), [handleExcelDownload]);

// 4⃣ 템플릿에 전달
<IntegratedListTemplateV2
  filterConfig={filterConfig}
  filterValues={filterValues}
  onFilterChange={handleFilterChange}
  onFilterReset={handleFilterReset}
  filterTitle="검색 필터"
  tableHeaderActions={tableHeaderActions}  // 엑셀 등 비필터 액션만
  // ...
/>
prop 역할 필수
filterConfig 필터 필드 정의 (key, label, type, options)
filterValues 현재 필터 상태
onFilterChange 필터 값 변경 핸들러
onFilterReset 필터 초기화 핸들러
filterTitle 모바일 바텀시트 타이틀 (기본: "검색 필터") 권장
tableHeaderActions 필터 외 액션 (엑셀 버튼 등) 필요 시

모바일 카드 (renderMobileCard)

import { MobileCard, InfoField } from '@/components/organisms/MobileCard';

const renderMobileCard = useCallback((
  item: MyItem,
  _index: number,
  _globalIndex: number,
  isSelected: boolean,
  onToggle: () => void,
) => (
  <MobileCard
    key={item.id}
    title={item.name}
    subtitle={item.department || '-'}
    headerBadges={[
      { text: STATUS_LABELS[item.status], variant: STATUS_VARIANTS[item.status] },
    ]}
    infoGrid={[
      <InfoField key="amount" label="금액" value={formatCurrency(item.amount)} />,
      <InfoField key="date" label="날짜" value={item.date} />,
    ]}
    isSelected={isSelected}
    onToggleSelection={onToggle}
    onClick={() => handleDetailOpen(item.id)}
  />
), []);

체크박스 선택 (Set<string>)

IntegratedListTemplateV2는 문자열 ID (Set<string>)를 요구한다:

// ✅ 올바른 패턴
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());

const toggleSelection = useCallback((id: string) => {
  setSelectedIds(prev => {
    const next = new Set(prev);
    next.has(id) ? next.delete(id) : next.add(id);
    return next;
  });
}, []);

const toggleSelectAll = useCallback(() => {
  setSelectedIds(prev =>
    prev.size === data.length
      ? new Set()
      : new Set(data.map(item => String(item.id)))
  );
}, [data]);

// 사용
<IntegratedListTemplateV2
  selectedItems={selectedIds}
  onToggleSelection={toggleSelection}
  onToggleSelectAll={toggleSelectAll}
  getItemId={(item) => String(item.id)}
/>

전체 스켈레톤 예제

'use client';

import { IntegratedListTemplateV2, type TableColumn } from '@/components/templates/IntegratedListTemplateV2';
import { useColumnSettings } from '@/hooks/useColumnSettings';
import { ColumnSettingsPopover } from '@/components/molecules/ColumnSettingsPopover';
import { MobileCard, InfoField } from '@/components/organisms/MobileCard';
import { TableRow, TableCell } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
import { MyIcon } from 'lucide-react';

const TABLE_COLUMNS: TableColumn[] = [
  { key: 'no', label: '번호', className: 'text-center w-[60px]' },
  { key: 'name', label: '이름', copyable: true },
  { key: 'status', label: '상태', className: 'text-center w-[80px]' },
  { key: 'actions', label: '작업', className: 'text-center w-[80px]' },
];

export function MyListPage() {
  // 컬럼 설정 (필수)
  const {
    visibleColumns, allColumnsWithVisibility, columnWidths,
    setColumnWidth, toggleColumnVisibility, resetSettings, hasHiddenColumns,
  } = useColumnSettings({
    pageId: 'my-page',
    columns: TABLE_COLUMNS,
    alwaysVisibleKeys: ['no', 'name', 'actions'],
  });

  // 선택 (Set<string>)
  const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
  // ... toggleSelection, toggleSelectAll 구현

  return (
    <IntegratedListTemplateV2<MyItem>
      // 헤더
      title="페이지 제목"
      description="설명"
      icon={MyIcon}

      // 헤더 액션
      headerActions={<Button>액션</Button>}
      createButton={{ label: '등록', onClick: handleCreate }}

      // 검색
      searchValue={search}
      onSearchChange={setSearch}
      searchPlaceholder="검색..."

      // 통계
      stats={[{ label: '전체', value: totalCount, icon: Users, iconColor: 'text-blue-600' }]}

      // 테이블 필터
      tableHeaderActions={filterNode}

      // 테이블 + 컬럼 설정
      tableColumns={visibleColumns}
      columnSettings={{
        columnWidths,
        onColumnResize: setColumnWidth,
        settingsPopover: (
          <ColumnSettingsPopover
            columns={allColumnsWithVisibility}
            onToggle={toggleColumnVisibility}
            onReset={resetSettings}
            hasHiddenColumns={hasHiddenColumns}
          />
        ),
      }}

      // 데이터
      data={items}
      selectedItems={selectedIds}
      onToggleSelection={toggleSelection}
      onToggleSelectAll={toggleSelectAll}
      getItemId={(item) => String(item.id)}

      // 렌더링
      renderTableRow={renderTableRow}
      renderMobileCard={renderMobileCard}

      // 페이지네이션
      pagination={{
        currentPage, totalPages, totalItems: totalCount,
        itemsPerPage: PAGE_SIZE, onPageChange: setCurrentPage,
      }}

      // 로딩
      isLoading={isLoading}
    />
  );
}

5. 상세/폼 페이지

표준 구조

'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가지
  • 라우팅: ?mode=new / ?mode=edit 쿼리파라미터 사용 (별도 /new, /edit 경로 금지)
  • page.tsx 분기: 목록 page.tsx에서 searchParams.get('mode') 로 등록 폼 분기
  • Hook 규칙: 모든 hook은 최상단, 조건부 return은 그 아래
  • 레이아웃: Card > CardHeader + CardContent 섹션 단위
  • 필드 그리드: grid grid-cols-1 md:grid-cols-2 gap-4
  • disabled: view 모드에서 모든 입력 비활성화
  • 알림: toast.success() / toast.error() (sonner)
  • 로딩: Skeleton 컴포넌트 사용
  • Select 버그 대응: <Select key={...}> 패턴 (CLAUDE.md 참조)

헤더 배치 표준

위치 요소
상단 좌측 페이지 제목 (<h1>)
상단 우측 ← 목록으로 (Button variant="link")

하단 Sticky 액션 바

Card 내부가 아닌 sticky bottom bar로 버튼 배치. 취소 좌측, 주요 액션 우측.

모드 좌측 우측
등록 (new) X 취소 💾 저장
상세 (view) X 취소 (목록으로) ✏️ 수정
수정 (edit) X 취소 💾 저장
  • 아이콘 포함: 취소(X), 저장(Save), 수정(Pencil)
  • 상세(view) "취소"는 목록 이동, "수정"은 ?mode=edit 전환

6. API 연동 패턴

Server Action 파일 구조

src/components/[domain]/[feature]/
├── index.tsx        — 메인 컴포넌트 (또는 리스트)
├── [Feature]Detail.tsx  — 상세/폼
├── actions.ts       — Server Actions (API 호출)
└── types.ts         — 타입 정의

Server Action 패턴

'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 };
}

클라이언트에서 호출

// 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 [];
}, []);

7. 페이지 라우팅 구조

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

// 리스트
'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 패턴