From 8f939d3609cc39b4d6ed86b43e81a52e49ed0c62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Mon, 9 Mar 2026 10:24:25 +0900 Subject: [PATCH] =?UTF-8?q?docs:=20[frontend]=20=ED=94=84=EB=A1=A0?= =?UTF-8?q?=ED=8A=B8=EC=97=94=EB=93=9C=20=EC=95=84=ED=82=A4=ED=85=8D?= =?UTF-8?q?=EC=B2=98/=EA=B0=80=EC=9D=B4=EB=93=9C=20=EB=AC=B8=EC=84=9C=20v1?= =?UTF-8?q?=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _index.md: 문서 목록 및 버전 관리 - 01~09: 아키텍처, API패턴, 컴포넌트, 폼, 스타일, 인증, 대시보드, 컨벤션 - 10: 문서 API 연동 스펙 (api-specs에서 이관) Co-Authored-By: Claude Opus 4.6 --- frontend/_index.md | 96 ++ frontend/v1/01-architecture.md | 140 +++ frontend/v1/02-api-pattern.md | 279 ++++++ frontend/v1/03-component-design.md | 172 ++++ frontend/v1/04-common-components.md | 246 +++++ frontend/v1/05-form-pattern.md | 193 ++++ frontend/v1/06-styling-guide.md | 225 +++++ frontend/v1/07-auth-flow.md | 137 +++ frontend/v1/08-dashboard-system.md | 183 ++++ frontend/v1/09-conventions.md | 266 +++++ frontend/v1/10-document-api-integration.md | 1049 ++++++++++++++++++++ 11 files changed, 2986 insertions(+) create mode 100644 frontend/_index.md create mode 100644 frontend/v1/01-architecture.md create mode 100644 frontend/v1/02-api-pattern.md create mode 100644 frontend/v1/03-component-design.md create mode 100644 frontend/v1/04-common-components.md create mode 100644 frontend/v1/05-form-pattern.md create mode 100644 frontend/v1/06-styling-guide.md create mode 100644 frontend/v1/07-auth-flow.md create mode 100644 frontend/v1/08-dashboard-system.md create mode 100644 frontend/v1/09-conventions.md create mode 100644 frontend/v1/10-document-api-integration.md diff --git a/frontend/_index.md b/frontend/_index.md new file mode 100644 index 0000000..679ee9b --- /dev/null +++ b/frontend/_index.md @@ -0,0 +1,96 @@ +# SAM ERP Frontend Documentation + +> **프로젝트**: SAM ERP Next.js 프론트엔드 +> **최종 갱신**: 2026-03-09 +> **현재 문서 버전**: v1 + +--- + +## 문서 구조 + +``` +frontend/ +├── _index.md ← 현재 문서 (목록 + 버전 관리) +├── v1/ ← 현재 활성 버전 +│ ├── 01 ~ 09 ← 프론트엔드 아키텍처/가이드 +│ └── 10 ← API 연동 스펙 +└── api-specs/ ← (레거시, v1/10으로 이관됨) +``` + +--- + +## 문서 목록 및 버전 현황 + +| # | 문서 | 버전 | 최종 수정 | 담당 | 대상 | 설명 | +|---|------|------|----------|------|------|------| +| 01 | [architecture](v1/01-architecture.md) | 1.0.0 | 2026-03-09 | Frontend | 전체 | 프로젝트 구조, 기술 스택, 디렉토리 설계 | +| 02 | [api-pattern](v1/02-api-pattern.md) | 1.0.0 | 2026-03-09 | Frontend | FE/BE | API 통신 패턴 (프록시, Server Action, buildApiUrl) | +| 03 | [component-design](v1/03-component-design.md) | 1.0.0 | 2026-03-09 | Frontend | FE/기획 | 컴포넌트 계층 (atoms → templates), 페이지 유형 | +| 04 | [common-components](v1/04-common-components.md) | 1.0.0 | 2026-03-09 | Frontend | FE | 공통 컴포넌트 사용법 (UniversalListPage 등) | +| 05 | [form-pattern](v1/05-form-pattern.md) | 1.0.0 | 2026-03-09 | Frontend | FE | 폼 패턴 (Zod, FormField, react-hook-form) | +| 06 | [styling-guide](v1/06-styling-guide.md) | 1.0.0 | 2026-03-09 | Frontend | FE/디자인 | CSS 규칙 (Tailwind, shadcn/ui, 색상 시스템) | +| 07 | [auth-flow](v1/07-auth-flow.md) | 1.0.0 | 2026-03-09 | Frontend | FE/BE | 인증 흐름 (HttpOnly cookie, 토큰 갱신) | +| 08 | [dashboard-system](v1/08-dashboard-system.md) | 1.0.0 | 2026-03-09 | Frontend | FE/BE | CEO 대시보드 아키텍처 (invalidation, hooks) | +| 09 | [conventions](v1/09-conventions.md) | 1.0.0 | 2026-03-09 | Frontend | FE | 네이밍, import, 파일 배치, Git 규칙 | +| 10 | [document-api-integration](v1/10-document-api-integration.md) | 1.0.0 | 2026-02-05 | API Team | FE/BE | 문서 관리 API 연동 (검사 성적서 resolve/upsert) | + +### 대상 범례 +- **FE**: 프론트엔드 개발자 +- **BE**: 백엔드 개발자 +- **기획**: 기획자/PM +- **디자인**: 디자이너 +- **전체**: 모든 역할 + +--- + +## 버전 변경 이력 + +### v1 (2026-03-09 ~) + +| 날짜 | 문서 | 변경 | 버전 | +|------|------|------|------| +| 2026-03-09 | 01~09 | 초기 작성 | 1.0.0 | +| 2026-02-05 | 10 | 문서 API 연동 가이드 작성 (api-specs에서 이관) | 1.0.0 | + +--- + +## 버전 관리 규칙 + +### 문서 버전 (Semantic Versioning) + +``` +MAJOR.MINOR.PATCH + +MAJOR: 문서 구조 변경, 기존 내용 대폭 수정 +MINOR: 새로운 섹션 추가, 기존 내용 보완 +PATCH: 오탈자, 코드 예시 수정, 사소한 수정 +``` + +### 업데이트 절차 + +1. 해당 문서 내용 수정 +2. 문서 상단 `버전`과 `최종 수정` 날짜 갱신 +3. 이 `_index.md`의 문서 목록 테이블 버전/날짜 갱신 +4. 변경 이력 테이블에 행 추가 + +### 새 문서 추가 시 + +1. `v1/` 폴더에 `{번호}-{주제}.md` 형식으로 생성 +2. 문서 상단에 버전/날짜/대상 헤더 포함 +3. `_index.md` 문서 목록 테이블에 행 추가 + +--- + +## 빠른 참고 + +| 할 일 | 읽을 문서 | +|-------|----------| +| 프로젝트 전체 구조 이해 | 01-architecture | +| API 호출 방법 알기 | 02-api-pattern | +| 새 리스트 페이지 만들기 | 03-component-design → 04-common-components | +| 새 폼 페이지 만들기 | 05-form-pattern | +| 디자인/스타일 규칙 확인 | 06-styling-guide | +| 인증 동작 이해 | 07-auth-flow | +| 대시보드 연동 작업 | 08-dashboard-system | +| 코딩 컨벤션 확인 | 09-conventions | +| 문서 관리 API 연동 | 10-document-api-integration | diff --git a/frontend/v1/01-architecture.md b/frontend/v1/01-architecture.md new file mode 100644 index 0000000..1881a1d --- /dev/null +++ b/frontend/v1/01-architecture.md @@ -0,0 +1,140 @@ +# 01. 프로젝트 아키텍처 + +> **대상**: 프론트엔드/백엔드/기획자 +> **버전**: 1.0.0 +> **최종 수정**: 2026-03-09 + +--- + +## 1. 기술 스택 + +| 영역 | 기술 | 버전 | +|------|------|------| +| 프레임워크 | Next.js (App Router) | 15.x | +| 런타임 | React | 19.x | +| 언어 | TypeScript | strict mode | +| UI 컴포넌트 | shadcn/ui (Radix UI 기반) | - | +| 스타일링 | Tailwind CSS | 4.x | +| 폼 관리 | react-hook-form + Zod | - | +| 상태관리 | React hooks (useState/useCallback/useMemo) | - | +| 토스트/알림 | sonner | - | +| 국제화 | next-intl | - | +| 모바일 | Capacitor (하이브리드 앱) | - | +| 백엔드 API | PHP Laravel (별도 프로젝트) | 12.x | + +--- + +## 2. 프로젝트 특성 + +- **폐쇄형 ERP 시스템**: 인증 필수, SEO 불필요, 오히려 노출 방지 +- **모든 페이지 Client Component**: `'use client'` 필수 (서버 컴포넌트 사용 금지) +- **이유**: HttpOnly 쿠키 기반 인증 → 서버 컴포넌트에서 쿠키 수정(토큰 갱신) 불가 + +--- + +## 3. 디렉토리 구조 + +``` +src/ +├── app/ # Next.js App Router +│ ├── [locale]/ # 다국어 라우팅 (ko, en) +│ │ ├── (protected)/ # 인증 필수 영역 +│ │ │ ├── layout.tsx # AuthenticatedLayout (사이드바/헤더) +│ │ │ ├── accounting/ # 회계 도메인 +│ │ │ ├── hr/ # 인사 도메인 +│ │ │ ├── production/ # 생산 도메인 +│ │ │ ├── orders/ # 영업/주문 도메인 +│ │ │ ├── business/ # 경영/대시보드 +│ │ │ └── dev/ # 개발 도구 (운영 비활성) +│ │ └── (auth)/ # 비인증 영역 (로그인 등) +│ └── api/ +│ └── proxy/[...path]/ # API 프록시 (HttpOnly 쿠키 처리) +│ +├── components/ +│ ├── atoms/ # 최소 단위 (ScrollableButtonGroup 등) +│ ├── molecules/ # 조합 단위 (FormField 등) +│ ├── organisms/ # 페이지 구성 블록 (PageHeader, DataTable 등) +│ ├── templates/ # 페이지 레이아웃 틀 +│ │ ├── UniversalListPage/ # 리스트 페이지 템플릿 (59+ 페이지 사용) +│ │ └── IntegratedDetailTemplate/ # 상세/폼 페이지 템플릿 +│ ├── ui/ # shadcn/ui 기본 컴포넌트 +│ └── {domain}/ # 도메인별 비즈니스 컴포넌트 +│ ├── accounting/ # 회계 (입금, 출금, 전표 등) +│ ├── hr/ # 인사 +│ ├── production/ # 생산 +│ ├── orders/ # 영업 +│ └── business/ # CEO 대시보드 등 +│ +├── hooks/ # 커스텀 훅 +├── lib/ +│ ├── api/ # API 통신 유틸리티 (핵심) +│ ├── utils/ # 범용 유틸리티 +│ └── dashboard-invalidation.ts # 대시보드 갱신 시스템 +│ +├── middleware.ts # 인증/보안/봇 차단 미들웨어 +└── types/ # 전역 타입 정의 +``` + +--- + +## 4. 컴포넌트 계층 구조 + +``` +atoms → molecules → organisms → templates → pages +``` + +| 계층 | 역할 | 예시 | +|------|------|------| +| **atoms** | HTML 확장, 단일 기능 | ScrollableButtonGroup, PhoneInput | +| **molecules** | atom 조합, 재사용 폼 필드 | FormField (Label+Input 통합) | +| **organisms** | 페이지 구성 블록 | PageHeader, DataTable, SearchableSelectionModal | +| **templates** | 페이지 뼈대 (config 기반) | UniversalListPage, IntegratedDetailTemplate | +| **pages** | app/ 라우트 + 도메인 컴포넌트 | page.tsx → {Domain}Component | + +--- + +## 5. 데이터 흐름 + +``` +[사용자 액션] + ↓ +[컴포넌트] → useCallback/useState + ↓ +[Server Action] ('use server') + ↓ +[executeServerAction / executePaginatedAction] + ↓ +[serverFetch → authenticatedFetch] + ↓ +[/api/proxy/...path] (Next.js API Route) + ↓ +[PHP Laravel Backend /api/v1/...] +``` + +- 컴포넌트에서 직접 `fetch()` 금지 +- 모든 데이터 요청은 Server Action → API 프록시 → 백엔드 경로 +- 인증 토큰은 HttpOnly 쿠키에 저장, 프록시에서 자동 주입 + +--- + +## 6. 관련 프로젝트 + +| 프로젝트 | 경로 | 역할 | +|---------|------|------| +| sam-react-prod | sam-next/sma-next-project/sam-react-prod | Next.js 프론트엔드 (현재) | +| sam-api | sam-api/sam-api | PHP Laravel 백엔드 API | +| sam-design | sam-design/sam-design | React 디자인 시스템 (레거시) | +| sam-docs | sam-docs | 프로젝트 문서 (현재 문서) | + +--- + +## 7. 주요 설계 결정 + +| 결정 | 이유 | +|------|------| +| Client Component Only | 폐쇄형 ERP, 쿠키 수정 필요 | +| API Proxy Pattern | HttpOnly 쿠키 보안 유지 | +| Config-Driven Templates | 59+ 리스트 페이지 일관성 | +| Server Actions | 타입 안전 + 쿠키 접근 가능 | +| Tailwind + shadcn/ui | 빠른 개발 + 일관된 디자인 | +| Zod (신규 폼만) | 기존 폼 안정성 유지하면서 점진 적용 | diff --git a/frontend/v1/02-api-pattern.md b/frontend/v1/02-api-pattern.md new file mode 100644 index 0000000..823d6ac --- /dev/null +++ b/frontend/v1/02-api-pattern.md @@ -0,0 +1,279 @@ +# 02. API 통신 패턴 + +> **대상**: 프론트엔드/백엔드 개발자 +> **버전**: 1.0.0 +> **최종 수정**: 2026-03-09 + +--- + +## 1. 전체 흐름 + +``` +클라이언트(브라우저) + ↓ fetch('/api/proxy/items?page=1') ← 토큰 없이 +Next.js API Proxy (/api/proxy/[...path]) + ↓ HttpOnly 쿠키에서 access_token 읽기 + ↓ Authorization: Bearer {token} 헤더 추가 +PHP Laravel Backend (https://api.xxx.com/api/v1/items?page=1) + ↓ 응답 +Next.js → 클라이언트 (응답 전달) +``` + +**왜 프록시?** +- HttpOnly 쿠키는 JavaScript에서 읽을 수 없음 (XSS 방지) +- 서버(Next.js)에서만 쿠키 읽어서 백엔드에 전달 가능 +- 토큰 갱신(refresh)도 프록시에서 자동 처리 + +--- + +## 2. API 호출 방법 2가지 + +### 방법 A: Server Action (대부분의 경우) + +Server Action에서 `serverFetch` / `executeServerAction` 사용. + +```typescript +// components/accounting/Bills/actions.ts +'use server'; + +import { buildApiUrl } from '@/lib/api/query-params'; +import { executePaginatedAction } from '@/lib/api'; + +export async function getBills(params: BillSearchParams) { + return executePaginatedAction({ + url: buildApiUrl('/api/v1/bills', { + search: params.search, + bill_type: params.billType !== 'all' ? params.billType : undefined, + page: params.page, + }), + transform: transformBillApiToFrontend, + errorMessage: '어음 목록 조회에 실패했습니다.', + }); +} +``` + +컴포넌트에서 호출: +```typescript +// 컴포넌트 내부 +const result = await getBills({ search: '', page: 1 }); +if (result.success) { + setData(result.data); + setPagination(result.pagination); +} +``` + +### 방법 B: 프록시 직접 호출 (특수한 경우) + +대시보드 훅, 파일 다운로드 등 Server Action이 부적합한 경우에만 사용. + +```typescript +// hooks/useCEODashboard.ts +const response = await fetch('/api/proxy/daily-report/summary'); +const result = await response.json(); +``` + +--- + +## 3. 핵심 유틸리티 + +### 3.1 buildApiUrl — URL 빌더 (필수) + +```typescript +import { buildApiUrl } from '@/lib/api/query-params'; + +// 기본 사용 +buildApiUrl('/api/v1/items') +// → "https://api.xxx.com/api/v1/items" + +// 쿼리 파라미터 (undefined는 자동 제외) +buildApiUrl('/api/v1/items', { + search: '볼트', + status: undefined, // ← 자동 제외됨 + page: 1, // ← 숫자 → 문자 자동 변환 +}) +// → "https://api.xxx.com/api/v1/items?search=볼트&page=1" + +// 동적 경로 + 파라미터 +buildApiUrl(`/api/v1/items/${id}`, { with_details: true }) +``` + +**금지 패턴:** +```typescript +// ❌ 직접 URLSearchParams 사용 금지 +const params = new URLSearchParams(); +params.set('search', value); +url: `${API_URL}/api/v1/items?${params.toString()}` +``` + +### 3.2 executeServerAction — 단건 조회/CUD + +```typescript +import { executeServerAction } from '@/lib/api'; + +// 단건 조회 +return executeServerAction({ + url: buildApiUrl(`/api/v1/items/${id}`), + transform: transformItemApiToFrontend, + errorMessage: '품목 조회에 실패했습니다.', +}); + +// 생성 (POST) +return executeServerAction({ + url: buildApiUrl('/api/v1/items'), + method: 'POST', + body: JSON.stringify(payload), + errorMessage: '등록에 실패했습니다.', +}); + +// 삭제 (DELETE) +return executeServerAction({ + url: buildApiUrl(`/api/v1/items/${id}`), + method: 'DELETE', + errorMessage: '삭제에 실패했습니다.', +}); +``` + +**반환 구조:** +```typescript +{ + success: boolean; + data?: T; + error?: string; + fieldErrors?: Record; // Laravel validation 에러 +} +``` + +### 3.3 executePaginatedAction — 페이지네이션 목록 + +```typescript +import { executePaginatedAction } from '@/lib/api'; + +return executePaginatedAction({ + url: buildApiUrl('/api/v1/items', { + search: params.search, + page: params.page, + }), + transform: transformItemApiToFrontend, + errorMessage: '목록 조회에 실패했습니다.', +}); +``` + +**반환 구조:** +```typescript +{ + success: boolean; + data: T[]; + pagination: { + currentPage: number; + lastPage: number; + perPage: number; + total: number; + }; + error?: string; +} +``` + +--- + +## 4. Server Action 규칙 + +### 파일 위치 +각 도메인 컴포넌트 폴더 내 `actions.ts`: +``` +src/components/accounting/Bills/actions.ts +src/components/hr/EmployeeList/actions.ts +``` + +### 필수 규칙 + +```typescript +'use server'; // 첫 줄 필수 + +// ✅ 타입은 인라인 정의 (export interface/type 허용) +export interface BillSearchParams { ... } + +// ❌ 타입 re-export 금지 (Next.js Turbopack 제한) +// export type { BillType } from './types'; ← 컴파일 에러 +// → 컴포넌트에서 원본 파일에서 직접 import할 것 +``` + +### actions.ts 패턴 요약 + +| 작업 | 유틸리티 | HTTP | +|------|---------|------| +| 목록 조회 (페이지네이션) | `executePaginatedAction` | GET | +| 단건 조회 | `executeServerAction` | GET | +| 등록 | `executeServerAction` | POST | +| 수정 | `executeServerAction` | PUT/PATCH | +| 삭제 | `executeServerAction` | DELETE | + +--- + +## 5. 인증 토큰 흐름 + +``` +[로그인] + ↓ POST /api/proxy/auth/login + ↓ 백엔드 → access_token + refresh_token 반환 + ↓ 프록시에서 HttpOnly 쿠키로 설정 + - access_token (HttpOnly, Max-Age=2h) + - refresh_token (HttpOnly, Max-Age=7d) + - is_authenticated (non-HttpOnly, 프론트 상태 확인용) + +[API 호출] + ↓ 프록시가 쿠키에서 토큰 읽어 헤더 주입 + +[401 발생 시] + ↓ authenticatedFetch가 자동 감지 + ↓ refresh_token으로 새 access_token 발급 + ↓ 재시도 (1회) + ↓ 실패 시 → 쿠키 삭제 → 로그인 페이지 이동 +``` + +--- + +## 6. 백엔드 개발자 참고 + +### API 응답 규격 + +프론트엔드는 Laravel 표준 응답 구조를 기대합니다: + +```json +// 단건 +{ + "success": true, + "data": { ... } +} + +// 페이지네이션 목록 +{ + "success": true, + "data": [ ... ], + "current_page": 1, + "last_page": 5, + "per_page": 20, + "total": 93 +} + +// 에러 +{ + "success": false, + "message": "에러 메시지" +} + +// Validation 에러 +{ + "message": "The given data was invalid.", + "errors": { + "name": ["이름은 필수입니다."], + "amount": ["금액은 0보다 커야 합니다."] + } +} +``` + +### API 엔드포인트 기본 규칙 +- 기본 경로: `/api/v1/{resource}` +- RESTful: GET(조회), POST(생성), PUT/PATCH(수정), DELETE(삭제) +- 페이지네이션: `?page=1&per_page=20` +- 검색: `?search=키워드` +- 개별 기능 API 스펙은 `sam-docs/frontend/api-specs/` 참조 diff --git a/frontend/v1/03-component-design.md b/frontend/v1/03-component-design.md new file mode 100644 index 0000000..f51dfef --- /dev/null +++ b/frontend/v1/03-component-design.md @@ -0,0 +1,172 @@ +# 03. 컴포넌트 설계 + +> **대상**: 프론트엔드 개발자, 기획자 +> **버전**: 1.0.0 +> **최종 수정**: 2026-03-09 + +--- + +## 1. 설계 원칙 + +- **모든 페이지는 Client Component** (`'use client'` 필수) +- **Config-Driven**: 설정 객체로 페이지 동작 정의 → 일관성 + 빠른 개발 +- **기존 패턴 우선**: 새 컴포넌트 만들기 전 반드시 유사 컴포넌트 검색 +- **계층 준수**: atoms → molecules → organisms → templates → pages + +--- + +## 2. 페이지 3대 유형 + +### 2.1 리스트 페이지 → UniversalListPage + +**사용 현황**: 59+ 페이지 + +```typescript +// 패턴: page.tsx는 얇은 껍데기, 비즈니스 로직은 도메인 컴포넌트에 +// src/app/[locale]/(protected)/accounting/bills/page.tsx +'use client'; +import { BillManagement } from '@/components/accounting/BillManagement'; +export default function BillsPage() { + return ; +} + +// src/components/accounting/BillManagement/index.tsx +export function BillManagement() { + const config: UniversalListConfig = { + title: '어음관리', + icon: FileText, + columns: [...], + actions: { getList: getBills }, + // ... 나머지 설정 + }; + return ; +} +``` + +**config로 제어하는 것들:** +- 컬럼 정의, 정렬, 필터 +- 검색/날짜 선택기 +- 통계 카드 +- 체크박스/선택 +- 액션 버튼 +- 모바일 카드 렌더링 +- Excel 내보내기 + +### 2.2 상세/폼 페이지 → IntegratedDetailTemplate + +**모드**: create(등록), view(조회), edit(수정) + +```typescript + +``` + +**또는 Card 기반 수동 구성** (기존 패턴): +```typescript + + + + + {/* 폼 필드들 */} + + + +``` + +### 2.3 대시보드 → 커스텀 섹션 조합 + +CEO 대시보드처럼 여러 섹션을 조합하는 경우: +```typescript + + +
+ {sectionOrder.map(key => renderSection(key))} +
+
+``` + +--- + +## 3. 컴포넌트 계층별 가이드 + +### atoms (src/components/atoms/) +- 가장 작은 재사용 단위 +- HTML 요소 확장 또는 단일 기능 +- 예: ScrollableButtonGroup, PhoneInput, BusinessNumberInput + +### molecules (src/components/molecules/) +- atom 2개 이상 조합 +- **FormField**: Label + Input 통합 (신규 폼 필수) +- 예: FormField, DateRangeFilter + +### organisms (src/components/organisms/) +- 독립적 기능 블록, 페이지에 바로 배치 가능 +- **내보내기 확인**: `src/components/organisms/index.ts` + +| 컴포넌트 | 용도 | +|---------|------| +| PageHeader | 페이지 제목 + 액션 버튼 | +| PageLayout | 페이지 콘텐츠 래퍼 (패딩/max-width) | +| DataTable | 범용 데이터 테이블 | +| StatCards | 통계 카드 모음 | +| SearchFilter | 검색/필터 바 | +| SearchableSelectionModal\ | 검색+선택 모달 (제네릭) | +| MobileCard | 모바일 리스트 카드 | + +### templates (src/components/templates/) +- 페이지 전체 구조 정의 +- config 객체로 동작 제어 + +| 템플릿 | 용도 | 사용 수 | +|--------|------|--------| +| UniversalListPage | 리스트/목록 페이지 | 59+ | +| IntegratedDetailTemplate | 상세/등록/수정 페이지 | 10+ | + +### 도메인 컴포넌트 (src/components/{domain}/) +- 비즈니스 로직 포함 +- 도메인별 폴더 분류 + +``` +components/ +├── accounting/ # 회계: 입금, 출금, 전표, 어음, 세금계산서 +├── hr/ # 인사: 사원, 급여, 근태 +├── production/ # 생산: 공정, 생산일보 +├── orders/ # 영업: 주문, 견적, 수주 +├── business/ # 경영: CEO 대시보드 +└── common/ # 공통: 계정과목 설정 등 여러 도메인에서 사용 +``` + +--- + +## 4. 새 페이지 만들기 체크리스트 + +### 리스트 페이지 +1. `src/app/[locale]/(protected)/{domain}/{page}/page.tsx` 생성 +2. `src/components/{domain}/{ComponentName}/index.tsx` 생성 +3. `src/components/{domain}/{ComponentName}/actions.ts` 생성 +4. UniversalListPage config 작성 +5. types 정의 (API 응답 → 프론트 타입 변환) + +### 상세/폼 페이지 +1. 기존 유사 페이지 검색 (패턴 참고) +2. IntegratedDetailTemplate 사용 가능한지 확인 +3. 아니면 Card 기반 수동 구성 + +### 모달/팝업 +1. `SearchableSelectionModal` 사용 가능한지 먼저 확인 +2. 아니면 Radix Dialog 직접 사용 +3. `alert()`, `confirm()` 사용 금지 → Dialog 또는 toast + +--- + +## 5. 컴포넌트 레지스트리 + +개발 환경에서 `/dev/component-registry` 접속하면: +- 전체 컴포넌트 목록 (실시간 스캔) +- 컴포넌트 간 관계도 (imports, usedBy) +- 새 컴포넌트 생성 전 기존 컴포넌트 확인 필수 diff --git a/frontend/v1/04-common-components.md b/frontend/v1/04-common-components.md new file mode 100644 index 0000000..6f9293f --- /dev/null +++ b/frontend/v1/04-common-components.md @@ -0,0 +1,246 @@ +# 04. 공통 컴포넌트 사용법 + +> **대상**: 프론트엔드 개발자 +> **버전**: 1.0.0 +> **최종 수정**: 2026-03-09 + +--- + +## 1. UniversalListPage + +59+ 리스트 페이지에서 사용하는 통합 템플릿. config 객체 하나로 전체 동작 제어. + +### 기본 사용법 + +```typescript +import { + UniversalListPage, + type UniversalListConfig, + type StatCard, +} from '@/components/templates/UniversalListPage'; + +const config: UniversalListConfig = { + // 필수 + title: '페이지 제목', + icon: FileText, + basePath: '/accounting/my-page', + idField: 'id', + columns: [ + { key: 'name', label: '이름', className: 'w-[200px]' }, + { key: 'amount', label: '금액', className: 'text-right w-[120px]' }, + { key: 'status', label: '상태', className: 'text-center w-[80px]' }, + ], + actions: { + getList: getMyList, // Server Action + deleteItems: deleteMyItems, // 선택사항 + }, + + // 선택 + itemsPerPage: 20, + showCheckbox: true, + clientSideFiltering: false, // 서버 페이지네이션 시 false +}; + +return ; +``` + +### 주요 config 옵션 + +| 옵션 | 타입 | 설명 | +|------|------|------| +| `columns` | Column[] | 테이블 컬럼 정의 | +| `actions.getList` | Function | 목록 조회 Server Action | +| `showCheckbox` | boolean | 체크박스 표시 | +| `hideSearch` | boolean | 검색창 숨김 | +| `computeStats` | () => StatCard[] | 통계 카드 | +| `headerActions` | () => ReactNode | 헤더 버튼 영역 | +| `renderTableRow` | Function | 커스텀 행 렌더링 | +| `renderMobileCard` | Function | 모바일 카드 렌더링 | +| `tableFooter` | ReactNode | 테이블 하단 (합계 행 등) | +| `dateRangeSelector` | Object | 날짜 범위 선택기 | +| `tabs` | Tab[] | 탭 필터 | +| `excelExport` | Object | Excel 내보내기 설정 | + +### 서버 페이지네이션 연동 + +```typescript +const [currentPage, setCurrentPage] = useState(1); +const [pagination, setPagination] = useState({ ... }); + + +``` + +--- + +## 2. SearchableSelectionModal\ + +검색 + 선택 기능이 필요한 모달. 직접 Dialog 조합 금지. + +### 사용법 + +```typescript +import { SearchableSelectionModal } from '@/components/organisms'; + + + open={isOpen} + onOpenChange={setIsOpen} + title="거래처 선택" + searchPlaceholder="거래처명 검색" + fetchItems={async (search) => { + const result = await searchClients({ search }); + return result.success ? result.data : []; + }} + columns={[ + { key: 'name', label: '거래처명' }, + { key: 'code', label: '코드' }, + ]} + onSelect={(item) => { + setSelectedClient(item); + setIsOpen(false); + }} + getItemId={(item) => item.id} +/> +``` + +--- + +## 3. IntegratedDetailTemplate + +상세/등록/수정 페이지 통합 템플릿. + +### 기본 사용법 + +```typescript +import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate'; + + +``` + +### forwardRef API + +프로그래밍 방식으로 폼 제어: + +```typescript +const templateRef = useRef(null); + +// 폼 데이터 읽기/쓰기 +templateRef.current?.getFormData(); +templateRef.current?.setFormData(newData); +templateRef.current?.setFieldValue('name', '새 이름'); +templateRef.current?.validate(); +``` + +--- + +## 4. PageHeader / PageLayout + +### PageLayout — 페이지 콘텐츠 래퍼 + +```typescript +import { PageLayout } from '@/components/organisms/PageLayout'; + + + {/* 콘텐츠 */} + +``` + +- 자동으로 `p-0 md:space-y-6` 패딩 적용 +- **page.tsx에서 추가 패딩 금지** (이중 패딩 방지) + +### PageHeader — 페이지 제목 + +```typescript +import { PageHeader } from '@/components/organisms/PageHeader'; + +등록 + } +/> +``` + +--- + +## 5. StatCards — 통계 카드 + +```typescript +import { StatCards } from '@/components/organisms'; + + +``` + +--- + +## 6. DataTable — 데이터 테이블 + +organisms 레벨 범용 테이블. UniversalListPage 내부에서도 사용. + +```typescript +import { DataTable } from '@/components/organisms'; + + handleDetail(item.id)} +/> +``` + +--- + +## 7. 테이블 필수 구조 + +모든 테이블은 다음 컬럼 순서를 준수: + +``` +[체크박스] → [번호(1부터)] → [데이터 컬럼들] → [작업 컬럼] +``` + +- **번호**: `(currentPage - 1) * pageSize + index + 1` +- **작업 버튼**: 체크박스 선택 시만 표시 (또는 행별 버튼) diff --git a/frontend/v1/05-form-pattern.md b/frontend/v1/05-form-pattern.md new file mode 100644 index 0000000..abf11a4 --- /dev/null +++ b/frontend/v1/05-form-pattern.md @@ -0,0 +1,193 @@ +# 05. 폼 패턴 + +> **대상**: 프론트엔드 개발자 +> **버전**: 1.0.0 +> **최종 수정**: 2026-03-09 + +--- + +## 1. 폼 패턴 2가지 + +| 패턴 | 적용 대상 | 비고 | +|------|----------|------| +| Zod + react-hook-form | **신규 폼** | 필수 | +| useState + 수동 검증 | 기존 폼 | 건드리지 않음 | + +--- + +## 2. 신규 폼: Zod + react-hook-form + +### 기본 패턴 + +```typescript +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; + +// 3. useForm 연결 +const { register, handleSubmit, formState: { errors } } = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + itemName: '', + quantity: 1, + status: 'active', + }, +}); +``` + +### 규칙 + +| 항목 | 규칙 | +|------|------| +| 스키마 위치 | 컴포넌트 파일 상단 또는 같은 폴더의 `schema.ts` | +| 타입 추출 | `z.infer` 사용, 별도 interface 중복 금지 | +| 에러 메시지 | **한글** (사용자에게 직접 표시) | +| `as` 캐스트 | 지양 (Zod가 타입 보장) | + +### Zod 사용하지 않는 경우 +- 기존 `rules={{ required: true }}` 패턴으로 작동 중인 폼 +- 필드 1~2개짜리 인라인 폼 (오버엔지니어링) + +--- + +## 3. FormField molecule + +Label + Input 수동 조합 대신 `FormField` 사용 (신규 폼). + +### 기본 사용법 + +```typescript +import { FormField } from '@/components/molecules/FormField'; + + handleChange('companyName', value)} + placeholder="회사명을 입력하세요" + disabled={!isEditMode} +/> +``` + +### 지원 타입 + +| type | 설명 | 비고 | +|------|------|------| +| `text` | 일반 텍스트 (기본값) | | +| `number` | 숫자 입력 | | +| `email` | 이메일 | | +| `tel` | 전화번호 | 자동 포맷 (010-1234-5678) | +| `businessNumber` | 사업자등록번호 | 자동 포맷 (123-45-67890) | +| `textarea` | 여러 줄 텍스트 | | +| `currency` | 금액 입력 | 콤마 자동 포맷 | +| `select` | 드롭다운 선택 | options prop 필요 | +| `date` | 날짜 선택 | DatePicker 연동 | + +### FormField로 대체하지 않는 경우 +- Select, DatePicker 단독 사용 (이미 Label 포함인 경우) +- ImageUpload, FileInput 등 특수 컴포넌트 +- 복합 레이아웃 (주소 검색: 버튼+입력 조합) + +### 비교 + +```typescript +// ✅ FormField 사용 (신규 폼) + handleChange('companyName', value)} +/> + +// ❌ 수동 조합 (신규 폼에서 금지) +
+ + handleChange('companyName', e.target.value)} + /> +
+``` + +--- + +## 4. 기존 폼 패턴 (수정하지 않음) + +```typescript +// useState 기반 — 작동 중이면 건드리지 않음 +const [formData, setFormData] = useState(initialData); + +const handleChange = (field: keyof FormData, value: string) => { + setFormData(prev => ({ ...prev, [field]: value })); +}; + +// react-hook-form rules 기반 + +``` + +--- + +## 5. 서버 검증 에러 처리 + +Laravel에서 반환하는 validation 에러를 폼에 표시: + +```typescript +const result = await saveItem(formData); + +if (!result.success && result.fieldErrors) { + // fieldErrors: { name: ['이름은 필수입니다.'], amount: ['0보다 커야 합니다.'] } + Object.entries(result.fieldErrors).forEach(([field, messages]) => { + setError(field as keyof FormData, { + type: 'server', + message: messages[0], + }); + }); +} +``` + +--- + +## 6. DatePicker 사용 규칙 + +`input type="date"` 대신 커스텀 DatePicker 사용: + +```typescript +import { DatePicker } from '@/components/ui/date-picker'; + + setFormData(prev => ({ ...prev, startDate: date }))} + placeholder="날짜 선택" +/> +``` + +- `value`/`onChange`: string (`yyyy-MM-dd`) +- `minDate`/`maxDate`: Date 객체 (`new Date('2026-01-01')`) +- 테이블 셀: `size="sm"` 사용 +- 한글 로케일, 주말/공휴일 색상 구분 + +--- + +## 7. Radix UI Select 주의사항 + +빈 값('')으로 시작 후 값 변경이 안 되는 버그 → `key` prop으로 해결: + +```typescript +// ✅ key prop으로 강제 리마운트 + +``` diff --git a/frontend/v1/06-styling-guide.md b/frontend/v1/06-styling-guide.md new file mode 100644 index 0000000..d1828ba --- /dev/null +++ b/frontend/v1/06-styling-guide.md @@ -0,0 +1,225 @@ +# 06. 스타일링 가이드 + +> **대상**: 프론트엔드 개발자, 디자이너 +> **버전**: 1.0.0 +> **최종 수정**: 2026-03-09 + +--- + +## 1. 기술 스택 + +| 도구 | 역할 | +|------|------| +| **Tailwind CSS 4** | 유틸리티 클래스 기반 스타일링 | +| **shadcn/ui** | Radix UI 기반 컴포넌트 라이브러리 | +| **CSS Variables** | 테마 토큰 (다크모드 대비) | +| **lucide-react** | 아이콘 | + +--- + +## 2. 기본 규칙 + +### 사용 +```typescript +// ✅ Tailwind 클래스 사용 +
+ +// ✅ shadcn/ui 컴포넌트 +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +``` + +### 금지 +```typescript +// ❌ 인라인 스타일 +
+ +// ❌ CSS 모듈 / styled-components +import styles from './Component.module.css'; + +// ❌ 전역 CSS (globals.css 외) +``` + +--- + +## 3. 레이아웃 패딩 규칙 + +``` +AuthenticatedLayout (
) → 패딩 없음 + └── PageLayout → p-0 md:space-y-6 (패딩 담당) + └── 콘텐츠 영역 +``` + +**핵심**: page.tsx에서 추가 패딩 래퍼 금지 (이중 패딩 방지) + +```typescript +// ✅ 올바름 + + + ... + + +// ❌ 이중 패딩 +
{/* ← 금지 */} + + ... + +
+``` + +--- + +## 4. 간격 시스템 + +| 용도 | 클래스 | 값 | +|------|--------|-----| +| 섹션 간 간격 | `space-y-6` | 24px | +| 카드 내부 간격 | `space-y-4` | 16px | +| 인라인 요소 간격 | `gap-2` | 8px | +| 폼 필드 간격 | `space-y-4` | 16px | +| 그리드 갭 | `gap-4` 또는 `gap-6` | 16px / 24px | + +--- + +## 5. 반응형 패턴 + +```typescript +// 그리드: 모바일 1열 → 데스크톱 2~4열 +
+ +// 숨기기/보이기 +
{/* 데스크톱만 */} +
{/* 모바일만 */} + +// 폰트 크기 반응형 +

