- 법인차량 관리 3개 페이지 (차량등록, 운행일지, 정비이력) - MES 데이터 정합성 분석 보고서 v1/v2 - sam-docs 프론트엔드 기술문서 v1 (9개 챕터) - claudedocs 가이드/테스트URL 업데이트
28 KiB
28 KiB
SAM 프로젝트 공통 페이지/컴포넌트 패턴 가이드
신규 페이지·모달 작업 시 이 문서를 참고하여 기존 구조와 일관성을 유지한다.
목차
- 공통 컴포넌트 맵
- 검색 모달 (SearchableSelectionModal)
- 리스트 페이지
- IntegratedListTemplateV2 표준 적용
- 상세/폼 페이지
- API 연동 패턴
- 페이지 라우팅 구조
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|new3가지 - 라우팅:
?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 패턴 |