feat: [vehicle] 법인차량 관리 모듈 + MES 분석 보고서 + 프론트엔드 문서

- 법인차량 관리 3개 페이지 (차량등록, 운행일지, 정비이력)
- MES 데이터 정합성 분석 보고서 v1/v2
- sam-docs 프론트엔드 기술문서 v1 (9개 챕터)
- claudedocs 가이드/테스트URL 업데이트
This commit is contained in:
유병철
2026-03-13 17:52:57 +09:00
parent 80164f722e
commit c309ac479f
27 changed files with 6383 additions and 31 deletions

View File

@@ -0,0 +1,90 @@
# SAM ERP 프론트엔드 개발 가이드
> **대상**: SAM ERP 프론트엔드 신규/기존 개발자
> **최종 업데이트**: 2026-03-13
---
## 목차
| 문서 | 내용 |
|------|------|
| [00-overview.md](./00-overview.md) | 프로젝트 개요 및 기술 스택 (이 문서) |
| [01-project-structure.md](./01-project-structure.md) | 디렉토리 구조 및 파일 배치 규칙 |
| [02-routing-and-pages.md](./02-routing-and-pages.md) | 라우팅, 페이지 모드, 레이아웃 |
| [03-authentication.md](./03-authentication.md) | 인증 흐름, HttpOnly 쿠키, API 프록시 |
| [04-server-actions.md](./04-server-actions.md) | Server Action 패턴, API 통신 유틸리티 |
| [05-common-components.md](./05-common-components.md) | 공통 컴포넌트 (organisms, molecules, templates) |
| [06-ui-components.md](./06-ui-components.md) | UI 컴포넌트 카탈로그 |
| [07-hooks.md](./07-hooks.md) | 공통 Hooks |
| [08-utilities.md](./08-utilities.md) | 유틸리티 함수 (포맷터, URL 빌더, 인쇄 등) |
| [09-coding-conventions.md](./09-coding-conventions.md) | 코딩 컨벤션 및 필수 규칙 |
---
## 프로젝트 개요
```yaml
프로젝트: SAM ERP (통합 자원관리 시스템)
프론트엔드: Next.js 15 (App Router, TypeScript)
백엔드 API: PHP Laravel (sam-api)
특성: 인증 필수 폐쇄형 ERP (SEO 불필요, 크롤링 차단)
```
### 저장소 구조
```
sam_project/
├── sam-next/sma-next-project/sam-react-prod/ # Next.js 프론트엔드 (현재)
├── sam-api/sam-api/ # PHP Laravel 백엔드 API
├── sam-design/sam-design/ # React 디자인 시스템
└── sam-hotfix/sam-hotfix/ # E2E 테스트/핫픽스 관리
```
### 기술 스택
| 카테고리 | 기술 |
|----------|------|
| **프레임워크** | Next.js 15 (App Router, Turbopack) |
| **언어** | TypeScript (strict) |
| **스타일링** | Tailwind CSS |
| **UI 라이브러리** | Radix UI (shadcn/ui 기반) |
| **상태 관리** | Zustand |
| **폼 관리** | react-hook-form + Zod (신규 폼) |
| **차트** | Recharts |
| **국제화** | next-intl (ko, en, ja) |
| **토스트** | Sonner |
| **아이콘** | Lucide React |
| **날짜** | date-fns (한국어 locale) |
| **인증** | HttpOnly Cookie + API Proxy |
| **모바일** | Capacitor (하이브리드 앱) |
### 핵심 원칙
1. **모든 페이지는 Client Component** (`'use client'`) - 폐쇄형 ERP이므로 SEO 불필요, 서버 컴포넌트에서 쿠키 갱신 불가
2. **HttpOnly 쿠키 인증** - JavaScript에서 토큰 접근 불가, API Proxy 필수
3. **mode 쿼리파라미터** - `/new`, `/edit` 별도 경로 대신 `?mode=new`, `?mode=edit` 사용
4. **buildApiUrl 필수** - URL 직접 조립 금지
5. **컴포넌트 재사용** - 새 컴포넌트 전 기존 컴포넌트 검색 필수
### 개발 환경
```bash
# 로컬 개발 서버
npm run dev
# 타입 체크
npx tsc --noEmit
# 빌드 (로컬 확인용)
npm run build
```
### 브랜치 전략
| 브랜치 | 역할 |
|--------|------|
| `develop` | 평소 작업 브랜치 (자유롭게 커밋) |
| `stage` | QA/테스트 환경 |
| `main` | 배포용 (기능별 squash merge) |
| `feature/*` | 큰 기능/실험적 작업 |

View File