+``` + +### 브레이크포인트 + +| 접두사 | 최소 너비 | 대상 | +|--------|----------|------| +| (없음) | 0px | 모바일 | +| `sm:` | 640px | 소형 태블릿 | +| `md:` | 768px | 태블릿 | +| `lg:` | 1024px | 데스크톱 | +| `xl:` | 1280px | 넓은 화면 | + +--- + +## 6. 색상 시스템 + +shadcn/ui CSS 변수 기반 — 다크모드 자동 대응: + +| 용도 | 클래스 | 설명 | +|------|--------|------| +| 배경 | `bg-background` | 페이지 배경 | +| 카드 | `bg-card` | 카드 배경 | +| 강조 | `bg-muted` | 연한 배경 (테이블 호버 등) | +| 텍스트 | `text-foreground` | 기본 텍스트 | +| 보조 텍스트 | `text-muted-foreground` | 설명, 플레이스홀더 | +| 테두리 | `border` | 기본 테두리 | +| 위험 | `text-destructive` | 삭제, 에러 | + +### 상태 색상 + +| 상태 | 텍스트 | 배경 | +|------|--------|------| +| 입금/증가 | `text-blue-600` | `bg-blue-50` | +| 출금/감소 | `text-red-600` | `bg-red-50` | +| 성공/완료 | `text-green-600` | `bg-green-50` | +| 경고/대기 | `text-orange-500` | `bg-orange-50` | +| 비활성 | `text-gray-400` | `bg-gray-50` | + +--- + +## 7. 컴포넌트 스타일 규칙 + +### 버튼 +```typescript +// 주요 액션 + + +// 보조 액션 + + +// 위험 액션 (삭제) + +``` + +### 테이블 +```typescript +// 기본 셀 정렬 + {/* 날짜, 상태, 번호 */} + {/* 금액 */} + {/* 텍스트 (기본) */} + +// 합계 행 + +``` + +### Badge +```typescript +기본 +활성 +에러 +``` + +--- + +## 8. 팝업/모달 규칙 + +| 용도 | 컴포넌트 | 비고 | +|------|---------|------| +| 확인/취소 | AlertDialog (Radix) | `alert()`, `confirm()` 금지 | +| 데이터 입력 | Dialog (Radix) | `prompt()` 금지 | +| 알림 | `toast` (sonner) | 성공/에러 피드백 | +| 검색+선택 | SearchableSelectionModal | 커스텀 Dialog 조합 금지 | + +```typescript +// ✅ 토스트 사용 +import { toast } from 'sonner'; +toast.success('저장되었습니다.'); +toast.error('저장에 실패했습니다.'); + +// ❌ alert 금지 +alert('저장되었습니다.'); +``` + +--- + +## 9. 아이콘 + +**lucide-react** 사용: + +```typescript +import { FileText, Settings, Search, Plus, Trash2 } from 'lucide-react'; + +// 인라인 아이콘 + + +// 버튼 내 아이콘 + +``` + +--- + +## 10. 금액 표시 + +```typescript +import { formatNumber } from '@/lib/utils/amount'; + +formatNumber(1234567) // "1,234,567" +formatNumber(0) // "0" +formatNumber(undefined) // "0" +``` + +테이블에서: +```typescript + + {formatNumber(item.amount)} + +``` diff --git a/frontend/v1/07-auth-flow.md b/frontend/v1/07-auth-flow.md new file mode 100644 index 0000000..1d777a4 --- /dev/null +++ b/frontend/v1/07-auth-flow.md @@ -0,0 +1,137 @@ +# 07. 인증 흐름 + +> **대상**: 프론트엔드/백엔드 개발자 +> **버전**: 1.0.0 +> **최종 수정**: 2026-03-09 + +--- + +## 1. 인증 아키텍처 요약 + +``` +┌─────────────────────────────────────────────────────┐ +│ 브라우저 │ +│ │ +│ [React 컴포넌트] │ +│ │ fetch('/api/proxy/...') │ +│ │ (토큰 전송 안함 — 쿠키가 자동 포함) │ +│ ▼ │ +│ [Next.js API Proxy] │ +│ │ 쿠키에서 access_token 읽기 │ +│ │ Authorization: Bearer {token} 헤더 추가 │ +│ ▼ │ +│ [authenticatedFetch 게이트웨이] │ +│ │ 401 → refresh → retry (자동) │ +│ ▼ │ +│ [PHP Laravel Backend] │ +│ │ +│ 쿠키: │ +│ ├── access_token (HttpOnly, 2시간) │ +│ ├── refresh_token (HttpOnly, 7일) │ +│ └── is_authenticated (non-HttpOnly, 상태 확인용) │ +└─────────────────────────────────────────────────────┘ +``` + +--- + +## 2. 왜 HttpOnly 쿠키? + +| 방식 | XSS 취약 | 토큰 갱신 | SAM 채택 | +|------|----------|----------|---------| +| localStorage | **취약** (JS 접근 가능) | 직접 구현 | ❌ | +| 일반 쿠키 | **취약** (JS 접근 가능) | 직접 구현 | ❌ | +| **HttpOnly 쿠키** | **안전** (JS 접근 불가) | 프록시에서 처리 | **✅** | + +- JavaScript로 토큰을 읽을 수 없음 → XSS 공격에 안전 +- 대신 서버(Next.js 프록시)에서만 읽기 가능 → 프록시 패턴 필수 + +--- + +## 3. 로그인 흐름 + +``` +1. 사용자 → 로그인 폼 입력 (email, password) +2. 프론트 → POST /api/proxy/auth/login { email, password } +3. 프록시 → POST /api/v1/auth/login (Laravel) +4. Laravel → { access_token, refresh_token, expires_in } +5. 프록시 → Set-Cookie 3개 설정: + - access_token=xxx; HttpOnly; Max-Age=7200 + - refresh_token=xxx; HttpOnly; Max-Age=604800 + - is_authenticated=true; Max-Age=7200 +6. 프론트 → 대시보드로 이동 +``` + +--- + +## 4. API 호출 시 토큰 흐름 + +``` +1. 컴포넌트 → Server Action 호출 (또는 fetch /api/proxy/...) +2. Server Action → serverFetch → authenticatedFetch + - 쿠키에서 access_token 읽기 + - Authorization: Bearer {token} 헤더 추가 +3. 백엔드 응답: + - 200 OK → 정상 처리 + - 401 → 토큰 만료 (아래 갱신 흐름) +``` + +--- + +## 5. 토큰 갱신 (자동) + +``` +1. API 호출 → 401 Unauthorized 응답 +2. authenticatedFetch 감지: + a. refresh_token으로 POST /api/v1/auth/refresh + b. 새 access_token 발급 + c. 새 토큰으로 원래 요청 재시도 (1회) +3. 결과: + - 재시도 성공 → 정상 응답 + 새 쿠키 설정 + - 재시도 실패 → 쿠키 삭제 + 로그인 페이지 이동 +``` + +**중요**: 동시에 여러 요청이 401을 받으면, refresh는 1번만 실행 (globalThis 캐싱으로 중복 방지) + +--- + +## 6. 미들웨어 (middleware.ts) + +요청 단계에서의 인증 검사 (토큰 갱신과는 별개): + +``` +요청 들어옴 + ↓ +1. 내부 요청 필터링 (_next/*) +2. IE 브라우저 차단 +3. 다국어 처리 (ko, en) +4. /dev/ 경로 프로덕션 차단 +5. 봇 탐지 (40+ 패턴 차단) +6. API/정적 파일 통과 +7. 인증 확인: + - 비인증 사용자 → 로그인 페이지 리다이렉트 + - 인증된 사용자가 로그인 페이지 접근 → 대시보드 리다이렉트 +8. 보안 헤더 설정 (X-Robots-Tag, CSP 등) +``` + +--- + +## 7. 프론트엔드 개발자 체크리스트 + +| 항목 | 설명 | +|------|------| +| 직접 토큰 관리 금지 | localStorage/sessionStorage에 토큰 저장 ❌ | +| fetch 직접 호출 금지 | Server Action 또는 `/api/proxy/` 경로만 사용 | +| 인증 상태 확인 | `is_authenticated` 쿠키 (non-HttpOnly) 또는 useAuthGuard() | +| 401 처리 | authenticatedFetch가 자동 처리 — 수동 처리 불필요 | + +--- + +## 8. 백엔드 개발자 참고 + +| 항목 | 내용 | +|------|------| +| 인증 방식 | Bearer Token (Authorization 헤더) | +| 토큰 발급 | POST /api/v1/auth/login | +| 토큰 갱신 | POST /api/v1/auth/refresh | +| 401 응답 시 | 프론트가 자동 refresh → retry | +| API Key | `X-API-KEY` 헤더 (환경변수: `API_KEY`) | diff --git a/frontend/v1/08-dashboard-system.md b/frontend/v1/08-dashboard-system.md new file mode 100644 index 0000000..40365a9 --- /dev/null +++ b/frontend/v1/08-dashboard-system.md @@ -0,0 +1,183 @@ +# 08. CEO 대시보드 시스템 + +> **대상**: 프론트엔드/백엔드 개발자 +> **버전**: 1.0.0 +> **최종 수정**: 2026-03-09 + +--- + +## 1. 아키텍처 개요 + +CEO 대시보드는 20개 섹션으로 구성된 실시간 경영 현황 화면. + +``` +CEODashboard.tsx +├── useCEODashboard() # 12개 섹션 API 통합 Hook +├── useEntertainment() # 접대비 독립 Hook +├── useWelfare() # 복리후생비 독립 Hook +├── useTodayIssue() # 금일 이슈 Hook +├── useCalendar() # 캘린더 Hook +├── useVat() # 부가세 Hook +├── SummaryNavBar # 섹션 바로가기 네비게이션 +├── DashboardSettingsDialog # 섹션 표시/순서 설정 +├── DetailModal # 상세 모달 (공통) +└── sections/ # 20개 섹션 컴포넌트 + ├── DailyReportSection + ├── MonthlyExpenseSection + ├── EntertainmentSection + ├── WelfareSection + └── ... (17개 더) +``` + +--- + +## 2. 섹션 목록 + +| 섹션 | API Hook | 데이터 소스 | +|------|----------|------------| +| 일일일보 | useCEODashboard.dailyReport | sam_stat 캐시 | +| 현황보드 | useCEODashboard.statusBoard | 실시간 집계 | +| 당월예상지출 | useCEODashboard.monthlyExpense | 예상경비 테이블 | +| 카드/가지급금 | useCEODashboard.cardManagement | 가지급금 테이블 | +| 매출채권 | useCEODashboard.receivable | 매출/입금 | +| 채권회수 | useCEODashboard.debtCollection | 부실채권 | +| 매출현황 | useCEODashboard.salesStatus | 매출 통계 | +| 매입현황 | useCEODashboard.purchaseStatus | 매입 통계 | +| 일일생산 | useCEODashboard.dailyProduction | 생산실적 | +| 미출하 | useCEODashboard.unshipped | 출하 대기 | +| 공사현황 | useCEODashboard.construction | 공사 진행 | +| 근태현황 | useCEODashboard.dailyAttendance | 근태 데이터 | +| **접대비** | **useEntertainment()** | **expense_accounts** | +| **복리후생비** | **useWelfare()** | **expense_accounts** | +| 금일이슈 | useTodayIssue() | 이슈 목록 | +| 부가세 | useVat() | 부가세 신고 | +| 캘린더 | useCalendar() | 일정 | +| 출하현황 | useCEODashboard.dailyProduction | 출하 실적 | + +--- + +## 3. Invalidation 시스템 + +다른 화면에서 CUD 발생 시 대시보드 데이터 자동 갱신. + +### 흐름 + +``` +[입금관리에서 입금 등록] + ↓ +invalidateDashboard('deposit') + ↓ +DOMAIN_SECTION_MAP에서 영향 섹션 조회 + deposit → ['dailyReport', 'receivable'] + ↓ +1. sessionStorage에 stale 섹션 저장 +2. CustomEvent 발행 + ↓ +[대시보드가 마운트 중이면] + → 즉시 해당 섹션만 refetch +[대시보드 비마운트 상태면] + → 다음 방문 시 stale 섹션 refetch +``` + +### 도메인 → 섹션 매핑 + +| 도메인 | 영향 섹션 | +|--------|----------| +| `deposit` | dailyReport, receivable | +| `withdrawal` | dailyReport, monthlyExpense | +| `sales` | dailyReport, salesStatus, receivable | +| `purchase` | dailyReport, purchaseStatus, monthlyExpense | +| `badDebt` | debtCollection, receivable | +| `expectedExpense` | monthlyExpense | +| `bill` | dailyReport, receivable | +| `giftCertificate` | entertainment, cardManagement | +| `journalEntry` | entertainment, welfare, monthlyExpense | + +### 사용법 (CUD 완료 후) + +```typescript +import { invalidateDashboard } from '@/lib/dashboard-invalidation'; + +// 입금 등록 성공 후 +const handleSuccess = () => { + loadData(); + invalidateDashboard('deposit'); +}; + +// 전표 등록 성공 후 +const handleJournalSuccess = () => { + loadData(); + invalidateDashboard('journalEntry'); +}; +``` + +### 새 도메인 추가 시 + +`src/lib/dashboard-invalidation.ts`에서: +1. `DomainKey`에 새 도메인 추가 +2. `DOMAIN_SECTION_MAP`에 영향 섹션 매핑 추가 +3. 해당 도메인의 CUD 콜백에서 `invalidateDashboard('newDomain')` 호출 + +--- + +## 4. 사용자 설정 + +### 섹션 표시/숨김 + 순서 + +```typescript +// localStorage에 저장 +const settings: DashboardSettings = { + dailyReport: true, + monthlyExpense: true, + entertainment: { enabled: true, companyType: 'medium' }, + welfare: { enabled: true, calculationType: 'monthly' }, + sectionOrder: ['dailyReport', 'statusBoard', 'monthlyExpense', ...], +}; +localStorage.setItem('ceo-dashboard-settings', JSON.stringify(settings)); +``` + +### 순서 변경 +- DashboardSettingsDialog에서 드래그 앤 드롭 또는 순서 변경 +- `sectionOrder` 배열로 관리 + +--- + +## 5. expense_accounts 동기화 + +접대비/복리후생비 대시보드 카드의 데이터 소스는 `expense_accounts` 테이블. + +### 현재 동기화 경로 + +| 입력 경로 | expense_accounts 반영 | +|----------|----------------------| +| 일반전표 (수기전표) | **반영됨** — syncExpenseAccounts() | +| 가지급금 상품권 | **반영됨** — LoanService | +| 출금관리/카드사용내역 | **확인 중** | +| 세금계산서 | **확인 중** | +| 예상경비 | **확인 중** | + +### 동기화 원리 +- 전표/거래에서 계정과목명에 "복리후생비" 또는 "접대비"가 포함되면 +- `expense_accounts` 테이블에 자동 INSERT +- CUD 시 delete-then-insert 전략 (정확한 추적) +- `journal_entry_id`, `journal_entry_line_id`로 원본 추적 가능 + +--- + +## 6. 백엔드 참고 + +### 대시보드 관련 API + +| 엔드포인트 | 용도 | +|-----------|------| +| GET /api/v1/daily-report/summary | 일일일보 요약 | +| GET /api/v1/monthly-expense/summary | 당월 예상 지출 | +| GET /api/v1/entertainments/summary | 접대비 리스크 카드 | +| GET /api/v1/welfares/summary | 복리후생비 리스크 카드 | +| GET /api/v1/card-management/summary | 카드/가지급금 | +| GET /api/v1/receivable/summary | 매출채권 | + +### sam_stat 캐시 +- 일부 대시보드 API는 5분 캐시 (sam_stat 테이블) +- 실시간이 아닌 근사치 데이터 +- invalidation은 프론트 refetch → 백엔드가 캐시 갱신 여부 판단 diff --git a/frontend/v1/09-conventions.md b/frontend/v1/09-conventions.md new file mode 100644 index 0000000..fe48194 --- /dev/null +++ b/frontend/v1/09-conventions.md @@ -0,0 +1,266 @@ +# 09. 코딩 컨벤션 + +> **대상**: 프론트엔드 개발자 +> **버전**: 1.0.0 +> **최종 수정**: 2026-03-09 + +--- + +## 1. 네이밍 규칙 + +### 파일/폴더 + +| 대상 | 규칙 | 예시 | +|------|------|------| +| 컴포넌트 파일 | PascalCase | `BillManagement.tsx` | +| 컴포넌트 폴더 | PascalCase | `BillManagement/` | +| 유틸리티 | camelCase | `query-params.ts`, `amount.ts` | +| 타입 파일 | camelCase | `types.ts` | +| Server Action | camelCase | `actions.ts` | +| 훅 | camelCase (use 접두사) | `useCEODashboard.ts` | +| 페이지 라우트 | kebab-case | `general-journal-entry/page.tsx` | + +### 변수/함수 + +| 대상 | 규칙 | 예시 | +|------|------|------| +| 컴포넌트 | PascalCase | `function BillManagement()` | +| 함수 | camelCase | `handleSave`, `loadData` | +| 이벤트 핸들러 | handle 접두사 | `handleClick`, `handleSubmit` | +| 콜백 prop | on 접두사 | `onSave`, `onChange` | +| boolean | is/has/show 접두사 | `isLoading`, `hasError`, `showModal` | +| 상수 | UPPER_SNAKE_CASE | `PERIOD_BUTTONS`, `TYPE_WELFARE` | +| 타입/인터페이스 | PascalCase | `BillRecord`, `SearchParams` | + +--- + +## 2. 파일 배치 규칙 + +### 도메인 컴포넌트 구조 + +``` +src/components/accounting/BillManagement/ +├── index.tsx # 메인 컴포넌트 (export) +├── actions.ts # Server Actions ('use server') +├── types.ts # 타입 정의 +├── BillDetail.tsx # 하위 컴포넌트 +├── BillForm.tsx # 폼 컴포넌트 +└── schema.ts # Zod 스키마 (선택) +``` + +### 배치 원칙 + +| 파일 | 위치 | +|------|------| +| 비즈니스 로직 컴포넌트 | `src/components/{domain}/{ComponentName}/` | +| 공통 UI 컴포넌트 | `src/components/ui/` (shadcn/ui) | +| 공통 조합 컴포넌트 | `src/components/organisms/` | +| 도메인 공통 | `src/components/{domain}/common/` | +| 전체 공통 | `src/components/common/` | +| API 유틸 | `src/lib/api/` | +| 범용 유틸 | `src/lib/utils/` | +| 훅 | `src/hooks/` | +| 전역 타입 | `src/types/` | + +--- + +## 3. Import 규칙 + +### 순서 + +```typescript +// 1. React/Next.js +import { useState, useCallback } from 'react'; +import { useRouter } from 'next/navigation'; + +// 2. 외부 라이브러리 +import { toast } from 'sonner'; +import { z } from 'zod'; + +// 3. UI 컴포넌트 (@/components/ui) +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; + +// 4. 공통 컴포넌트 (@/components/organisms, molecules, atoms) +import { PageLayout } from '@/components/organisms/PageLayout'; +import { FormField } from '@/components/molecules/FormField'; + +// 5. 도메인 컴포넌트/액션 +import { getBills } from './actions'; +import { BillDetail } from './BillDetail'; + +// 6. 유틸/훅/타입 +import { formatNumber } from '@/lib/utils/amount'; +import type { BillRecord } from './types'; +``` + +### 경로 별칭 +- `@/` = `src/` (tsconfig paths) +- 항상 `@/` 별칭 사용, 상대 경로(`../../`)는 같은 폴더 내에서만 + +--- + +## 4. 컴포넌트 패턴 + +### 페이지 컴포넌트 (page.tsx) + +```typescript +// 얇은 껍데기만 — 비즈니스 로직은 도메인 컴포넌트에 +'use client'; +import { BillManagement } from '@/components/accounting/BillManagement'; + +export default function BillsPage() { + return ; +} +``` + +### 도메인 컴포넌트 (index.tsx) + +```typescript +'use client'; + +import { useState, useCallback, useEffect } from 'react'; +// ... imports + +export function BillManagement() { + // 1. 상태 선언 + const [data, setData] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + // 2. 데이터 로드 + const loadData = useCallback(async () => { ... }, [deps]); + useEffect(() => { loadData(); }, [loadData]); + + // 3. 이벤트 핸들러 + const handleSave = useCallback(async () => { ... }, [deps]); + const handleDelete = useCallback(async () => { ... }, [deps]); + + // 4. 계산값 (useMemo) + const config = useMemo(() => ({ ... }), [deps]); + + // 5. 렌더링 + return ; +} +``` + +--- + +## 5. TypeScript 규칙 + +### 타입 정의 + +```typescript +// ✅ 컴포넌트 props는 interface (확장 가능) +interface BillDetailProps { + record: BillRecord; + onSave: (data: BillFormData) => Promise; +} + +// ✅ 데이터 모델은 type (유니온/인터섹션 활용) +type BillStatus = 'draft' | 'confirmed' | 'paid'; + +// ✅ API 응답 변환 +interface BillApiResponse { ... } // 백엔드 스네이크_케이스 +interface BillRecord { ... } // 프론트 camelCase +function transformBillApiToFrontend(api: BillApiResponse): BillRecord { ... } +``` + +### 금지 패턴 + +```typescript +// ❌ any 사용 금지 +const data: any = response; + +// ❌ as 캐스트 지양 (Zod 사용 시 불필요) +const value = input as string; + +// ❌ non-null assertion 지양 +const name = user!.name; +``` + +--- + +## 6. 상태 관리 규칙 + +| 범위 | 방법 | +|------|------| +| 컴포넌트 내부 | useState, useReducer | +| 형제 간 공유 | 부모에서 prop 전달 | +| 전역 인증 | useAuthGuard (Context) | +| 서버 데이터 | Server Action + useState | +| 대시보드 갱신 | dashboard-invalidation (CustomEvent) | + +**사용하지 않는 것**: Redux, Zustand, Recoil 등 전역 상태 라이브러리 + +--- + +## 7. 에러 처리 규칙 + +```typescript +// Server Action 결과 처리 +const result = await saveItem(formData); +if (result.success) { + toast.success('저장되었습니다.'); + loadData(); +} else { + toast.error(result.error || '저장에 실패했습니다.'); +} + +// try-catch (Server Action 호출 자체의 에러) +try { + const result = await getItems(); + if (result.success) setData(result.data); +} catch { + toast.error('서버 오류가 발생했습니다.'); +} +``` + +--- + +## 8. 성능 규칙 + +| 규칙 | 이유 | +|------|------| +| `useMemo`로 config 객체 감싸기 | 불필요한 리렌더 방지 | +| `useCallback`으로 핸들러 감싸기 | 자식 컴포넌트 리렌더 방지 | +| 무거운 컴포넌트 `React.memo` | 부모 리렌더 시 불필요한 재계산 방지 | +| 대시보드 섹션 LazySection | Intersection Observer 기반 지연 로딩 | +| 이미지 `next/image` 사용 | 자동 최적화 | + +--- + +## 9. Git 커밋 메시지 + +``` +[타입]: 작업내용 (한글) + +타입: +- feat: 신규 기능 +- fix: 버그 수정 +- refactor: 리팩토링 +- chore: 설정/빌드 +- style: 포맷팅 +- docs: 문서 +``` + +예시: +``` +feat: CEO 대시보드 접대비 섹션 API 연동 +fix: 어음관리 날짜 필터 오류 수정 +refactor: 계정과목 설정 모달 공통화 +``` + +--- + +## 10. 빠른 참조 + +| 상황 | 해야 할 것 | +|------|-----------| +| 새 리스트 페이지 | UniversalListPage + actions.ts + types.ts | +| 새 폼 | Zod 스키마 + FormField + react-hook-form | +| 모달 필요 | SearchableSelectionModal 먼저 확인 | +| API 호출 | Server Action → buildApiUrl → executeServerAction | +| 토스트 알림 | `toast.success()` / `toast.error()` | +| 날짜 입력 | DatePicker (input type="date" 금지) | +| 대시보드 갱신 | `invalidateDashboard('domain')` | +| 금액 표시 | `formatNumber()` | diff --git a/frontend/v1/10-document-api-integration.md b/frontend/v1/10-document-api-integration.md new file mode 100644 index 0000000..c185dcf --- /dev/null +++ b/frontend/v1/10-document-api-integration.md @@ -0,0 +1,1049 @@ +# 문서 API 연동 가이드 + +> **버전:** 1.0.0 +> **최종 수정:** 2026-02-05 +> **담당:** API Team + +--- + +## 1. 개요 + +SAM 시스템의 문서 관리(검사 성적서 등) API를 React 프론트엔드와 연동하는 방법을 설명합니다. + +### 1.1 역할 분리 + +| 시스템 | 역할 | 사용자 | +|--------|------|--------| +| **MNG** | 양식 생성/관리, 문서 조회/출력 | 본사 관리자 | +| **API** | 문서 CRUD, 결재 워크플로우 | 시스템 | +| **React** | 문서 작성/수정 UI | 현장 작업자 | + +### 1.2 전체 플로우 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 시스템 흐름도 │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ +│ │ MNG │ │ API │ │ React │ │ DB │ │ +│ │ (본사) │ │ Server │ │ (현장) │ │ │ │ +│ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ │ +│ │ │ │ │ │ +│ │ 1. 양식 생성/관리 │ │ │ │ +│ │──────────────────>│ │ │ │ +│ │ │──────────────────────────────────────>│ │ +│ │ │ │ │ │ +│ │ │ 2. resolve │ │ │ +│ │ │<──────────────────│ (category+item_id)│ │ +│ │ │──────────────────────────────────────>│ │ +│ │ │<──────────────────────────────────────│ │ +│ │ │ 3. 템플릿+문서 │ │ │ +│ │ │──────────────────>│ │ │ +│ │ │ │ │ │ +│ │ │ │ 4. 사용자 입력 │ │ +│ │ │ │ (측정값, 판정) │ │ +│ │ │ │ │ │ +│ │ │ 5. upsert │ │ │ +│ │ │<──────────────────│ │ │ +│ │ │──────────────────────────────────────>│ │ +│ │ │ │ │ │ +│ │ 6. 문서 조회/출력 │ │ │ │ +│ │<──────────────────│ │ │ │ +│ │ │ │ │ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 2. API 엔드포인트 + +### 2.1 Document Resolve (문서 조회/생성 준비) + +품목(item_id)과 문서 분류(category)를 기반으로 해당하는 템플릿과 기존 문서를 조회합니다. + +#### 엔드포인트 + +``` +GET /api/v1/documents/resolve +``` + +#### Request + +**Headers:** +```http +Content-Type: application/json +x-api-key: {API_KEY} +Authorization: Bearer {TOKEN} +``` + +**Query Parameters:** + +| 파라미터 | 타입 | 필수 | 설명 | 예시 | +|---------|------|:----:|------|------| +| `category` | string | ✅ | 문서 분류 코드 | `incoming_inspection` | +| `item_id` | integer | ✅ | 품목 ID | `14172` | + +**category 값 (common_codes 기반):** + +| code | name | 설명 | +|------|------|------| +| `incoming_inspection` | 수입검사 | 자재 입고 시 검사 | +| `quality_inspection` | 품질검사 | 중간/공정 검사 | +| `outgoing_inspection` | 출하검사 | 제품 출하 전 검사 | + +#### Response - 신규 문서 (is_new: true) + +```json +{ + "success": true, + "message": "조회 성공", + "data": { + "is_new": true, + "template": { + "id": 18, + "name": "EGI 수입검사 (두께별 자동매칭)", + "category": "수입검사", + "title": "수입검사 성적서", + "company_name": "케이디산업", + "company_address": null, + "company_contact": null, + "footer_remark_label": "부적합 내용", + "footer_judgement_label": "종합판정", + "footer_judgement_options": ["합격", "불합격", "조건부합격"], + "approval_lines": [ + {"id": 1, "role": "작성", "user_id": null, "sort_order": 0}, + {"id": 2, "role": "검토", "user_id": null, "sort_order": 1}, + {"id": 3, "role": "승인", "user_id": null, "sort_order": 2} + ], + "basic_fields": [ + { + "id": 1, + "field_key": "lot_no", + "label": "LOT No", + "input_type": "text", + "options": null, + "default_value": null, + "is_required": true, + "sort_order": 0 + } + ], + "section_fields": [ + {"id": 1, "field_key": "category", "label": "구분", "field_type": "text", "width": "65px", "is_required": false}, + {"id": 2, "field_key": "item", "label": "검사항목", "field_type": "text", "width": "130px", "is_required": true}, + {"id": 3, "field_key": "standard", "label": "검사기준", "field_type": "text", "width": "180px", "is_required": false}, + {"id": 4, "field_key": "standard_criteria", "label": "기준범위", "field_type": "json_criteria", "width": "100px", "is_required": false}, + {"id": 5, "field_key": "tolerance", "label": "공차/범위", "field_type": "json_tolerance", "width": "120px", "is_required": false}, + {"id": 6, "field_key": "method", "label": "검사방식", "field_type": "select_api", "width": "110px", "is_required": false}, + {"id": 7, "field_key": "measurement_type", "label": "측정유형", "field_type": "select", "width": "100px", "is_required": false} + ], + "sections": [ + { + "id": 1, + "name": "검사 항목", + "sort_order": 0, + "items": [ + { + "id": 307, + "field_values": { + "category": "", + "item": "겉모양", + "standard": "사용상 해로울 결함이 없을 것", + "method": "visual", + "measurement_type": "checkbox" + }, + "standard_criteria": null, + "tolerance": null, + "sort_order": 0 + }, + { + "id": 308, + "field_values": { + "category": "치수", + "item": "두께", + "standard": null, + "method": "check", + "measurement_type": "numeric" + }, + "standard_criteria": {"min": 0.8, "min_op": "gte", "max": 1.0, "max_op": "lt"}, + "tolerance": {"type": "symmetric", "value": "0.07"}, + "sort_order": 1 + } + ] + } + ], + "columns": [ + {"id": 1, "label": "측정1", "input_type": "text", "width": "60px", "is_required": false}, + {"id": 2, "label": "측정2", "input_type": "text", "width": "60px", "is_required": false}, + {"id": 3, "label": "측정3", "input_type": "text", "width": "60px", "is_required": false}, + {"id": 4, "label": "판정", "input_type": "select", "width": "60px", "is_required": true} + ] + }, + "document": null, + "item": { + "id": 14172, + "code": "20000", + "name": "sus1.2*1219*2438", + "attributes": { + "thickness": 1.2, + "width": 1219, + "length": 2438, + "spec": " ", + "item_div": "[원재료]" + } + } + } +} +``` + +#### Response - 기존 문서 (is_new: false) + +```json +{ + "success": true, + "message": "조회 성공", + "data": { + "is_new": false, + "template": { ... }, + "document": { + "id": 7, + "document_no": "DOC-20260205-0001", + "title": "수입검사 성적서 - EGI 1.2T", + "status": "DRAFT", + "linkable_type": "item", + "linkable_id": 14172, + "submitted_at": null, + "completed_at": null, + "created_at": "2026-02-05T12:41:35.000000Z", + "data": [ + {"section_id": 1, "column_id": 1, "row_index": 0, "field_key": "measurement_1", "field_value": "1.21"}, + {"section_id": 1, "column_id": 2, "row_index": 0, "field_key": "measurement_2", "field_value": "1.20"}, + {"section_id": 1, "column_id": 3, "row_index": 0, "field_key": "measurement_3", "field_value": "1.22"}, + {"section_id": 1, "column_id": 4, "row_index": 0, "field_key": "judgement", "field_value": "합격"} + ], + "attachments": [], + "approvals": [] + }, + "item": { ... } + } +} +``` + +#### Error Responses + +| HTTP 코드 | 에러 메시지 | 원인 | +|:---------:|------------|------| +| 400 | 유효하지 않은 문서 분류입니다 | category가 common_codes에 없음 | +| 404 | 해당 조건에 맞는 문서 양식을 찾을 수 없습니다 | 해당 category+item_id에 연결된 템플릿 없음 | +| 404 | 품목 정보를 찾을 수 없습니다 | item_id가 존재하지 않거나 다른 테넌트 | + +--- + +### 2.2 Document Upsert (문서 저장) + +문서를 저장합니다. 기존 문서(DRAFT/REJECTED 상태)가 있으면 UPDATE, 없으면 CREATE. + +#### 엔드포인트 + +``` +POST /api/v1/documents/upsert +``` + +#### Request + +**Headers:** +```http +Content-Type: application/json +x-api-key: {API_KEY} +Authorization: Bearer {TOKEN} +``` + +**Body:** +```json +{ + "template_id": 18, + "item_id": 14172, + "title": "수입검사 성적서 - EGI 1.2T", + "data": [ + {"section_id": 1, "column_id": 1, "row_index": 0, "field_key": "measurement_1", "field_value": "1.21"}, + {"section_id": 1, "column_id": 2, "row_index": 0, "field_key": "measurement_2", "field_value": "1.20"}, + {"section_id": 1, "column_id": 3, "row_index": 0, "field_key": "measurement_3", "field_value": "1.22"}, + {"section_id": 1, "column_id": 4, "row_index": 0, "field_key": "judgement", "field_value": "합격"}, + {"section_id": 1, "column_id": 1, "row_index": 1, "field_key": "measurement_1", "field_value": "1220"}, + {"section_id": 1, "column_id": 4, "row_index": 1, "field_key": "judgement", "field_value": "합격"} + ], + "attachments": [ + {"file_id": 123, "attachment_type": "certificate", "description": "Mill Sheet"} + ] +} +``` + +**Body Parameters:** + +| 파라미터 | 타입 | 필수 | 설명 | +|---------|------|:----:|------| +| `template_id` | integer | ✅ | 템플릿 ID | +| `item_id` | integer | ✅ | 품목 ID | +| `title` | string | ❌ | 문서 제목 (없으면 기존 유지 또는 빈 값) | +| `data` | array | ❌ | 문서 데이터 배열 | +| `data[].section_id` | integer | ❌ | 섹션 ID | +| `data[].column_id` | integer | ❌ | 컬럼 ID | +| `data[].row_index` | integer | ❌ | 행 인덱스 (0부터 시작) | +| `data[].field_key` | string | ✅* | 필드 키 (*data가 있으면 필수) | +| `data[].field_value` | string | ❌ | 필드 값 | +| `attachments` | array | ❌ | 첨부파일 배열 | +| `attachments[].file_id` | integer | ✅* | 파일 ID (*attachments가 있으면 필수) | +| `attachments[].attachment_type` | string | ❌ | 첨부유형 | +| `attachments[].description` | string | ❌ | 설명 | + +#### Response - 성공 + +```json +{ + "success": true, + "message": "저장 성공", + "data": { + "id": 7, + "tenant_id": 287, + "template_id": 18, + "document_no": "DOC-20260205-0001", + "title": "수입검사 성적서 - EGI 1.2T", + "status": "DRAFT", + "linkable_type": "item", + "linkable_id": 14172, + "submitted_at": null, + "completed_at": null, + "created_by": 1, + "updated_by": 1, + "created_at": "2026-02-05", + "updated_at": "2026-02-05", + "template": { + "id": 18, + "name": "EGI 수입검사 (두께별 자동매칭)", + "category": "수입검사" + }, + "approvals": [], + "data": [...], + "attachments": [], + "creator": { + "id": 1, + "name": "홍길동" + } + } +} +``` + +--- + +## 3. React 연동 가이드 + +### 3.1 TypeScript 타입 정의 + +```typescript +// types/document.ts + +export interface DocumentResolveResponse { + is_new: boolean; + template: DocumentTemplate; + document: Document | null; + item: Item; +} + +export interface DocumentTemplate { + id: number; + name: string; + category: string; + title: string | null; + company_name: string | null; + company_address: string | null; + company_contact: string | null; + footer_remark_label: string; + footer_judgement_label: string; + footer_judgement_options: string[] | null; + approval_lines: ApprovalLine[]; + basic_fields: BasicField[]; + section_fields: SectionField[]; + sections: Section[]; + columns: Column[]; +} + +export interface Section { + id: number; + name: string; + sort_order: number; + items: SectionItem[]; +} + +export interface SectionItem { + id: number; + field_values: Record; + standard_criteria: StandardCriteria | null; + tolerance: Tolerance | null; + sort_order: number; +} + +export interface StandardCriteria { + min: number | null; + min_op: 'gt' | 'gte' | null; + max: number | null; + max_op: 'lt' | 'lte' | null; +} + +export interface Tolerance { + type: 'symmetric' | 'asymmetric' | 'range' | 'percentage'; + value?: string; + plus?: string; + minus?: string; + min?: string; + max?: string; +} + +export interface Document { + id: number; + document_no: string; + title: string; + status: DocumentStatus; + linkable_type: string; + linkable_id: number; + submitted_at: string | null; + completed_at: string | null; + created_at: string; + data: DocumentData[]; + attachments: DocumentAttachment[]; + approvals: DocumentApproval[]; +} + +export type DocumentStatus = 'DRAFT' | 'PENDING' | 'APPROVED' | 'REJECTED' | 'CANCELLED'; + +export interface DocumentData { + section_id: number | null; + column_id: number | null; + row_index: number; + field_key: string; + field_value: string | null; +} + +export interface Item { + id: number; + code: string; + name: string; + attributes: ItemAttributes | null; +} + +export interface ItemAttributes { + thickness?: number; + width?: number; + length?: number; + [key: string]: any; +} +``` + +### 3.2 Custom Hook + +```typescript +// hooks/useDocument.ts + +import { useState, useEffect, useCallback } from 'react'; +import { api } from '@/lib/api'; +import type { DocumentResolveResponse, DocumentData } from '@/types/document'; + +interface UseDocumentOptions { + category: string; + itemId: number; +} + +interface UseDocumentReturn { + data: DocumentResolveResponse | null; + loading: boolean; + error: string | null; + save: (formData: SaveDocumentPayload) => Promise; + refresh: () => Promise; +} + +interface SaveDocumentPayload { + title?: string; + data: DocumentData[]; + attachments?: { file_id: number; attachment_type?: string; description?: string }[]; +} + +export function useDocument({ category, itemId }: UseDocumentOptions): UseDocumentReturn { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchDocument = useCallback(async () => { + if (!category || !itemId) return; + + try { + setLoading(true); + setError(null); + const response = await api.get('/documents/resolve', { + params: { category, item_id: itemId } + }); + setData(response.data.data); + } catch (err: any) { + const message = err.response?.data?.message || '문서 조회에 실패했습니다.'; + setError(message); + setData(null); + } finally { + setLoading(false); + } + }, [category, itemId]); + + useEffect(() => { + fetchDocument(); + }, [fetchDocument]); + + const save = useCallback(async (formData: SaveDocumentPayload) => { + if (!data?.template.id) { + throw new Error('템플릿 정보가 없습니다.'); + } + + const response = await api.post('/documents/upsert', { + template_id: data.template.id, + item_id: itemId, + title: formData.title, + data: formData.data, + attachments: formData.attachments + }); + + // 저장 후 데이터 갱신 + await fetchDocument(); + + return response.data; + }, [data?.template.id, itemId, fetchDocument]); + + return { + data, + loading, + error, + save, + refresh: fetchDocument + }; +} +``` + +### 3.3 검사 폼 컴포넌트 예제 + +```tsx +// components/InspectionForm.tsx + +import { useState, useEffect, useMemo } from 'react'; +import { useDocument } from '@/hooks/useDocument'; +import type { SectionItem, DocumentData, ItemAttributes, StandardCriteria } from '@/types/document'; + +interface Props { + category: string; + itemId: number; + onSaveSuccess?: () => void; +} + +export function InspectionForm({ category, itemId, onSaveSuccess }: Props) { + const { data, loading, error, save } = useDocument({ category, itemId }); + const [formData, setFormData] = useState>({}); + const [saving, setSaving] = useState(false); + + // 기존 문서 데이터로 폼 초기화 + useEffect(() => { + if (data?.document?.data) { + const initialData: Record = {}; + data.document.data.forEach(d => { + const key = makeFieldKey(d.section_id, d.row_index, d.field_key); + initialData[key] = d.field_value || ''; + }); + setFormData(initialData); + } else { + setFormData({}); + } + }, [data]); + + // 품목 속성 기반 자동 하이라이트 + const highlightedRows = useMemo(() => { + if (!data?.item.attributes || !data.template.sections) { + return new Set(); + } + + const highlighted = new Set(); + data.template.sections.forEach(section => { + section.items.forEach(item => { + if (shouldHighlight(item, data.item.attributes!)) { + highlighted.add(item.id); + } + }); + }); + + return highlighted; + }, [data]); + + const handleInputChange = (sectionId: number, rowIndex: number, fieldKey: string, value: string) => { + const key = makeFieldKey(sectionId, rowIndex, fieldKey); + setFormData(prev => ({ ...prev, [key]: value })); + }; + + const handleSubmit = async () => { + if (!data) return; + + try { + setSaving(true); + + // formData를 API 형식으로 변환 + const documentData: DocumentData[] = []; + Object.entries(formData).forEach(([key, value]) => { + const [sectionId, rowIndex, fieldKey] = parseFieldKey(key); + if (value) { + documentData.push({ + section_id: sectionId, + column_id: null, + row_index: rowIndex, + field_key: fieldKey, + field_value: value + }); + } + }); + + await save({ + title: `${data.template.name} - ${data.item.name}`, + data: documentData + }); + + onSaveSuccess?.(); + alert('저장되었습니다.'); + } catch (err: any) { + alert(err.response?.data?.message || '저장에 실패했습니다.'); + } finally { + setSaving(false); + } + }; + + if (loading) return
로딩 중...
; + if (error) return
에러: {error}
; + if (!data) return null; + + return ( +
+ {/* 헤더 정보 */} +
+

{data.template.name}

+
+ 품목: {data.item.name} ({data.item.code}) + + 상태: {data.is_new ? ( + 신규 작성 + ) : ( + 기존 문서 ({data.document?.document_no}) + )} + +
+ {data.item.attributes && ( +
+ 연결 품목 규격: + t={data.item.attributes.thickness} + w={data.item.attributes.width} + l={data.item.attributes.length} +
+ )} +
+ + {/* 검사 항목 테이블 */} + {data.template.sections.map(section => ( +
+

{section.name}

+ + + + {data.template.section_fields.map(field => ( + + ))} + {data.template.columns.map(col => ( + + ))} + + + + {section.items.map((item, rowIndex) => ( + + {/* 검사 항목 정보 (읽기 전용) */} + {data.template.section_fields.map(field => ( + + ))} + + {/* 측정값 입력 */} + {data.template.columns.map(col => ( + + ))} + + ))} + +
+ {field.label} + + {col.label} +
+ {formatFieldValue(item.field_values?.[field.field_key], field.field_type, item)} + + {renderInput( + col, + formData[makeFieldKey(section.id, rowIndex, col.label)] || '', + (value) => handleInputChange(section.id, rowIndex, col.label, value), + item.field_values?.measurement_type + )} +
+
+ ))} + + {/* 저장 버튼 */} +
+ +
+
+ ); +} + +// 헬퍼 함수들 +function makeFieldKey(sectionId: number | null, rowIndex: number, fieldKey: string): string { + return `${sectionId || 0}_${rowIndex}_${fieldKey}`; +} + +function parseFieldKey(key: string): [number | null, number, string] { + const parts = key.split('_'); + const sectionId = parts[0] === '0' ? null : parseInt(parts[0]); + const rowIndex = parseInt(parts[1]); + const fieldKey = parts.slice(2).join('_'); + return [sectionId, rowIndex, fieldKey]; +} + +function shouldHighlight(item: SectionItem, attributes: ItemAttributes): boolean { + const criteria = item.standard_criteria; + if (!criteria) return false; + + const fieldValues = item.field_values || {}; + const itemName = fieldValues.item?.toLowerCase() || ''; + + // 두께 매칭 + if (itemName.includes('두께') && attributes.thickness != null) { + return matchCriteria(attributes.thickness, criteria); + } + // 너비 매칭 + if (itemName.includes('너비') && attributes.width != null) { + return matchCriteria(attributes.width, criteria); + } + // 길이 매칭 + if (itemName.includes('길이') && attributes.length != null) { + return matchCriteria(attributes.length, criteria); + } + + return false; +} + +function matchCriteria(value: number, criteria: StandardCriteria): boolean { + const { min, min_op, max, max_op } = criteria; + let match = true; + + if (min != null) { + match = match && (min_op === 'gte' ? value >= min : value > min); + } + if (max != null) { + match = match && (max_op === 'lte' ? value <= max : value < max); + } + + return match; +} + +function formatFieldValue(value: any, fieldType: string, item: SectionItem): string { + if (value == null) return '-'; + + switch (fieldType) { + case 'json_tolerance': + return formatTolerance(item.tolerance); + case 'json_criteria': + return formatCriteria(item.standard_criteria); + default: + return String(value); + } +} + +function formatTolerance(tolerance: any): string { + if (!tolerance) return '-'; + + switch (tolerance.type) { + case 'symmetric': + return `±${tolerance.value}`; + case 'asymmetric': + return `+${tolerance.plus}/-${tolerance.minus}`; + case 'range': + return `${tolerance.min}~${tolerance.max}`; + case 'percentage': + return `±${tolerance.value}%`; + default: + return '-'; + } +} + +function formatCriteria(criteria: StandardCriteria | null): string { + if (!criteria) return '-'; + + const parts: string[] = []; + if (criteria.min != null) { + parts.push(`${criteria.min_op === 'gte' ? '≥' : '>'}${criteria.min}`); + } + if (criteria.max != null) { + parts.push(`${criteria.max_op === 'lte' ? '≤' : '<'}${criteria.max}`); + } + + return parts.join(', ') || '-'; +} + +function renderInput( + column: any, + value: string, + onChange: (value: string) => void, + measurementType?: string +): JSX.Element { + const inputType = column.input_type || 'text'; + + if (inputType === 'select' || measurementType === 'checkbox') { + return ( + + ); + } + + return ( + onChange(e.target.value)} + className="w-full px-2 py-1 border rounded text-sm" + /> + ); +} +``` + +--- + +## 4. 사용 케이스별 예제 + +### 4.1 신규 문서 작성 플로우 + +```typescript +// 1. resolve 호출 +const response = await api.get('/documents/resolve', { + params: { category: 'incoming_inspection', item_id: 14172 } +}); + +console.log(response.data.data.is_new); // true +console.log(response.data.data.document); // null +console.log(response.data.data.template.id); // 18 + +// 2. 폼에 데이터 입력 후 저장 +await api.post('/documents/upsert', { + template_id: 18, + item_id: 14172, + title: '수입검사 성적서 - EGI 1.2T', + data: [ + { row_index: 0, field_key: 'measurement_1', field_value: '1.21' }, + { row_index: 0, field_key: 'measurement_2', field_value: '1.20' }, + { row_index: 0, field_key: 'measurement_3', field_value: '1.22' }, + { row_index: 0, field_key: 'judgement', field_value: '합격' } + ] +}); +// 결과: 새 문서 생성됨 (DOC-20260205-0001) +``` + +### 4.2 기존 문서 수정 플로우 + +```typescript +// 1. resolve 호출 - 기존 DRAFT 문서 반환 +const response = await api.get('/documents/resolve', { + params: { category: 'incoming_inspection', item_id: 14172 } +}); + +console.log(response.data.data.is_new); // false +console.log(response.data.data.document.document_no); // "DOC-20260205-0001" +console.log(response.data.data.document.status); // "DRAFT" + +// 2. 기존 데이터를 폼에 표시 → 수정 → 저장 +await api.post('/documents/upsert', { + template_id: 18, + item_id: 14172, + title: '수입검사 성적서 - EGI 1.2T (수정)', + data: [ + { row_index: 0, field_key: 'measurement_1', field_value: '1.23' }, // 수정됨 + { row_index: 0, field_key: 'measurement_2', field_value: '1.20' }, + { row_index: 0, field_key: 'measurement_3', field_value: '1.22' }, + { row_index: 0, field_key: 'judgement', field_value: '합격' } + ] +}); +// 결과: 기존 문서 업데이트됨 +``` + +### 4.3 에러 처리 패턴 + +```typescript +async function loadDocument(category: string, itemId: number) { + try { + const response = await api.get('/documents/resolve', { + params: { category, item_id: itemId } + }); + return { success: true, data: response.data.data }; + } catch (error: any) { + const status = error.response?.status; + const message = error.response?.data?.message; + + if (status === 400) { + // 잘못된 카테고리 + return { success: false, error: 'invalid_category', message }; + } + + if (status === 404) { + if (message?.includes('양식')) { + // 템플릿 없음 - MNG에서 해당 품목을 템플릿에 연결해야 함 + return { success: false, error: 'template_not_found', message }; + } + if (message?.includes('품목')) { + // 품목 없음 + return { success: false, error: 'item_not_found', message }; + } + } + + return { success: false, error: 'unknown', message: message || '알 수 없는 오류' }; + } +} + +// 사용 예시 +const result = await loadDocument('incoming_inspection', 14172); + +if (!result.success) { + switch (result.error) { + case 'invalid_category': + alert('유효하지 않은 문서 분류입니다.'); + break; + case 'template_not_found': + alert('이 품목에 연결된 검사 양식이 없습니다.\n본사에 문의해주세요.'); + break; + case 'item_not_found': + alert('품목 정보를 찾을 수 없습니다.'); + break; + default: + alert(result.message); + } + return; +} + +// 성공 시 처리 +const { is_new, template, document, item } = result.data; +``` + +--- + +## 5. 문서 상태 워크플로우 + +### 5.1 상태 전이도 + +``` + ┌──────────────────────┐ + │ │ + ▼ │ + ┌────────┐ 결재요청 ┌─────────┐ 회수 ┌───────────┐ + │ DRAFT │ ──────────> │ PENDING │ ───────> │ CANCELLED │ + └────────┘ └─────────┘ └───────────┘ + ▲ │ + │ ├── 승인 ──> ┌──────────┐ + │ │ │ APPROVED │ + │ │ └──────────┘ + │ │ + │ 재수정 └── 반려 ──> ┌──────────┐ + └───────────────────────────────── │ REJECTED │ + └──────────┘ +``` + +### 5.2 상태별 특성 + +| 상태 | 수정 가능 | 삭제 가능 | 결재 요청 | 설명 | +|------|:--------:|:--------:|:---------:|------| +| DRAFT | ✅ | ✅ | ✅ | 임시저장 | +| PENDING | ❌ | ❌ | ❌ | 결재 진행 중 | +| APPROVED | ❌ | ❌ | ❌ | 승인 완료 | +| REJECTED | ✅ | ❌ | ✅ | 반려됨 (수정 시 DRAFT로 변경) | +| CANCELLED | ❌ | ❌ | ❌ | 취소됨 | + +### 5.3 upsert와 상태의 관계 + +- **upsert**는 `DRAFT` 또는 `REJECTED` 상태의 문서만 대상으로 함 +- 같은 template_id + item_id 조합으로 `APPROVED` 문서가 있어도, `DRAFT`/`REJECTED` 문서가 있으면 그것을 업데이트 +- 모든 문서가 `APPROVED`/`PENDING`/`CANCELLED` 상태면 새 `DRAFT` 문서 생성 + +--- + +## 6. 문서 분류 관리 (common_codes) + +### 6.1 기본 분류 + +| code | name | 설명 | +|------|------|------| +| `incoming_inspection` | 수입검사 | 자재/원료 입고 시 검사 | +| `quality_inspection` | 품질검사 | 중간/공정 중 품질 검사 | +| `outgoing_inspection` | 출하검사 | 완제품 출하 전 검사 | + +### 6.2 테넌트별 커스텀 분류 추가 + +테넌트별로 추가 분류를 등록할 수 있습니다. + +```sql +-- common_codes 테이블에 테넌트별 분류 추가 +INSERT INTO common_codes (code_group, code, name, tenant_id, sort_order, is_active) +VALUES ('document_category', 'interim_inspection', '중간검사', 287, 4, true); +``` + +### 6.3 분류 조회 우선순위 + +1. 테넌트 전용 분류 (tenant_id = 현재 테넌트) +2. 글로벌 분류 (tenant_id = NULL) + +--- + +## 7. 주의사항 + +### 7.1 템플릿-품목 연결 필수 + +- `resolve` API는 해당 category의 템플릿 중 item_id가 **연결된** 템플릿만 반환 +- MNG에서 템플릿 편집 시 "연결 설정"에서 품목을 연결해야 함 + +### 7.2 다중 문서 방지 + +- 같은 품목(item_id)에 대해 동일 템플릿의 DRAFT 문서는 **1개만** 존재 +- 기존 DRAFT가 있으면 upsert는 UPDATE 동작 + +### 7.3 Auto-Highlight + +- `item.attributes`의 `thickness`, `width`, `length` 값과 검사 항목의 `standard_criteria`를 비교 +- UI에서 해당 행을 하이라이트하여 사용자가 어떤 검사 항목을 작성해야 하는지 안내 + +### 7.4 날짜 형식 + +- 응답의 날짜 필드는 `Y-m-d` 형식 (ApiResponse::formatDates 적용) +- ISO 8601 원본이 필요하면 `*_at` 필드의 raw 값 사용 + +--- + +## 변경 이력 + +| 버전 | 날짜 | 작성자 | 변경 내용 | +|------|------|--------|----------| +| 1.0.0 | 2026-02-05 | API Team | 최초 작성 |