203 lines
5.7 KiB
Markdown
203 lines
5.7 KiB
Markdown
# 11. 페이지 패턴 가이드
|
|
|
|
> 대상: 프론트엔드 개발자
|
|
> 최종 업데이트: 2026-03-20
|
|
|
|
---
|
|
|
|
## 목차
|
|
|
|
| 번호 | 항목 |
|
|
|------|------|
|
|
| 11.1 | [리스트 페이지](#111-리스트-페이지) |
|
|
| 11.2 | [상세/폼 페이지](#112-상세폼-페이지) |
|
|
| 11.3 | [검색 모달](#113-검색-모달) |
|
|
| 11.4 | [공통 기능은 공통 컴포넌트에서](#114-공통-기능은-공통-컴포넌트에서) |
|
|
|
|
---
|
|
|
|
## 11.1 리스트 페이지
|
|
|
|
### UniversalListPage (권장)
|
|
|
|
대부분의 리스트 페이지는 `UniversalListPage`로 구성합니다.
|
|
|
|
```typescript
|
|
const config: UniversalListConfig<MyItem> = {
|
|
// 검색 (클라이언트 사이드 필터링)
|
|
clientSideFiltering: true,
|
|
searchFilter: (item, searchValue) => {
|
|
const q = searchValue.toLowerCase();
|
|
return item.name.toLowerCase().includes(q) || item.code.toLowerCase().includes(q);
|
|
},
|
|
searchPlaceholder: '이름, 코드 검색...',
|
|
|
|
// 컬럼 정의
|
|
columns: [...],
|
|
|
|
// 모바일 카드
|
|
renderMobileCard: (item) => <MobileCard item={item} />,
|
|
};
|
|
```
|
|
|
|
### IntegratedListTemplateV2 필수 적용 항목
|
|
|
|
IntegratedListTemplateV2 사용 시 다음 10가지를 반드시 확인:
|
|
|
|
| # | 항목 | 필수 |
|
|
|---|------|------|
|
|
| 1 | `useColumnSettings` + `ColumnSettingsPopover` | O |
|
|
| 2 | `searchValue` + `onSearchChange` (or clientSideFiltering) | O |
|
|
| 3 | 체크박스 `Set<string>` 패턴 | O |
|
|
| 4 | 페이지네이션 | O |
|
|
| 5 | `renderMobileCard` | O |
|
|
| 6 | 테이블 행 클릭 -> 상세 이동 | O |
|
|
| 7 | 헤더 레이아웃 (StatCards, 필터) | O |
|
|
| 8 | `tableHeaderActions` (테이블 내 필터) | 선택 |
|
|
| 9 | `filterConfig` (필터 설정) | 선택 |
|
|
| 10 | 탭 구성 | 선택 |
|
|
|
|
### 검색 패턴 (필수)
|
|
|
|
```typescript
|
|
// ✅ 올바른 패턴 -- UniversalListPage + clientSideFiltering
|
|
const config: UniversalListConfig<MyItem> = {
|
|
clientSideFiltering: true,
|
|
searchFilter: (item, searchValue) => {
|
|
const q = searchValue.toLowerCase();
|
|
return item.name.toLowerCase().includes(q);
|
|
},
|
|
};
|
|
// 데이터를 한 번 API로 로드 -> 검색은 메모리에서 즉시 필터링 -> 깜빡임 없음
|
|
```
|
|
|
|
```typescript
|
|
// ❌ 금지 패턴 -- IntegratedListTemplateV2에 onSearchChange 직접 연결
|
|
<IntegratedListTemplateV2
|
|
searchValue={searchTerm}
|
|
onSearchChange={(q) => { setSearchTerm(q); }}
|
|
/>
|
|
// 키입력마다 state 변경 -> re-render -> 화면 깜빡임, 한글 조합 불가
|
|
```
|
|
|
|
---
|
|
|
|
## 11.2 상세/폼 페이지
|
|
|
|
### 라우팅 규칙
|
|
|
|
- 별도 `/new` 경로 금지 -> `?mode=new` 쿼리파라미터 사용
|
|
- 별도 `/edit` 경로 금지 -> `?mode=edit` 쿼리파라미터 사용
|
|
|
|
```typescript
|
|
// ✅ 올바른 패턴
|
|
router.push('/some-page?mode=new');
|
|
router.push('/some-page/123?mode=edit');
|
|
|
|
// ❌ 금지 패턴
|
|
router.push('/some-page/new');
|
|
router.push('/some-page/123/edit');
|
|
```
|
|
|
|
### 페이지 구조
|
|
|
|
```typescript
|
|
export default function SomePage() {
|
|
const searchParams = useSearchParams();
|
|
const mode = searchParams.get('mode');
|
|
|
|
if (mode === 'new') return <SomeForm />;
|
|
return <SomeList />;
|
|
}
|
|
```
|
|
|
|
### 헤더 표준
|
|
|
|
| 위치 | 요소 |
|
|
|------|------|
|
|
| 상단 좌측 | 페이지 제목 (`<h1>`) |
|
|
| 상단 우측 | `<- 목록으로` 링크 |
|
|
|
|
### 하단 Sticky 액션 바 (필수)
|
|
|
|
```typescript
|
|
<div className="sticky bottom-0 bg-white border-t shadow-sm">
|
|
<div className="px-3 py-3 md:px-6 md:py-4 flex items-center justify-between">
|
|
<Button variant="outline" onClick={() => router.push(listPath)}>
|
|
<X className="h-4 w-4 mr-1" /> 취소
|
|
</Button>
|
|
<Button onClick={handleSubmit} disabled={isSubmitting}>
|
|
<Save className="h-4 w-4 mr-1" /> 저장
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
```
|
|
|
|
| 모드 | 좌측 | 우측 |
|
|
|------|------|------|
|
|
| 등록 (new) | 취소 | 저장 |
|
|
| 상세 (view) | 취소 (목록으로) | 수정 |
|
|
| 수정 (edit) | 취소 | 저장 |
|
|
|
|
### 폼 레이아웃
|
|
|
|
- Card 내부에 버튼 넣지 않음 -> sticky 하단 바 사용
|
|
- 아이콘 포함: 취소(`X`), 저장(`Save`), 수정(`Pencil`)
|
|
|
|
---
|
|
|
|
## 11.3 검색 모달
|
|
|
|
### SearchableSelectionModal (필수)
|
|
|
|
검색+선택 기능이 필요한 모달은 반드시 `SearchableSelectionModal<T>`을 사용합니다.
|
|
|
|
```typescript
|
|
// ✅ 올바른 패턴
|
|
<SearchableSelectionModal<VendorItem>
|
|
open={open}
|
|
onOpenChange={setOpen}
|
|
title="거래처 선택"
|
|
fetchData={async () => {
|
|
const result = await getVendors();
|
|
return result.success ? result.data : [];
|
|
}}
|
|
columns={[
|
|
{ key: 'vendorName', label: '거래처명' },
|
|
{ key: 'businessNumber', label: '사업자번호' },
|
|
]}
|
|
onSelect={(vendor) => {
|
|
setSelectedVendor(vendor);
|
|
}}
|
|
searchFilter={(item, query) =>
|
|
item.vendorName.includes(query) || item.businessNumber?.includes(query)
|
|
}
|
|
/>
|
|
```
|
|
|
|
```typescript
|
|
// ❌ 금지 패턴 -- Dialog + Table 직접 조합
|
|
<Dialog>
|
|
<Input onChange={...} />
|
|
<Table>...</Table>
|
|
</Dialog>
|
|
```
|
|
|
|
---
|
|
|
|
## 11.4 공통 기능은 공통 컴포넌트에서
|
|
|
|
리스트 페이지 전체에 적용해야 하는 기능은 개별 페이지 수정 금지. 반드시 공통 레이어에서 처리.
|
|
|
|
| 기능 | 수정 위치 | 개별 페이지 수정 |
|
|
|------|----------|----------------|
|
|
| 검색 상태 보존 | `UniversalListPage` | 금지 |
|
|
| 검색 X(클리어) 버튼 | `SearchFilter` + `IntegratedListTemplateV2` | 금지 |
|
|
| 검색 디바운스 | `UniversalListPage` 내부 300ms | 금지 |
|
|
| 체크박스 선택 | `IntegratedListTemplateV2` | 금지 |
|
|
| 페이지네이션 | `IntegratedListTemplateV2` | 금지 |
|
|
| 모바일 카드/인피니티 | `IntegratedListTemplateV2` | 금지 |
|
|
| 컬럼 설정 | `useColumnSettings` + `ColumnSettingsPopover` | 금지 |
|
|
|
|
**원칙**: "26개 페이지에 하나씩 적용" -> 잘못된 접근. "공통 1곳 수정 -> 전체 자동 적용" -> 올바른 접근.
|