@@ -0,0 +1,112 @@
# 프로젝트 구조 및 파일 배치 규칙
## 디렉토리 구조
```
src/
├── app/ # Next.js App Router
│ ├── [locale]/ # i18n (ko, en, ja)
│ │ ├── (auth)/ # 인증 페이지 (로그인 등)
│ │ │ └── login/
│ │ ├── (protected)/ # 보호된 라우트 (인증 필수)
│ │ │ ├── accounting/ # 회계
│ │ │ ├── approval/ # 전자결재
│ │ │ ├── board/ # 게시판
│ │ │ ├── construction/ # 시공
│ │ │ ├── customer-center/ # 고객센터
│ │ │ ├── dashboard/ # 대시보드
│ │ │ ├── hr/ # 인사
│ │ │ ├── master-data/ # 기준정보
│ │ │ ├── material/ # 자재
│ │ │ ├── outbound/ # 출고
│ │ │ ├── production/ # 생산
│ │ │ ├── quality/ # 품질
│ │ │ ├── reports/ # 리포트
│ │ │ ├── sales/ # 영업
│ │ │ ├── settings/ # 설정
│ │ │ ├── [...slug]/ # catch-all (미구현 메뉴)
│ │ │ └── layout.tsx # 보호 레이아웃
│ │ ├── layout.tsx # 루트 레이아웃 (i18n)
│ │ └── page.tsx # / → /dashboard 리다이렉트
│ └── api/ # API Routes
│ ├── proxy/[...path]/ # HttpOnly 쿠키 프록시
│ ├── auth/ # 인증 엔드포인트
│ └── pdf/generate/ # PDF 생성
├── components/
│ ├── ui/ # 기본 UI 컴포넌트 (shadcn/ui 기반)
│ ├── molecules/ # 조합 컴포넌트 (FormField, StatusBadge 등)
│ ├── organisms/ # 페이지 구성 블록 (PageHeader, DataTable 등)
│ ├── templates/ # 페이지 템플릿 (IntegratedListTemplateV2 등)
│ ├── layout/ # 레이아웃 컴포넌트 (Sidebar, Header 등)
│ └── {domain}/ # 도메인별 컴포넌트
│ ├── accounting/
│ ├── hr/
│ ├── production/
│ ├── quality/
│ └── ...
├── hooks/ # 공통 Hooks
├── layouts/ # AuthenticatedLayout
├── lib/ # 유틸리티
│ ├── api/ # API 통신 유틸리티
│ ├── auth/ # 인증 유틸리티
│ ├── formatters.ts # 포맷팅 함수
│ ├── print-utils.ts # 인쇄 유틸리티
│ └── utils.ts # 기본 유틸리티 (cn 등)
├── stores/ # Zustand 스토어
├── i18n/ # 국제화 설정
├── types/ # 공통 타입 정의
└── styles/ # 글로벌 스타일
```
## 컴포넌트 계층
```
ui/ → 원자 컴포넌트 (Button, Input, Select ...)
molecules/ → 조합 컴포넌트 (FormField = Label + Input + Error)
organisms/ → 페이지 빌딩 블록 (PageHeader, DataTable, SearchFilter ...)
templates/ → 페이지 전체 템플릿 (IntegratedListTemplateV2)
{domain}/ → 도메인 전용 컴포넌트 (AccountingForm, QualityReport ...)
```
## 파일 배치 규칙
### 도메인 컴포넌트
```
src/components/{domain}/
├── {Feature}List.tsx # 목록 컴포넌트
├── {Feature}Detail.tsx # 상세/수정/등록 컴포넌트
├── {Feature}Modal.tsx # 모달 컴포넌트
└── actions.ts # Server Actions
```
### 페이지 파일
```
src/app/[locale]/(protected)/{domain}/{feature}/
├── page.tsx # 목록 + mode=new 분기
├── [id]/
│ └── page.tsx # 상세 + mode=edit 분기
└── actions.ts # (또는 components/{domain}/actions.ts)
```
### Server Actions 위치
- **우선**: `src/components/{domain}/actions.ts` (도메인별)
- **대안**: `src/app/[locale]/(protected)/{domain}/{feature}/actions.ts` (페이지별)
## 파일 네이밍
| 유형 | 네이밍 | 예시 |
|------|--------|------|
| 컴포넌트 | PascalCase | `VendorDetail.tsx` |
| 페이지 | `page.tsx` (Next.js 규칙) | `page.tsx` |
| Server Action | `actions.ts` | `actions.ts` |
| 유틸리티 | kebab-case | `query-params.ts` |
| Hook | camelCase + `use` 접두사 | `useColumnSettings.ts` |
| 타입 | `types.ts` 또는 인라인 | `types.ts` |
| 스키마 | `schema.ts` | `schema.ts` |
## 신규 파일 생성 전 체크리스트
- [ ] 유사 컴포넌트가 이미 있는지 `organisms/`, `molecules/` 확인
- [ ] 같은 도메인에 재사용 가능한 컴포넌트 확인
- [ ] dev/component-registry 페이지에서 검색
- [ ] 공통 UI 컴포넌트(`ui/`)로 해결 가능한지 확인

View File

@@ -0,0 +1,159 @@
# 라우팅 및 페이지 패턴
## 라우팅 구조
```
/[locale]/(auth)/login # 로그인
/[locale]/(protected)/dashboard # 대시보드
/[locale]/(protected)/{domain}/{feature} # 목록
/[locale]/(protected)/{domain}/{feature}?mode=new # 등록
/[locale]/(protected)/{domain}/{feature}/[id] # 상세(view)
/[locale]/(protected)/{domain}/{feature}/[id]?mode=edit # 수정
```
## 레이아웃 계층
```
Root Layout ([locale]/layout.tsx) - Server Component
├── i18n 설정 (NextIntlClientProvider)
├── 폰트 로드 (PretendardVariable)
├── Toaster (sonner)
└── Protected Layout ((protected)/layout.tsx) - Client Component
├── useAuthGuard() - 인증 보호
├── RootProvider - 전역 상태
├── ApiErrorProvider - 401 에러 처리
├── FCMProvider - 푸시 알림
├── PermissionGate - 권한 제어
└── AuthenticatedLayout
├── Sidebar - 메뉴
├── Header - 회사선택, 검색, 알림
├── HeaderFavoritesBar - 즐겨찾기
└── {children} - 페이지 컨텐츠
```
## 페이지 모드 패턴 (mode 쿼리파라미터)
### 규칙
- **별도 `/new`, `/edit` 경로 금지** → `?mode=new`, `?mode=edit` 사용
- 목록과 등록을 **같은 page.tsx에서 분기**
### 목록 + 등록 (page.tsx)
```tsx
'use client';
import { useSearchParams } from 'next/navigation';
export default function ItemsPage() {
const searchParams = useSearchParams();
const mode = searchParams.get('mode');
// mode=new → 등록 폼
if (mode === 'new') {
return <ItemDetail mode="new" />;
}
// 기본 → 목록
return <ItemList />;
}
```
### 상세 + 수정 ([id]/page.tsx)
```tsx
'use client';
import { useParams, useSearchParams } from 'next/navigation';
export default function ItemDetailPage() {
const params = useParams();
const searchParams = useSearchParams();
const id = params.id as string;
const mode = searchParams.get('mode') === 'edit' ? 'edit' : 'view';
return <ItemDetail id={id} mode={mode} />;
}
```
### 네비게이션
```tsx
// 목록 → 등록
router.push('/master-data/items?mode=new');
// 목록 → 상세
router.push(`/master-data/items/${id}`);
// 상세 → 수정
router.push(`/master-data/items/${id}?mode=edit`);
// 수정 → 상세 (저장 후)
router.push(`/master-data/items/${id}`);
// → 목록으로
router.push('/master-data/items');
```
## 페이지 레이아웃 표준
### PageLayout 패딩 규칙
- `AuthenticatedLayout``<main>`에는 패딩 없음
- `PageLayout` 컴포넌트가 `p-3 md:p-6` 패딩 담당
- **page.tsx에서 패딩 wrapper 추가 금지** (이중 패딩 방지)
### 등록/수정/상세 페이지 헤더
```tsx
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold">페이지 제목</h1>
<Button variant="link" className="text-muted-foreground"
onClick={() => router.push(listPath)}>
목록으로
</Button>
</div>
```
### 하단 Sticky 액션 바 (필수)
폼 페이지 하단에 sticky bar로 버튼 배치:
| 모드 | 좌측 | 우측 |
|------|------|------|
| 등록 (new) | `X 취소` | `💾 저장` |
| 상세 (view) | `X 취소` (목록으로) | `✏️ 수정` |
| 수정 (edit) | `X 취소` | `💾 저장` |
```tsx
<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}>
{isSubmitting
? <Loader2 className="h-4 w-4 mr-1 animate-spin" />
: <Save className="h-4 w-4 mr-1" />}
저장
</Button>
</div>
</div>
```
## 테이블 표준
### 필수 컬럼 구조
**체크박스** → **번호(1부터)****데이터 컬럼****작업 컬럼**
```tsx
// 번호 계산 (페이지네이션 고려)
const globalIndex = (currentPage - 1) * pageSize + index + 1;
```
### 작업 버튼
- 체크박스 선택 시에만 표시
## i18n
```
지원 언어: ko (기본), en, ja
경로: /ko/..., /en/..., /ja/...
```

View File

@@ -0,0 +1,137 @@
# 인증 및 API 통신
## 인증 아키텍처
SAM ERP는 **HttpOnly Cookie 기반 인증**을 사용합니다. JavaScript에서 토큰을 직접 접근할 수 없으므로, 모든 인증 API 호출은 **Next.js API Proxy**를 통해 처리됩니다.
```
클라이언트 (브라우저)
├── Server Action 호출 (useEffect에서)
│ └── serverFetch() → 서버에서 쿠키 읽기 → 백엔드 API 호출
└── 프록시 API 호출 (fetch('/api/proxy/...'))
└── API Proxy Route → 서버에서 쿠키 읽기 → 백엔드 API 호출
```
## 쿠키 구조
| 쿠키명 | HttpOnly | 용도 | Max-Age |
|--------|:--------:|------|---------|
| `access_token` | O | API 인증 토큰 | 2시간 |
| `refresh_token` | O | 토큰 갱신용 | 7일 |
| `is_authenticated` | X | 클라이언트 인증 상태 확인 | 2시간 |
- `access_token`, `refresh_token`: HttpOnly → JavaScript 접근 불가 (XSS 방지)
- `is_authenticated`: non-HttpOnly → 클라이언트에서 인증 상태 확인 가능 (FCM 등)
- 프로덕션: `Secure` 플래그 활성화 (HTTPS만)
- `SameSite=Lax`: CSRF 방지
## 인증 흐름
### 로그인
```
1. 사용자 → /api/auth/login (POST)
2. 백엔드 → access_token + refresh_token 반환
3. API Route → Set-Cookie (HttpOnly) 설정
4. 클라이언트 → /(protected)/dashboard 리다이렉트
```
### API 요청 (Server Action)
```
1. 클라이언트 → Server Action 호출
2. serverFetch() → 쿠키에서 access_token 읽기
3. authenticatedFetch() → Authorization 헤더에 토큰 추가
4. 백엔드 API 호출 → 응답 반환
```
### 토큰 만료 시 (401 자동 갱신)
```
1. API 요청 → 401 응답 수신
2. authenticatedFetch() → refresh_token으로 갱신 요청
3. 새 토큰 수신 → 쿠키 업데이트
4. 원래 요청 재시도 → 성공
5. 갱신 실패 → 쿠키 삭제 → /login 리다이렉트
```
### 토큰 갱신 중복 방지
```typescript
// globalThis 레벨 캐싱 (5초)
// 여러 요청이 동시에 401을 받아도 refresh는 1회만 실행
// 진행 중인 refresh Promise를 공유하여 대기
```
## API 프록시 (`/api/proxy/[...path]`)
클라이언트에서 직접 백엔드 API를 호출해야 하는 경우 프록시 사용:
```typescript
// 클라이언트에서 프록시 호출
const response = await fetch('/api/proxy/item-master/init');
const data = await response.json();
```
프록시 내부 동작:
1. HttpOnly 쿠키에서 `access_token` 읽기
2. 백엔드 URL 구성 (`/api/proxy/*` → 백엔드 `/*`)
3. `Authorization: Bearer {token}` 헤더 추가
4. 요청 전달 → 응답 반환
5. 401 시 자동 토큰 갱신 후 재시도
6. 새 토큰 → Set-Cookie 헤더로 클라이언트에 전달
## 인증 보호
### Protected Layout
```tsx
// (protected)/layout.tsx
export default function ProtectedLayout({ children }) {
// 인증 가드 (뒤로가기 캐시 감지)
useAuthGuard();
return (
<RootProvider>
<ApiErrorProvider> {/* 401 에러 자동 처리 */}
<FCMProvider> {/* 푸시 알림 */}
<AuthenticatedLayout>
<PermissionGate> {/* 권한 기반 접근 제어 */}
{children}
</PermissionGate>
</AuthenticatedLayout>
</FCMProvider>
</ApiErrorProvider>
</RootProvider>
);
}
```
### 인증 상태 확인 (클라이언트)
```typescript
import { hasAuthToken } from '@/lib/api/auth-headers';
// is_authenticated 쿠키 확인 (non-HttpOnly)
if (hasAuthToken()) {
// 인증됨
}
```
## 로그아웃
완전한 로그아웃 절차:
1. Zustand 스토어 초기화 (useAuthStore, useMasterDataStore, useItemMasterStore)
2. sessionStorage 캐시 삭제 (page_config_*, mes-*)
3. localStorage 사용자 데이터 삭제
4. FCM 토큰 해제 (Capacitor 환경)
5. 서버 로그아웃 API 호출
6. /login 리다이렉트
## 주의사항
- **Server Component에서 쿠키 수정 불가** → Client Component 사용 필수
- **`alert()`, `confirm()`, `prompt()` 사용 금지** → Radix UI Dialog 또는 `toast` 사용
- **API 직접 호출 금지** → 반드시 Server Action 또는 프록시 사용

View File

@@ -0,0 +1,245 @@
# Server Action 패턴
## 개요
모든 백엔드 API 호출은 Server Action을 통해 처리합니다. 공통 유틸리티를 사용하여 보일러플레이트를 제거하고 일관된 패턴을 유지합니다.
## 핵심 유틸리티
### buildApiUrl - URL 빌더 (필수)
```typescript
import { buildApiUrl } from '@/lib/api/query-params';
// 기본 사용
buildApiUrl('/api/v1/items')
// → "https://api.example.com/api/v1/items"
// 쿼리 파라미터
buildApiUrl('/api/v1/items', {
search: 'test',
status: 'active',
page: 1,
})
// → "https://api.example.com/api/v1/items?search=test&status=active&page=1"
// undefined/null/'' 자동 필터링
buildApiUrl('/api/v1/items', {
search: '', // 제외됨
status: undefined, // 제외됨
page: 1,
})
// → "https://api.example.com/api/v1/items?page=1"
// 동적 경로 + 파라미터
buildApiUrl(`/api/v1/items/${id}`, { with_details: true })
```
> **금지**: `new URLSearchParams()` 직접 사용, `${API_URL}` 직접 조립
### executeServerAction - 단건/목록 조회
```typescript
import { executeServerAction } from '@/lib/api/execute-server-action';
const result = await executeServerAction<ApiType, FrontendType>({
url: buildApiUrl('/api/v1/items', { search: params.search }),
method: 'GET', // 기본값: GET
transform: (data) => ..., // snake_case → camelCase 변환
errorMessage: '조회에 실패했습니다.',
});
// 반환 타입
interface ActionResult<T> {
success: boolean;
data?: T;
error?: string;
fieldErrors?: Record<string, string[]>; // Laravel validation errors
__authError?: boolean; // 401 감지
}
```
### executePaginatedAction - 페이지네이션 조회
```typescript
import { executePaginatedAction } from '@/lib/api/execute-paginated-action';
const result = await executePaginatedAction<ApiType, FrontendType>({
url: buildApiUrl('/api/v1/items', {
search: params.search,
status: params.status !== 'all' ? params.status : undefined,
page: params.page,
}),
transform: transformApiToFrontend, // 개별 아이템 변환 함수
errorMessage: '목록 조회에 실패했습니다.',
});
// 반환 타입
interface PaginatedActionResult<T> {
success: boolean;
data: T[]; // 변환된 아이템 배열
pagination: PaginationMeta; // 페이지네이션 정보
error?: string;
__authError?: boolean;
}
interface PaginationMeta {
currentPage: number;
lastPage: number;
perPage: number;
total: number;
}
```
## Server Action 작성 패턴
### 표준 예시
```typescript
// src/components/{domain}/actions.ts
'use server';
import { executeServerAction } from '@/lib/api/execute-server-action';
import { executePaginatedAction } from '@/lib/api/execute-paginated-action';
import { buildApiUrl } from '@/lib/api/query-params';
// ===== 1. API 원본 타입 (snake_case) =====
interface ItemApi {
id: number;
item_name: string;
item_code: string;
created_at: string;
}
// ===== 2. 프론트엔드 타입 (camelCase) =====
export interface Item {
id: string;
itemName: string;
itemCode: string;
createdAt: string;
}
// ===== 3. Transform 함수 =====
function transformItem(api: ItemApi): Item {
return {
id: String(api.id),
itemName: api.item_name,
itemCode: api.item_code,
createdAt: api.created_at,
};
}
// ===== 4. 목록 조회 (페이지네이션) =====
export async function getItems(params: {
search?: string;
status?: string;
page?: number;
}) {
return executePaginatedAction({
url: buildApiUrl('/api/v1/items', {
search: params.search,
status: params.status !== 'all' ? params.status : undefined,
page: params.page,
}),
transform: transformItem,
errorMessage: '품목 목록 조회에 실패했습니다.',
});
}
// ===== 5. 단건 조회 =====
export async function getItem(id: string) {
return executeServerAction({
url: buildApiUrl(`/api/v1/items/${id}`),
transform: (data: { item: ItemApi }) => transformItem(data.item),
errorMessage: '품목 조회에 실패했습니다.',
});
}
// ===== 6. 생성 =====
export async function createItem(formData: Partial<Item>) {
return executeServerAction({
url: buildApiUrl('/api/v1/items'),
method: 'POST',
body: {
item_name: formData.itemName,
item_code: formData.itemCode,
},
errorMessage: '품목 등록에 실패했습니다.',
});
}
// ===== 7. 수정 =====
export async function updateItem(id: string, formData: Partial<Item>) {
return executeServerAction({
url: buildApiUrl(`/api/v1/items/${id}`),
method: 'PUT',
body: {
item_name: formData.itemName,
item_code: formData.itemCode,
},
errorMessage: '품목 수정에 실패했습니다.',
});
}
// ===== 8. 삭제 =====
export async function deleteItems(ids: string[]) {
return executeServerAction({
url: buildApiUrl('/api/v1/items/bulk-delete'),
method: 'POST',
body: { ids: ids.map(Number) },
errorMessage: '품목 삭제에 실패했습니다.',
});
}
```
## 컴포넌트에서 Server Action 호출
```tsx
'use client';
import { useEffect, useState } from 'react';
import { getItems, type Item } from '@/components/{domain}/actions';
export default function ItemList() {
const [data, setData] = useState<Item[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
getItems({ page: 1 })
.then(result => {
if (result.success) {
setData(result.data);
}
})
.finally(() => setIsLoading(false));
}, []);
if (isLoading) return <div>로딩 ...</div>;
return <>{/* 렌더링 */}</>;
}
```
## 주의사항
### 'use server' 파일에서 타입 re-export 금지
```typescript
// ❌ 금지 - Next.js Turbopack 제한 (async 함수만 export 허용)
export type { Item } from './types';
export { type Item } from './types';
// ✅ 허용 - 인라인 타입 정의
export interface Item { ... }
export type Item = { ... };
// ✅ 허용 - 컴포넌트에서 원본 타입 파일 직접 import
// 컴포넌트에서: import type { Item } from './types';
```
### 데이터 변환 체인
```
Backend (snake_case) → safeResponseJson() → transform() → Frontend (camelCase)
```
- `safeResponseJson`: PHP 백엔드가 JSON 뒤에 경고 텍스트를 붙여 보내는 경우 방어
- `transform`: snake_case → camelCase 변환 (개발자 작성)

View File

@@ -0,0 +1,346 @@
# 공통 컴포넌트 가이드
## 컴포넌트 계층 요약
```
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`)
**기본 사용법**:
```tsx
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
```tsx
<PageHeader
title="품목 관리"
description="품목 목록을 관리합니다"
icon={Package}
actions={<Button onClick={handleCreate}>등록</Button>}
/>
```
| Prop | 타입 | 설명 |
|------|------|------|
| `title` | string \| ReactNode | 페이지 제목 (필수) |
| `description?` | string | 부제목 |
| `icon?` | LucideIcon | 좌측 아이콘 |
| `actions?` | ReactNode | 우측 액션 버튼 |
### PageLayout
```tsx
<PageLayout maxWidth="full">
{children}
</PageLayout>
```
| Prop | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| `maxWidth?` | "sm"\|"md"\|"lg"\|"xl"\|"2xl"\|"full" | "full" | 최대 너비 |
### StatCards
```tsx
<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
```tsx
<SearchFilter
searchValue={search}
onSearchChange={setSearch}
searchPlaceholder="검색어 입력"
extraActions={<DatePicker value={date} onChange={setDate} />}
/>
```
### DataTable
```tsx
<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 조합 금지**.
```tsx
<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
```tsx
<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
```tsx
<EmptyState message="데이터가 없습니다" />
<TableEmptyState colSpan={5} message="검색 결과가 없습니다" />
```
---
## Molecules
**경로**: `src/components/molecules/`
### FormField (신규 폼 필수)
`Label + Input + Error` 수동 조합 대신 사용.
```tsx
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
```tsx
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과 함께 사용:
```tsx
<ColumnSettingsPopover
columns={allColumnsWithVisibility}
onToggle={toggleColumnVisibility}
onReset={resetSettings}
hasHiddenColumns={hasHiddenColumns}
/>
```
### StandardDialog
```tsx
<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`

View File

@@ -0,0 +1,176 @@
# UI 컴포넌트 카탈로그
**경로**: `src/components/ui/`
**기반**: shadcn/ui (Radix UI + Tailwind CSS)
---
## 입력 컴포넌트
### 기본 입력
| 컴포넌트 | 파일 | 용도 |
|----------|------|------|
| `Input` | `input.tsx` | 텍스트 입력 |
| `Textarea` | `textarea.tsx` | 여러 줄 텍스트 |
| `Checkbox` | `checkbox.tsx` | 체크박스 |
| `RadioGroup` | `radio-group.tsx` | 라디오 버튼 |
| `Switch` | `switch.tsx` | 토글 스위치 |
| `Slider` | `slider.tsx` | 슬라이더 |
| `Select` | `select.tsx` | 셀렉트 (Radix UI) |
### 특화 입력
| 컴포넌트 | 파일 | 용도 | 특징 |
|----------|------|------|------|
| `DatePicker` | `date-picker.tsx` | 날짜 선택 | 한글 locale, 주말/휴일 색상, 연/월 선택, "오늘" 버튼 |
| `DateRangePicker` | `date-range-picker.tsx` | 기간 선택 | 시작~종료 날짜 |
| `DateTimePicker` | `date-time-picker.tsx` | 날짜+시간 | |
| `TimePicker` | `time-picker.tsx` | 시간만 | |
| `PhoneInput` | `phone-input.tsx` | 전화번호 | 자동 하이픈 (010-1234-5678) |
| `BusinessNumberInput` | `business-number-input.tsx` | 사업자번호 | 자동 포맷 (000-00-00000) |
| `PersonalNumberInput` | `personal-number-input.tsx` | 주민번호 | 마스킹 가능 |
| `CardNumberInput` | `card-number-input.tsx` | 카드번호 | 4자리 구분 |
| `AccountNumberInput` | `account-number-input.tsx` | 계좌번호 | |
| `NumberInput` | `number-input.tsx` | 숫자 | |
| `CurrencyInput` | `currency-input.tsx` | 금액 | 천단위 콤마, ₩ 접두사 |
| `QuantityInput` | `quantity-input.tsx` | 수량 | +/- 버튼 |
| `FileInput` | `file-input.tsx` | 파일 | |
| `FileDropzone` | `file-dropzone.tsx` | 파일 드래그 앤 드롭 | |
| `ImageUpload` | `image-upload.tsx` | 이미지 업로드 | 미리보기 |
### 검색/선택
| 컴포넌트 | 파일 | 용도 |
|----------|------|------|
| `SearchableSelect` | `searchable-select.tsx` | 검색 가능 셀렉트 |
| `MultiSelectCombobox` | `multi-select-combobox.tsx` | 다중 선택 콤보박스 |
| `Command` | `command.tsx` | 검색/필터 커맨드 팔레트 |
---
## 피드백 컴포넌트
| 컴포넌트 | 파일 | 용도 |
|----------|------|------|
| `Button` | `button.tsx` | 버튼 (variant: default, destructive, outline, secondary, ghost, link) |
| `Badge` | `badge.tsx` | 뱃지 |
| `Alert` | `alert.tsx` | 알림 |
| `AlertDialog` | `alert-dialog.tsx` | 알림 다이얼로그 |
| `ConfirmDialog` | `confirm-dialog.tsx` | 확인 다이얼로그 |
| `ErrorCard` | `error-card.tsx` | 에러 카드 |
| `ErrorMessage` | `error-message.tsx` | 에러 메시지 |
| `LoadingSpinner` | `loading-spinner.tsx` | 로딩 스피너 |
| `Skeleton` | `skeleton.tsx` | 스켈레톤 (로딩 플레이스홀더) |
| `toast` | `sonner` 라이브러리 | 토스트 알림 |
---
## 레이아웃 컴포넌트
| 컴포넌트 | 파일 | 용도 |
|----------|------|------|
| `Card` | `card.tsx` | 카드 (CardHeader, CardContent, CardFooter) |
| `Dialog` | `dialog.tsx` | 다이얼로그 (모달) |
| `Drawer` | `drawer.tsx` | 드로어 (하단/측면 패널) |
| `Popover` | `popover.tsx` | 팝오버 |
| `Sheet` | `sheet.tsx` | 시트 (측면 패널) |
| `Accordion` | `accordion.tsx` | 아코디언 (접기/펼치기) |
| `Tabs` | `tabs.tsx` | 탭 |
| `Table` | `table.tsx` | 테이블 (Table, TableHeader, TableBody, TableRow, TableCell) |
---
## 기타 컴포넌트
| 컴포넌트 | 파일 | 용도 |
|----------|------|------|
| `Label` | `label.tsx` | 라벨 |
| `Separator` | `separator.tsx` | 구분선 |
| `ScrollArea` | `scroll-area.tsx` | 스크롤 영역 |
| `Tooltip` | `tooltip.tsx` | 툴팁 |
| `Progress` | `progress.tsx` | 진행률 바 |
| `FileList` | `file-list.tsx` | 파일 목록 표시 |
| `ChartWrapper` | `chart-wrapper.tsx` | 차트 래퍼 (Recharts) |
| `EmptyState` | `empty-state.tsx` | 빈 상태 표시 |
---
## DatePicker 사용법
프로젝트 전체에서 `<input type="date">` 대신 사용.
```tsx
import { DatePicker } from '@/components/ui/date-picker';
// 기본
<DatePicker
value={date} // "yyyy-MM-dd" 문자열
onChange={(date) => setDate(date)}
/>
// 옵션
<DatePicker
value={date}
onChange={setDate}
placeholder="날짜 선택"
size="sm" // "default" | "sm" | "lg"
disabled={!isEditMode}
minDate={new Date('2024-01-01')}
maxDate={new Date()}
/>
```
**Props**:
- `value`: `string` (yyyy-MM-dd 형식)
- `onChange`: `(date: string) => void`
- `size?`: `"default"` | `"sm"` | `"lg"`
- `disabled?`, `placeholder?`, `className?`
- `minDate?`, `maxDate?`: `Date` 타입 (**문자열 아님**)
---
## Radix UI Select 주의사항
빈 값('')으로 마운트 후 value 변경이 반영 안 되는 버그:
```tsx
// ✅ key prop으로 강제 리마운트
<Select
key={`${fieldKey}-${stringValue}`}
value={stringValue}
onValueChange={onChange}
>
{/* options */}
</Select>
```
---
## 팝업 정책
```
❌ 금지: alert(), confirm(), prompt()
✅ 사용: AlertDialog, ConfirmDialog, toast (sonner)
```
```tsx
// 토스트
import { toast } from 'sonner';
toast.success('저장되었습니다');
toast.error('오류가 발생했습니다');
// 확인 다이얼로그
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>삭제 확인</AlertDialogTitle>
<AlertDialogDescription>정말 삭제하시겠습니까?</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>취소</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete}>삭제</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
```

View File

@@ -0,0 +1,186 @@
# 공통 Hooks
**경로**: `src/hooks/`
---
## 리스트 페이지 관련
### useColumnSettings
테이블 컬럼 표시/숨기기 및 너비 관리. `IntegratedListTemplateV2`와 함께 사용.
```tsx
import { useColumnSettings } from '@/hooks/useColumnSettings';
const columns = [
{ key: 'name', label: '이름', width: '200px' },
{ key: 'code', label: '코드', width: '150px' },
{ key: 'status', label: '상태', width: '100px' },
];
const {
visibleColumns, // 현재 표시되는 컬럼
allColumnsWithVisibility, // 전체 컬럼 (visibility/locked 포함)
columnWidths, // 컬럼 너비 맵
setColumnWidth, // 컬럼 너비 변경
toggleColumnVisibility, // 컬럼 표시/숨기기 토글
resetSettings, // 초기화
hasHiddenColumns, // 숨겨진 컬럼 존재 여부
} = useColumnSettings({
pageId: 'item-list', // Zustand 저장 키 (고유)
columns,
alwaysVisibleKeys: ['name'], // 항상 표시되는 컬럼 (숨기기 불가)
});
```
### useListHandlers
리스트 페이지 검색, 필터, 페이지네이션 핸들러 통합.
```tsx
import { useListHandlers } from '@/hooks/useListHandlers';
const { search, setSearch, pagination, handlePageChange, handleSearch } = useListHandlers({
initialSearch: '',
fetchData: getItems,
});
```
### useCRUDHandlers
생성, 수정, 삭제 핸들러 통합.
```tsx
import { useCRUDHandlers } from '@/hooks/useCRUDHandlers';
const { handleCreate, handleUpdate, handleDelete, isSubmitting } = useCRUDHandlers({
createFn: createItem,
updateFn: updateItem,
deleteFn: deleteItems,
onSuccess: () => fetchData(),
});
```
### useDeleteDialog
삭제 확인 다이얼로그 상태 관리.
```tsx
import { useDeleteDialog } from '@/hooks/useDeleteDialog';
const { isOpen, openDialog, closeDialog, confirmDelete, targetId } = useDeleteDialog({
onConfirm: async (id) => {
await deleteItem(id);
fetchData();
},
});
```
---
## 상세 페이지 관련
### useDetailPageState
상세/수정/등록 페이지의 모드 및 상태 관리.
```tsx
import { useDetailPageState } from '@/hooks/useDetailPageState';
const { mode, isEditMode, isNewMode, isViewMode } = useDetailPageState();
```
### useDetailData
상세 데이터 비동기 로드.
```tsx
import { useDetailData } from '@/hooks/useDetailData';
const { data, isLoading, error, refetch } = useDetailData({
id: params.id,
fetchFn: getItem,
});
```
---
## 데이터 관련
### useCommonCodes
공통 코드 조회 (상태, 분류 등).
```tsx
import { useCommonCodes } from '@/hooks/useCommonCodes';
const { codes, isLoading } = useCommonCodes('item_status');
// codes: [{ id: 'active', name: '활성' }, { id: 'inactive', name: '비활성' }]
```
### useClientList
거래처 목록 조회.
```tsx
import { useClientList } from '@/hooks/useClientList';
const { clients, isLoading } = useClientList();
```
### useItemList
품목 목록 조회.
```tsx
import { useItemList } from '@/hooks/useItemList';
const { items, isLoading } = useItemList();
```
---
## 유틸리티 관련
### useDateRange
날짜 범위 상태 관리.
```tsx
import { useDateRange } from '@/hooks/useDateRange';
const { startDate, endDate, setStartDate, setEndDate, reset } = useDateRange({
defaultStart: '2024-01-01',
defaultEnd: '2024-12-31',
});
```
### usePermission
권한 기반 접근 제어.
```tsx
import { usePermission } from '@/hooks/usePermission';
const { canRead, canWrite, canDelete } = usePermission('item_master');
if (!canWrite) {
return <div>수정 권한이 없습니다.</div>;
}
```
### useDaumPostcode
다음 우편번호 API 연동.
```tsx
import { useDaumPostcode } from '@/hooks/useDaumPostcode';
const { openPostcode } = useDaumPostcode({
onComplete: (data) => {
setAddress(data.address);
setZipCode(data.zonecode);
},
});
```

View File

@@ -0,0 +1,175 @@
# 유틸리티 함수
---
## cn - 클래스명 병합
```typescript
import { cn } from '@/lib/utils';
// clsx + tailwind-merge
<div className={cn('p-4 bg-white', isActive && 'bg-blue-50', className)} />
```
## safeJsonParse - 안전한 JSON 파싱
```typescript
import { safeJsonParse } from '@/lib/utils';
const data = safeJsonParse<Config>(localStorage.getItem('config'), defaultConfig);
// 파싱 실패 시 fallback 반환
```
---
## 포맷팅 함수
**경로**: `src/lib/formatters.ts`
### 전화번호
```typescript
import { formatPhoneNumber, parsePhoneNumber } from '@/lib/formatters';
formatPhoneNumber('01012345678') // → "010-1234-5678"
parsePhoneNumber('010-1234-5678') // → "01012345678" (숫자만)
```
### 사업자번호
```typescript
import { formatBusinessNumber, validateBusinessNumber } from '@/lib/formatters';
formatBusinessNumber('1234567890') // → "123-45-67890"
validateBusinessNumber('1234567890') // → true/false (체크섬 검증)
```
### 주민번호
```typescript
import { formatPersonalNumber, formatPersonalNumberMasked } from '@/lib/formatters';
formatPersonalNumber('9001011234567') // → "900101-1234567"
formatPersonalNumberMasked('9001011234567') // → "900101-*******"
```
### 카드/계좌번호
```typescript
import { formatCardNumber, formatAccountNumber } from '@/lib/formatters';
formatCardNumber('1234567890123456') // → "1234-5678-9012-3456"
formatAccountNumber('12345678901234') // → "1234-5678-9012-34"
```
### 숫자/금액
```typescript
import { formatNumber, parseNumber, extractDigits } from '@/lib/formatters';
formatNumber(1234567) // → "1,234,567"
parseNumber('1,234,567') // → 1234567
extractDigits('abc-123-def') // → "123"
```
---
## URL 빌더
**경로**: `src/lib/api/query-params.ts`
```typescript
import { buildApiUrl, buildQueryParams } from '@/lib/api/query-params';
// API URL 생성 (undefined/null/'' 자동 필터링)
const url = buildApiUrl('/api/v1/items', {
search: 'test',
status: undefined, // 제외
page: 1,
});
// 쿼리 파라미터만 생성
const params = buildQueryParams({ search: 'test', page: 1 });
// → URLSearchParams 객체
```
---
## 인쇄 유틸리티
**경로**: `src/lib/print-utils.ts`
```typescript
import { printElement, printArea } from '@/lib/print-utils';
// 특정 요소 인쇄
printElement(document.getElementById('invoice'));
// .print-area 클래스 영역 인쇄
printArea({ title: '견적서' });
// 옵션
printElement('#invoice', {
title: '견적서', // 브라우저 탭 제목
styles: customCSS, // 추가 CSS
closeAfterPrint: true, // 인쇄 후 창 닫기
});
```
**HTML에서 사용**:
```tsx
<div className="print-area">
{/* 인쇄될 영역 */}
</div>
```
---
## 인증 헤더
**경로**: `src/lib/api/auth-headers.ts`
```typescript
import { getAuthHeaders, getMultipartHeaders, hasAuthToken } from '@/lib/api/auth-headers';
// JSON 요청 헤더 (프록시 사용 시)
const headers = getAuthHeaders();
// → { 'Content-Type': 'application/json', 'Accept': 'application/json' }
// Multipart FormData 헤더
const headers = getMultipartHeaders();
// → { 'Accept': 'application/json' }
// 인증 상태 확인 (클라이언트)
if (hasAuthToken()) { /* 인증됨 */ }
```
---
## 에러 처리
**경로**: `src/lib/api/errors.ts`
```typescript
import { createErrorResponse, isApiError, isAuthError } from '@/lib/api/errors';
// 에러 응답 생성
const error = createErrorResponse(404, '데이터를 찾을 수 없습니다');
// 에러 타입 확인
if (isApiError(response)) { /* API 에러 */ }
if (isAuthError(response)) { /* 인증 에러 (401) */ }
```
---
## localStorage 접근 (Next.js 호환)
```typescript
// ✅ Next.js Pattern (SSR 안전)
const [data, setData] = useState(() => {
if (typeof window === 'undefined') return defaultValue;
const saved = localStorage.getItem('key');
return saved ? JSON.parse(saved) : defaultValue;
});
```

View File

@@ -0,0 +1,205 @@
# 코딩 컨벤션 및 필수 규칙
---
## Client Component 필수
모든 페이지는 `'use client'` 선언 필수. Server Component 사용 금지.
```tsx
// ✅ 올바른 패턴
'use client';
export default function Page() { ... }
// ❌ 금지
export default async function Page() { ... }
```
**이유**: 폐쇄형 ERP (SEO 불필요), Server Component에서 쿠키 수정(토큰 갱신) 불가
## 데이터 로딩 패턴
```tsx
'use client';
import { useEffect, useState } from 'react';
import { getData } from '@/components/.../actions';
export default function Page() {
const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
getData()
.then(result => {
if (result.success) setData(result.data);
})
.finally(() => setIsLoading(false));
}, []);
if (isLoading) return <div>로딩 ...</div>;
return <Component data={data} />;
}
```
---
## buildApiUrl 필수 사용
```tsx
// ✅ 필수
import { buildApiUrl } from '@/lib/api/query-params';
const url = buildApiUrl('/api/v1/items', { search, page });
// ❌ 금지
const params = new URLSearchParams();
params.set('search', value);
const url = `${API_URL}/api/v1/items?${params.toString()}`;
```
---
## 컴포넌트 재사용 우선
새 컴포넌트 작성 전 확인 순서:
1. `src/components/organisms/index.ts` export 목록
2. `src/components/molecules/` 내 공통 컴포넌트
3. `src/components/ui/` 내 UI 컴포넌트
4. dev/component-registry 페이지 검색
5. 동일 도메인 기존 컴포넌트
---
## FormField 사용 (신규 폼)
```tsx
// ✅ 신규 폼 - FormField 사용
<FormField label="회사명" value={v} onChange={handleChange} />
// ❌ 신규 폼에서 수동 조합 금지
<div className="space-y-2">
<Label>회사명</Label>
<Input value={v} onChange={handleChange} />
</div>
```
**기존 폼**: 건드리지 않음 (정상 작동 중이면 마이그레이션 불필요)
---
## Zod 스키마 검증 (신규 폼)
```tsx
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
// 1. 스키마 정의
const formSchema = z.object({
itemName: z.string().min(1, '품목명을 입력하세요'),
quantity: z.number().min(1, '1 이상 입력하세요'),
status: z.enum(['active', 'inactive']),
memo: z.string().optional(),
});
// 2. 타입 추출 (별도 interface 정의 불필요)
type FormData = z.infer<typeof formSchema>;
// 3. useForm에 연결
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: { itemName: '', quantity: 1, status: 'active' },
});
```
**규칙**:
- 에러 메시지 한글 작성
- 스키마 위치: 컴포넌트 파일 상단 또는 `schema.ts`
- `z.infer` 사용, 별도 `interface` 중복 정의 금지
---
## 팝업 정책
```
❌ 금지: alert(), confirm(), prompt()
✅ 사용: Radix UI Dialog/AlertDialog, toast (sonner)
```
---
## 검색 모달 표준
```
❌ 금지: Dialog + Input + 리스트 직접 조합
✅ 사용: SearchableSelectionModal<T>
```
---
## 리스트 페이지 필수 항목
`IntegratedListTemplateV2` 사용 시:
- [ ] `useColumnSettings` + `ColumnSettingsPopover` 적용
- [ ] `renderMobileCard` (모바일 카드) 구현
- [ ] `selectedItems: Set<string>` (체크박스) 구현
- [ ] `tableHeaderActions` (테이블 내 필터) 필요 시 구현
---
## 테이블 rowSpan/colSpan (문서/보고서)
**반드시 구조 분석 → 코딩 순서**:
1. **플랫 인덱스 맵**: 실제 렌더링 행 수 기준으로 인덱스 산정
2. **병합 범위 표기**: span은 그룹 첫 행에만
3. **Coverage Map 패턴**:
```typescript
function buildCoverageMap(items, spanKey) {
const map = {};
const covered = new Set();
items.forEach((item, idx) => {
const span = item[spanKey];
if (span && span > 1) {
map[idx] = span;
for (let i = idx + 1; i < idx + span; i++) covered.add(i);
}
});
return { map, covered };
}
// map에 있으면 → <td rowSpan={span}>
// covered에 있으면 → skip (렌더링 안 함)
// 둘 다 아니면 → 일반 <td>
```
---
## Git 규칙
- **develop**: 평소 작업 (자유롭게 커밋)
- **main**: 기능별 squash merge만 (직접 push 금지)
- **커밋 메시지**: `[타입]: 작업내용` (feat, fix, chore, refactor 등)
- **`snapshot.txt`, `.DS_Store`**: 항상 제외
---
## 빌드 정책
- 개발자가 직접 빌드 확인
- TypeScript strict 모드 사용
- ESLint: 빌드 시 무시 (CI에서 별도 처리)
---
## 신규 페이지 생성 체크리스트
- [ ] `'use client'` 선언
- [ ] `?mode=new/edit` 쿼리파라미터 패턴 사용 (`/new`, `/edit` 경로 금지)
- [ ] Server Action에서 `buildApiUrl()` 사용
- [ ] 기존 컴포넌트 재사용 확인 (organisms, molecules 검색)
- [ ] 리스트 페이지: `IntegratedListTemplateV2` 사용 검토
- [ ] 폼 페이지: FormField, Zod 스키마 사용 (신규)
- [ ] 검색 모달: `SearchableSelectionModal` 사용
- [ ] 하단 sticky 액션 바 구현
- [ ] 모바일 반응형 대응
- [ ] 타입 체크 (`npx tsc --noEmit`)