From 751e65f59bf20a62c26b858224af7d78e123c873 Mon Sep 17 00:00:00 2001 From: byeongcheolryu Date: Thu, 4 Dec 2025 20:52:42 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=ED=92=88=EB=AA=A9=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EA=B8=B0=EB=8A=A5=20=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EB=B0=8F=20Sales=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 품목관리 수정 버그 수정 - FG(제품) 수정 시 품목명 반영 안되는 문제 해결 - productName → name 필드 매핑 추가 - FG 품목코드 = 품목명 동기화 로직 추가 - Materials(SM, RM, CS) 수정페이지 진입 오류 해결 - UNIQUE 제약조건 위반 오류 해결 ## Sales 페이지 - 거래처관리 (client-management-sales-admin) 페이지 구현 - 견적관리 (quote-management) 페이지 구현 - 관련 컴포넌트 및 훅 추가 ## 기타 - 회원가입 페이지 차단 처리 - 디버깅용 콘솔 로그 추가 (PUT 요청/응답 확인용) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .DS_Store | Bin 6148 -> 6148 bytes claudedocs/.DS_Store | Bin 0 -> 6148 bytes claudedocs/_index.md | 11 +- .../[IMPL-2025-12-04] signup-page-blocking.md | 74 ++ .../item-master/[REF] item-code-hardcoding.md | 544 +++++++++--- .../[API-2025-12-04] client-api-analysis.md | 382 +++++++++ .../[API-2025-12-04] quote-api-request.md | 675 +++++++++++++++ ...2-04] client-management-api-integration.md | 170 ++++ ...-12-04] quote-management-implementation.md | 346 ++++++++ package-lock.json | 74 ++ package.json | 1 + .../(protected)/items/[id]/edit/page.tsx | 461 ++++++---- .../[locale]/(protected)/items/[id]/page.tsx | 329 ++++--- .../[id]/edit/page.tsx | 82 ++ .../[id]/page.tsx | 128 +++ .../new/page.tsx | 30 + .../client-management-sales-admin/page.tsx | 517 ++++------- .../sales/quote-management/[id]/edit/page.tsx | 102 +++ .../sales/quote-management/[id]/page.tsx | 631 ++++++++++++++ .../sales/quote-management/new/page.tsx | 34 + .../sales/quote-management/page.tsx | 78 +- src/app/api/proxy/[...path]/route.ts | 17 +- src/components/auth/LoginPage.tsx | 36 +- src/components/clients/ClientDetail.tsx | 247 ++++++ src/components/clients/ClientRegistration.tsx | 607 +++++++++++++ .../hooks/useDynamicFormState.ts | 25 +- .../items/DynamicItemForm/index.tsx | 404 +++++++-- .../utils/itemCodeGenerator.ts | 35 + src/components/items/ItemDetailClient.tsx | 2 +- .../items/ItemForm/forms/ProductForm.tsx | 109 +-- .../forms/parts/PurchasedPartForm.tsx | 42 +- src/components/items/ItemListClient.tsx | 108 ++- .../dialogs/FieldDialog.tsx | 18 +- .../hooks/useFieldManagement.ts | 19 +- src/components/layout/Sidebar.tsx | 14 +- src/components/molecules/FormField.tsx | 185 ++++ src/components/organisms/FormActions.tsx | 74 ++ src/components/organisms/FormFieldGrid.tsx | 35 + src/components/organisms/FormSection.tsx | 62 ++ .../quotes/PurchaseOrderDocument.tsx | 425 ++++++++++ .../quotes/QuoteCalculationReport.tsx | 508 +++++++++++ src/components/quotes/QuoteDocument.tsx | 461 ++++++++++ src/components/quotes/QuoteRegistration.tsx | 801 ++++++++++++++++++ .../templates/IntegratedListTemplateV2.tsx | 4 +- .../templates/ResponsiveFormTemplate.tsx | 96 +++ src/components/ui/radio-group.tsx | 45 + src/hooks/useClientGroupList.ts | 366 ++++++++ src/hooks/useClientList.ts | 530 ++++++++++++ src/layouts/DashboardLayout.tsx | 2 +- src/lib/api/auth/auth-config.ts | 2 +- src/middleware.ts | 7 + tsconfig.tsbuildinfo | 2 +- 52 files changed, 8869 insertions(+), 1088 deletions(-) create mode 100644 claudedocs/.DS_Store create mode 100644 claudedocs/auth/[IMPL-2025-12-04] signup-page-blocking.md create mode 100644 claudedocs/sales/[API-2025-12-04] client-api-analysis.md create mode 100644 claudedocs/sales/[API-2025-12-04] quote-api-request.md create mode 100644 claudedocs/sales/[IMPL-2025-12-04] client-management-api-integration.md create mode 100644 claudedocs/sales/[PLAN-2025-12-04] quote-management-implementation.md create mode 100644 src/app/[locale]/(protected)/sales/client-management-sales-admin/[id]/edit/page.tsx create mode 100644 src/app/[locale]/(protected)/sales/client-management-sales-admin/[id]/page.tsx create mode 100644 src/app/[locale]/(protected)/sales/client-management-sales-admin/new/page.tsx create mode 100644 src/app/[locale]/(protected)/sales/quote-management/[id]/edit/page.tsx create mode 100644 src/app/[locale]/(protected)/sales/quote-management/[id]/page.tsx create mode 100644 src/app/[locale]/(protected)/sales/quote-management/new/page.tsx create mode 100644 src/components/clients/ClientDetail.tsx create mode 100644 src/components/clients/ClientRegistration.tsx create mode 100644 src/components/molecules/FormField.tsx create mode 100644 src/components/organisms/FormActions.tsx create mode 100644 src/components/organisms/FormFieldGrid.tsx create mode 100644 src/components/organisms/FormSection.tsx create mode 100644 src/components/quotes/PurchaseOrderDocument.tsx create mode 100644 src/components/quotes/QuoteCalculationReport.tsx create mode 100644 src/components/quotes/QuoteDocument.tsx create mode 100644 src/components/quotes/QuoteRegistration.tsx create mode 100644 src/components/templates/ResponsiveFormTemplate.tsx create mode 100644 src/components/ui/radio-group.tsx create mode 100644 src/hooks/useClientGroupList.ts create mode 100644 src/hooks/useClientList.ts diff --git a/.DS_Store b/.DS_Store index 10aba0478001a8c39fc1ed8ca18b28dc6542b089..94e599e180ec917b79cffac4a3f936674e368986 100644 GIT binary patch delta 12 TcmZoMXfc?uhLLgO+Hi3I9rFZp delta 12 TcmZoMXfc?uhLK_8+Hi3I9qt5j diff --git a/claudedocs/.DS_Store b/claudedocs/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..11ca4f54acbe119a1b521908aee49b863788a2cf GIT binary patch literal 6148 zcmeH~J&pn~427R}L0XB1k}}O6fEz>zPQV3r`RPWY7}4kGJUea}wqA|Uv*f(kiRb4l zCSw4$yI;1z8o(Fb72iHg%or~*;uiz17%$^_INff8r}2?{J)rX%&+EA?5djep0TB=Z z5ttEyIK+AWuV(a2dK3{5fq4+{??a)x*3{NDJ{=sQ1)wgN4&yv}32N~KwWhYN%+M^m z2g_26HpKH$PA$2wrnauV9G1<8<( 프로젝트 기술 문서 인덱스 (Last Updated: 2025-12-02) +> 프로젝트 기술 문서 인덱스 (Last Updated: 2025-12-04) ## 폴더 구조 @@ -23,6 +23,7 @@ claudedocs/ | 파일 | 설명 | |------|------| +| `[IMPL-2025-12-04] signup-page-blocking.md` | ✅ **완료** - MVP 회원가입 페이지 차단 (운영 페이지 이동 예정) | | `token-management-guide.md` | ⭐ **핵심** - Access/Refresh Token 완전 가이드 | | `jwt-cookie-authentication-final.md` | JWT + HttpOnly Cookie 구현 | | `auth-guard-usage.md` | AuthGuard 훅 사용법 | @@ -40,6 +41,8 @@ claudedocs/ | 파일 | 설명 | |------|------| +| `[REF] item-code-hardcoding.md` | ⭐ **핵심** - 품목관리 하드코딩 내역 종합 (품목유형/코드자동생성/전개도/BOM) | +| `[IMPL-2025-12-02] item-code-auto-generation.md` | 품목코드 자동생성 구현 상세 | | `[PLAN-2025-12-01] service-layer-refactoring.md` | ✅ **완료** - 서비스 레이어 리팩토링 계획 (도메인 로직 중앙화) | | `[REF-2025-12-01] state-sync-solutions.md` | 📋 **참조** - 상태 동기화 문제 및 해결 방안 (정규화, React Query 등) | | `[PLAN-2025-11-28] dynamic-item-form-implementation.md` | ⚠️ **롤백됨** - 이전 구현 계획 (참조용) | @@ -63,7 +66,11 @@ claudedocs/ | 파일 | 설명 | |------|------| -| `[PLAN-2025-12-02] sales-pages-migration.md` | 📋 **신규** - 견적관리/거래처관리 마이그레이션 계획 | +| `[API-2025-12-04] quote-api-request.md` | ⭐ **NEW** - 견적관리 API 요청서 (데이터 모델, 엔드포인트, 수식 계산) | +| `[PLAN-2025-12-04] quote-management-implementation.md` | 📋 **NEW** - 견적관리 작업계획서 (6 Phase, 체크리스트) | +| `[IMPL-2025-12-04] client-management-api-integration.md` | ✅ **완료** - 거래처관리 API 연동 체크리스트 (CRUD, 그룹 훅) | +| `[API-2025-12-04] client-api-analysis.md` | ⭐ 거래처 API 분석 (sam-api 연동 현황, 필드 매핑) | +| `[PLAN-2025-12-02] sales-pages-migration.md` | 📋 견적관리/거래처관리 마이그레이션 계획 | --- diff --git a/claudedocs/auth/[IMPL-2025-12-04] signup-page-blocking.md b/claudedocs/auth/[IMPL-2025-12-04] signup-page-blocking.md new file mode 100644 index 00000000..1ecc52ef --- /dev/null +++ b/claudedocs/auth/[IMPL-2025-12-04] signup-page-blocking.md @@ -0,0 +1,74 @@ +# MVP 회원가입 페이지 차단 + +> **날짜**: 2025-12-04 +> **상태**: 완료 +> **목적**: MVP 버전에서 회원가입 접근 차단 (운영 페이지로 이동 예정) + +--- + +## 변경 사항 + +### 1. auth-config.ts +**파일**: `src/lib/api/auth/auth-config.ts` + +```typescript +// Before +guestOnlyRoutes: ['/login', '/signup', '/forgot-password'] + +// After +guestOnlyRoutes: ['/login', '/forgot-password'] +``` + +- `/signup`을 guestOnlyRoutes에서 제거 +- 주석에 변경 이유 기록 + +### 2. LoginPage.tsx +**파일**: `src/components/auth/LoginPage.tsx` + +| 제거된 요소 | 위치 | +|------------|------| +| 헤더 회원가입 버튼 | Line 188 (이전) | +| "계정 만들기" 버튼 | Line 304-310 (이전) | +| 하단 회원가입 링크 | Line 314-325 (이전) | + +- 총 3개의 회원가입 관련 UI 요소 제거 +- 주석으로 제거 이유 기록 + +### 3. middleware.ts +**파일**: `src/middleware.ts` + +```typescript +// 4.5️⃣ MVP: /signup 접근 차단 → /login 리다이렉트 (2025-12-04) +if (pathnameWithoutLocale === '/signup' || pathnameWithoutLocale.startsWith('/signup/')) { + console.log(`[Signup Blocked] Redirecting to /login from ${pathname}`); + return NextResponse.redirect(new URL('/login', request.url)); +} +``` + +- URL 직접 접근 시 `/login`으로 리다이렉트 +- 로그 출력으로 접근 시도 추적 가능 + +--- + +## 유지된 파일 (삭제 안함) + +| 파일 | 이유 | +|------|------| +| `src/app/[locale]/signup/page.tsx` | 운영 페이지에서 재사용 예정 | +| `src/app/api/auth/signup/route.ts` | API 로직 재사용 예정 | + +--- + +## 테스트 체크리스트 + +- [ ] 로그인 페이지에서 회원가입 링크 없음 +- [ ] `/signup` URL 직접 접근 시 `/login`으로 리다이렉트 +- [ ] `/ko/signup` (로케일 포함) 접근 시 `/login`으로 리다이렉트 +- [ ] 기존 로그인 기능 정상 동작 + +--- + +## 향후 작업 + +- 회원가입 기능을 운영 페이지(관리자용)로 이동 +- 운영 페이지에서 사용자 등록 기능 구현 diff --git a/claudedocs/item-master/[REF] item-code-hardcoding.md b/claudedocs/item-master/[REF] item-code-hardcoding.md index 310a0ff4..97588ea3 100644 --- a/claudedocs/item-master/[REF] item-code-hardcoding.md +++ b/claudedocs/item-master/[REF] item-code-hardcoding.md @@ -1,35 +1,98 @@ -# 품목코드/품목명 자동생성 하드코딩 내역 +# 품목관리 하드코딩 내역 종합 문서 -> MVP용 프론트엔드 구현 - 추후 백엔드 API 또는 품목기준관리 설정으로 이관 필요 +> **MVP용 프론트엔드 구현** - 추후 품목기준관리 설정 또는 백엔드 API로 이관 필요 ## 개요 -PT(부품) 품목 등록 시 품목코드와 품목명을 자동 생성하는 로직이 프론트엔드에 하드코딩되어 있습니다. -이 문서는 해당 하드코딩 내역을 정리하여 추후 백엔드 이관 시 참고할 수 있도록 합니다. - -## 품목코드/품목명 생성 규칙 - -### 적용 범위 - -| 품목유형 | 품목코드 형식 | 품목명 형식 | 예시 | -|---------|-------------|------------|------| -| **PT (부품)** | `영문약어-순번` | 한글 조합 | `GR-001` / `가이드레일 130×80` | -| FG (제품) | `품목명-규격` | 직접 입력 | `스크린-2400` | -| SM (부자재) | `품목명-규격` | 직접 입력 | `볼트-M8` | -| RM (원자재) | `품목명-규격` | 직접 입력 | `알루미늄-T1.5` | -| CS (소모품) | `품목명-규격` | 직접 입력 | `테이프-50mm` | +품목기준관리에서 동적으로 설정해야 하지만 아직 해당 기능이 없어 프론트엔드에 하드코딩된 기능 목록입니다. --- -## 하드코딩 항목 1: 영문약어 매핑 테이블 +## 하드코딩 항목 요약 + +| # | 항목 | 파일 위치 | 우선순위 | 상태 | +|---|------|----------|---------|------| +| 1 | 품목유형 등록 (FG/PT/SM/RM/CS) | `ItemTypeSelect.tsx` | 🔴 High | 하드코딩 | +| 2 | 품목코드/품목명 자동생성 | `itemCodeGenerator.ts`, `DynamicItemForm/index.tsx` | 🔴 High | 하드코딩 | +| 3 | 전개도/바라시 섹션 (조립/절곡) | `DynamicItemForm/index.tsx` | 🟡 Medium | 하드코딩 | +| 4 | BOM 섹션 내부 구조 | `DynamicBOMSection.tsx` | 🟡 Medium | 하드코딩 | +| 5 | 부품유형 판별 로직 | `DynamicItemForm/index.tsx` | 🟢 Low | 하드코딩 | +| 6 | FG(제품) 시방서/인정서 파일업로드 | `DynamicItemForm/index.tsx` | 🟡 Medium | 하드코딩 | + +--- + +## 1. 품목유형 등록 (FG/PT/SM/RM/CS) ### 파일 위치 -`src/components/items/DynamicItemForm/utils/itemCodeGenerator.ts` +`src/components/items/ItemTypeSelect.tsx` -### 상수명 -`ITEM_CODE_PREFIX_MAP` +### 하드코딩 내용 +```typescript +const ITEM_TYPE_LABELS_WITH_ENGLISH: Record = { + FG: '제품 (Finished Goods)', + PT: '부품 (Part)', + SM: '부자재 (Sub Material)', + RM: '원자재 (Raw Material)', + CS: '소모품 (Consumables)', +}; +``` + +### 문제점 +- 품목유형 추가/수정/삭제 불가 +- 각 유형별 표시 순서 고정 +- 영문명 커스터마이징 불가 + +### 마이그레이션 방안 +```yaml +Phase 1: 품목기준관리 API 확장 + - item_types 테이블 생성 + - GET /api/v1/item-master/types 엔드포인트 추가 + - 응답: { code: 'FG', name: '제품', englishName: 'Finished Goods', sortOrder: 1 } + +Phase 2: 프론트엔드 연동 + - ItemTypeSelect에서 API 호출 + - 품목기준관리에 품목유형 관리 UI 추가 +``` + +--- + +## 2. 품목코드/품목명 자동생성 + +### 파일 위치 +- **부품(PT)**: `src/components/items/DynamicItemForm/utils/itemCodeGenerator.ts` +- **제품(FG)**: `src/components/items/DynamicItemForm/index.tsx` (Lines: 1430-1444) + +### 2-0. 제품(FG) 품목코드 규칙 + +```typescript +// 제품(FG)의 품목코드는 품목명과 동일 (조합식 없음) +// DynamicItemForm/index.tsx에서 직접 처리 + +{/* FG(제품) 전용: 품목명 필드 다음에 품목코드 자동생성 */} +{isItemNameField && selectedItemType === 'FG' && ( +
+ + +

+ * 제품(FG)의 품목코드는 품목명과 동일하게 설정됩니다 +

+
+)} +``` + +**제품(FG) 특징**: +- 품목코드 = 품목명 (단순 복사) +- 조합식이나 영문약어 매핑 없음 +- `isItemNameField` 플래그로 품목명 필드 다음에 자동으로 표시 + +### 2-1. 영문약어 매핑 테이블 -### 내용 ```typescript export const ITEM_CODE_PREFIX_MAP: Record = { // 부품 - 조립품 @@ -69,21 +132,8 @@ export const ITEM_CODE_PREFIX_MAP: Record = { }; ``` -### 마이그레이션 방안 -- 품목기준관리 API에 `영문약어 설정` 기능 추가 -- 또는 별도 `item_code_prefix` 테이블 생성 +### 2-2. 절곡품 코드 체계 ---- - -## 하드코딩 항목 2: 절곡품 코드 체계 - -### 파일 위치 -`src/components/items/DynamicItemForm/utils/itemCodeGenerator.ts` - -### 상수명 -`BENDING_CODE_SYSTEM` - -### 내용 ```typescript export const BENDING_CODE_SYSTEM = { // 품목명코드 (category2) @@ -128,25 +178,8 @@ export const BENDING_CODE_SYSTEM = { }; ``` -### 사용 예시 -- 품목명코드 `R` + 종류코드 `C` + 길이코드 `24` = `RC24` -- 가이드레일 채널 2438mm +### 2-3. 조립품 설치유형 매핑 -### 마이그레이션 방안 -- 품목기준관리 API에 `코드 체계 설정` 기능 추가 -- 또는 별도 `bending_code_system` 테이블 생성 - ---- - -## 하드코딩 항목 3: 조립품 설치유형 매핑 - -### 파일 위치 -`src/components/items/DynamicItemForm/utils/itemCodeGenerator.ts` - -### 상수명 -`INSTALLATION_TYPE_MAP` - -### 내용 ```typescript export const INSTALLATION_TYPE_MAP: Record = { 'standard': '표준형', @@ -157,79 +190,359 @@ export const INSTALLATION_TYPE_MAP: Record = { }; ``` -### 사용 예시 -- 품목명 `가이드레일` + 설치유형 `standard` → `가이드레일표준형` +### 자동생성 함수 목록 + +| 함수명 | 용도 | 형식 예시 | +|--------|------|----------| +| `generateItemCode` | PT 품목코드 | `GR-001`, `MOTOR-002` | +| `generateBendingItemCode` | 절곡품 품목코드 | `RC24` (가이드레일 채널 2438mm) | +| `generateAssemblyItemName` | 조립품 품목명 | `가이드레일표준형50*60*24` | +| `generateBendingItemName` | 절곡품 품목명 | `가이드레일 채널 50×30` | +| `generatePurchasedItemName` | 구매품 품목명 | `모터 0.4KW` | ### 마이그레이션 방안 -- 품목기준관리 필드 옵션으로 설정 가능하도록 변경 +```yaml +Phase 1: 품목기준관리 설정 확장 + - 영문약어 필드 추가 (품목명 필드 옵션에 매핑) + - 코드생성규칙 설정 UI 추가 ---- - -## 자동생성 함수 목록 - -### 1. generateItemCode (품목코드 생성) -```typescript -// 용도: PT(부품) 품목코드 자동생성 -// 형식: 영문약어-순번 (예: GR-001, MOTOR-002) -generateItemCode(itemName: string, existingCodes: string[]): string -``` - -### 2. generateBendingItemCode (절곡품 품목코드) -```typescript -// 용도: 절곡품 전용 품목코드 -// 형식: 품목명코드 + 종류코드 + 길이코드 (예: RC24) -generateBendingItemCode(category2: string, category3: string, lengthMm: number): string -``` - -### 3. generateAssemblyItemName (조립품 품목명) -```typescript -// 용도: 조립품 품목명 자동생성 -// 형식: 품목명 + 설치유형 + 측면규격*길이코드 (예: 가이드레일표준형50*60*24) -generateAssemblyItemName(itemName, installationType, sideSpecWidth, sideSpecHeight, lengthMm): string -``` - -### 4. generateBendingItemName (절곡품 품목명) -```typescript -// 용도: 절곡품 품목명 자동생성 -// 형식: 품목명 + 종류 + 규격 (예: 가이드레일 채널 50×30) -generateBendingItemName(category2Label, category3Label, specification): string -``` - -### 5. generatePurchasedItemName (구매품 품목명) -```typescript -// 용도: 구매품 품목명 자동생성 -// 형식: 품목명 + 규격 (예: 모터 0.4KW) -generatePurchasedItemName(itemName, specification): string +Phase 2: 백엔드 이관 + - 품목 저장 시 백엔드에서 코드 자동 생성 + - 순번 관리를 DB 시퀀스로 변경 (동시성 처리) ``` --- -## 추후 개발 계획 +## 3. 전개도/바라시 섹션 (조립/절곡) -### Phase 1: 품목기준관리 설정 확장 -1. `영문약어` 필드 추가 (품목명 필드 옵션에 매핑) -2. `코드생성규칙` 설정 UI 추가 -3. API에서 코드 생성 규칙 반환 +### 파일 위치 +`src/components/items/DynamicItemForm/index.tsx` (Lines: 1474-1560) -### Phase 2: 백엔드 이관 -1. 품목 저장 시 백엔드에서 코드 자동 생성 -2. 순번 관리를 DB 시퀀스로 변경 (동시성 처리) -3. 프론트엔드 하드코딩 제거 +### 하드코딩 내용 -### Phase 3: 고급 기능 -1. 품목별 코드 생성 규칙 커스터마이징 -2. 코드 중복 검사 강화 -3. 코드 변경 이력 관리 +#### 3-1. 조립품 전개도 섹션 (바라시) +```typescript +{/* 조립품 전개도 섹션 (PT - 조립 부품 전용) */} +{selectedItemType === 'PT' && isAssemblyPart && assemblyItemNameKey && ( + +)} +``` + +#### 3-2. 절곡품 전개도 섹션 +```typescript +{/* 절곡품 전개도 섹션 (PT - 절곡 부품 전용) */} +{selectedItemType === 'PT' && isBendingPart && bendingFields.material && ( + +)} +``` + +### 문제점 +- 전개도 섹션 표시 조건이 코드에 고정 +- 조립/절곡 외 다른 부품유형에 전개도 추가 불가 +- 전개도 섹션 필드 구성 변경 불가 + +### 데이터 구조 (저장 시) +```typescript +// 절곡품 +{ + bending_diagram: string | null, // 전개도 이미지 Base64 + bending_details: BendingDetail[], // 전개도 상세 (좌표, 길이 등) + width_sum: string | null, // 폭 합계 + shape_and_length: string | null, // 모양 & 길이 +} + +// 조립품 (동일 필드 사용) +{ + bending_diagram: string | null, + width_sum: string | null, + shape_and_length: string | null, +} +``` + +### 마이그레이션 방안 +```yaml +Phase 1: 품목기준관리에 "특수 섹션" 설정 추가 + - 섹션 유형: 일반, 전개도, BOM 선택 가능 + - 전개도 섹션 표시 조건 설정 (부품유형별) + +Phase 2: 동적 렌더링 연동 + - 품목기준관리 설정에 따라 전개도 섹션 표시 + - 필드 구성도 동적으로 변경 가능 +``` --- -## 관련 파일 +## 4. BOM 섹션 내부 구조 -| 파일 | 설명 | -|-----|------| -| `src/components/items/DynamicItemForm/utils/itemCodeGenerator.ts` | 하드코딩된 매핑 및 생성 함수 | -| `src/components/items/DynamicItemForm/index.tsx` | 품목코드 자동생성 로직 호출 | -| `claudedocs/item-master/[IMPL-2025-12-02] item-code-auto-generation.md` | 기존 품목코드 자동생성 문서 | +### 파일 위치 +`src/components/items/DynamicItemForm/sections/DynamicBOMSection.tsx` + +### 하드코딩 내용 + +#### 4-1. BOM 라인 기본 구조 +```typescript +const newLine: BOMLine = { + id: `bom-${Date.now()}`, + childItemCode: '', + childItemName: '', + quantity: 1, + unit: 'EA', // 기본 단위 고정 + specification: '', + material: '', + note: '', + partType: '', + bendingDiagram: '', +}; +``` + +#### 4-2. BOM 테이블 컬럼 구조 +```typescript +// 고정된 컬럼들 +품목코드 +품목명 +규격 +재질 +수량 +단위 +비고 +``` + +#### 4-3. 품목 검색 결과 매핑 +```typescript +const mappedItems: SearchedItem[] = rawItems.map((item) => ({ + id: String(item.id), + itemCode: (item.code ?? item.item_code ?? '') as string, + itemName: (item.name ?? item.item_name ?? '') as string, + specification: (item.specification ?? '') as string, + material: (item.material ?? '') as string, + unit: (item.unit ?? 'EA') as string, + partType: (item.part_type ?? '') as string, + bendingDiagram: (item.bending_diagram ?? '') as string, +})); +``` + +### 문제점 +- BOM 컬럼 추가/삭제/순서변경 불가 +- 기본 단위 'EA' 고정 +- BOM 필드별 필수여부 설정 불가 +- 절곡품 전개도 표시 영역 고정 + +### 마이그레이션 방안 +```yaml +Phase 1: 품목기준관리에 "BOM 섹션 설정" 추가 + - BOM 컬럼 구성 설정 (표시/숨김, 순서) + - 기본값 설정 (단위, 수량 등) + +Phase 2: 동적 BOM 렌더링 + - 품목기준관리 설정에 따라 BOM 테이블 렌더링 + - 컬럼별 width, 정렬 등 설정 가능 +``` + +--- + +## 5. 부품유형 판별 로직 + +### 파일 위치 +`src/components/items/DynamicItemForm/index.tsx` (Lines: 444-505) + +### 하드코딩 내용 +```typescript +// part_type 필드 탐지 (field_key 기반) +const isPartType = fieldKey.includes('part_type') || + lowerKey.includes('부품유형') || + lowerKey.includes('부품_유형') || + fieldName.includes('부품유형') || + fieldName.includes('부품 유형'); + +// 부품유형별 판별 (값 기반) +const isBending = currentPartType.includes('절곡') || + currentPartType.toUpperCase() === 'BENDING'; +const isAssembly = currentPartType.includes('조립') || + currentPartType.toUpperCase() === 'ASSEMBLY'; +const isPurchased = currentPartType.includes('구매') || + currentPartType.toUpperCase() === 'PURCHASED'; +``` + +### 문제점 +- 부품유형 키워드 매칭이 코드에 고정 +- 새로운 부품유형 추가 시 코드 수정 필요 +- 다국어 지원 어려움 + +### 마이그레이션 방안 +```yaml +Phase 1: 품목기준관리에 "부품유형 설정" 추가 + - 부품유형 목록 관리 (절곡, 조립, 구매 등) + - 각 부품유형별 특수 처리 설정 + +Phase 2: 동적 부품유형 판별 + - API에서 부품유형 목록과 매칭 키워드 제공 + - 코드 기반 판별에서 설정 기반 판별로 전환 +``` + +--- + +## 6. FG(제품) 시방서/인정서 파일업로드 + +### 파일 위치 +`src/components/items/DynamicItemForm/index.tsx` (Lines: 1446-1494) + +### 하드코딩 내용 + +#### 6-1. 파일업로드 상태 관리 +```typescript +// FG(제품) 전용 파일 업로드 상태 관리 +const [specificationFile, setSpecificationFile] = useState(null); +const [certificationFile, setCertificationFile] = useState(null); +``` + +#### 6-2. 인정 유효기간 종료일 필드 감지 +```typescript +// 인정 유효기간 종료일 필드인지 체크 (FG 시방서/인정서 파일 업로드 위치) +const isCertEndDateField = fieldKey.includes('certification_end') || + fieldKey.includes('인정_유효기간_종료') || + fieldName.includes('인정 유효기간 종료') || + fieldName.includes('유효기간 종료'); +``` + +#### 6-3. 시방서/인정서 파일업로드 UI +```typescript +{/* FG(제품) 전용: 인정 유효기간 종료일 다음에 시방서/인정서 파일 업로드 */} +{isCertEndDateField && selectedItemType === 'FG' && ( +
+ {/* 시방서 파일 업로드 */} +
+ +
+ { + const file = e.target.files?.[0] || null; + setSpecificationFile(file); + }} + disabled={isSubmitting} + className="cursor-pointer" + /> + {specificationFile && ( +

+ 선택된 파일: {specificationFile.name} +

+ )} +
+
+ {/* 인정서 파일 업로드 */} +
+ +
+ { + const file = e.target.files?.[0] || null; + setCertificationFile(file); + }} + disabled={isSubmitting} + className="cursor-pointer" + /> + {certificationFile && ( +

+ 선택된 파일: {certificationFile.name} +

+ )} +
+
+
+)} +``` + +### 표시 위치 +- **조건**: `selectedItemType === 'FG'` (제품 유형일 때만) +- **위치**: 인정 유효기간 종료일 필드 바로 다음 +- **파일 형식**: PDF만 허용 (`accept=".pdf"`) + +### 문제점 +- FG 유형 고정 (다른 품목유형에는 표시 안됨) +- 인정 유효기간 종료일 필드명 매칭이 하드코딩 +- 파일 업로드 필드가 품목기준관리에서 설정 불가 + +### 마이그레이션 방안 +```yaml +Phase 1: 품목기준관리에 "파일 첨부 필드" 유형 추가 + - 필드 타입: file, image, document 등 + - 허용 확장자 설정 (PDF, DOC, 이미지 등) + - 품목유형별 표시 조건 설정 + +Phase 2: 동적 파일업로드 렌더링 + - 품목기준관리 설정에 따라 파일 필드 동적 표시 + - 백엔드 파일 저장 API 연동 +``` + +--- + +## 추가 하드코딩 발견 사항 + +### 속성 옵션 상태 +**파일**: `src/components/items/ItemMasterDataManagement/hooks/useAttributeManagement.ts:77` +```typescript +// 속성 옵션 상태 (기본값 하드코딩 - TODO: 나중에 백엔드 API로 대체) +``` + +### BOM 가격 정보 +**파일**: `src/components/items/ItemForm/hooks/useBOMManagement.ts:89` +```typescript +unitPrice: 0, // TODO: pricing에서 가져오기 +``` + +--- + +## 종합 마이그레이션 로드맵 + +### Phase 1: 품목기준관리 API 확장 (백엔드) +1. `item_types` 테이블 - 품목유형 관리 +2. `code_generation_rules` 테이블 - 코드 생성 규칙 +3. `special_sections` 설정 - 전개도/BOM 섹션 설정 +4. `part_types` 테이블 - 부품유형 관리 + +### Phase 2: 품목기준관리 UI 확장 (프론트엔드) +1. 품목유형 관리 탭 추가 +2. 코드생성규칙 설정 UI +3. 특수 섹션 설정 UI +4. 부품유형 관리 UI + +### Phase 3: 동적 렌더링 연동 +1. 품목유형 API 연동 (ItemTypeSelect) +2. 코드 자동생성 API 연동 (itemCodeGenerator 대체) +3. 전개도 섹션 동적 렌더링 +4. BOM 섹션 동적 렌더링 + +### Phase 4: 프론트엔드 하드코딩 제거 +1. 상수 파일들 제거 +2. 판별 로직 설정 기반으로 전환 +3. 테스트 및 검증 + +--- + +## 관련 파일 목록 + +| 파일 | 하드코딩 항목 | +|------|-------------| +| `src/components/items/ItemTypeSelect.tsx` | 품목유형 목록 | +| `src/components/items/DynamicItemForm/utils/itemCodeGenerator.ts` | PT 코드 생성 규칙, 매핑 테이블 | +| `src/components/items/DynamicItemForm/index.tsx` | FG 품목코드, FG 시방서/인정서 파일업로드, 전개도 섹션, 부품유형 판별 | +| `src/components/items/DynamicItemForm/sections/DynamicBOMSection.tsx` | BOM 구조 | +| `src/components/items/DynamicItemForm/types.ts` | BOMLine 타입 정의 | + +> **참고**: `src/components/items/ItemForm/forms/ProductForm.tsx`는 현재 사용되지 않음 (레거시) --- @@ -237,5 +550,8 @@ generatePurchasedItemName(itemName, specification): string | 날짜 | 내용 | |-----|------| -| 2025-12-03 | PT 품목코드 `영문약어-순번` 형식 구현 | -| 2025-12-03 | 하드코딩 내역 문서화 | \ No newline at end of file +| 2025-12-03 | 품목코드/품목명 자동생성 문서화 | +| 2025-12-04 | 전체 하드코딩 항목 종합 문서로 확장 | +| 2025-12-04 | 품목유형, 전개도/바라시, BOM 섹션 추가 | +| 2025-12-04 | 제품(FG) 품목코드 규칙 추가 (품목명=품목코드) - DynamicItemForm으로 이동 | +| 2025-12-04 | FG 전용 시방서/인정서 파일업로드 추가 | \ No newline at end of file diff --git a/claudedocs/sales/[API-2025-12-04] client-api-analysis.md b/claudedocs/sales/[API-2025-12-04] client-api-analysis.md new file mode 100644 index 00000000..62f55424 --- /dev/null +++ b/claudedocs/sales/[API-2025-12-04] client-api-analysis.md @@ -0,0 +1,382 @@ +# 거래처 관리 API 분석 + +> **작성일**: 2025-12-04 +> **목적**: sam-api 백엔드 Client API와 프론트엔드 거래처 관리 페이지 간 연동 분석 + +--- + +## 1. 현재 상태 요약 + +### 프론트엔드 (sam-react-prod) +- **파일**: `src/app/[locale]/(protected)/sales/client-management-sales-admin/page.tsx` +- **상태**: ❌ **API 미연동** - 로컬 샘플 데이터(`SAMPLE_CUSTOMERS`)로만 동작 +- **모든 CRUD가 클라이언트 사이드에서만 수행됨** + +### 백엔드 (sam-api) +- **컨트롤러**: `app/Http/Controllers/Api/V1/ClientController.php` +- **서비스**: `app/Services/ClientService.php` +- **모델**: `app/Models/Orders/Client.php` +- **상태**: ✅ **API 구현 완료** - 모든 CRUD 기능 제공 + +--- + +## 2. 백엔드 API 명세 + +### 2.1 Client (거래처) API + +| Method | Endpoint | 설명 | 인증 | +|--------|----------|------|------| +| `GET` | `/api/v1/clients` | 목록 조회 (페이지네이션, 검색) | ✅ Required | +| `GET` | `/api/v1/clients/{id}` | 단건 조회 | ✅ Required | +| `POST` | `/api/v1/clients` | 생성 | ✅ Required | +| `PUT` | `/api/v1/clients/{id}` | 수정 | ✅ Required | +| `DELETE` | `/api/v1/clients/{id}` | 삭제 | ✅ Required | +| `PATCH` | `/api/v1/clients/{id}/toggle` | 활성/비활성 토글 | ✅ Required | + +### 2.2 Client Group (거래처 그룹) API + +| Method | Endpoint | 설명 | 인증 | +|--------|----------|------|------| +| `GET` | `/api/v1/client-groups` | 그룹 목록 | ✅ Required | +| `GET` | `/api/v1/client-groups/{id}` | 그룹 단건 | ✅ Required | +| `POST` | `/api/v1/client-groups` | 그룹 생성 | ✅ Required | +| `PUT` | `/api/v1/client-groups/{id}` | 그룹 수정 | ✅ Required | +| `DELETE` | `/api/v1/client-groups/{id}` | 그룹 삭제 | ✅ Required | +| `PATCH` | `/api/v1/client-groups/{id}/toggle` | 그룹 활성/비활성 | ✅ Required | + +### 2.3 목록 조회 파라미터 (`GET /api/v1/clients`) + +| 파라미터 | 타입 | 설명 | 기본값 | +|---------|------|------|--------| +| `page` | integer | 페이지 번호 | 1 | +| `size` | integer | 페이지당 개수 | 20 | +| `q` | string | 검색어 (이름, 코드, 담당자) | - | +| `only_active` | boolean | 활성 거래처만 조회 | - | + +--- + +## 3. 데이터 모델 비교 + +### 3.1 필드 매핑 분석 + +| 프론트엔드 필드 | 백엔드 필드 | 상태 | 비고 | +|---------------|------------|------|------| +| `id` | `id` | ✅ 동일 | | +| `code` | `client_code` | ✅ 매핑 필요 | 필드명 변경 | +| `name` | `name` | ✅ 동일 | | +| `representative` | `contact_person` | ✅ 매핑 필요 | 필드명 변경 | +| `phone` | `phone` | ✅ 동일 | | +| `email` | `email` | ✅ 동일 | | +| `address` | `address` | ✅ 동일 | | +| `registeredDate` | `created_at` | ✅ 매핑 필요 | 필드명 변경 | +| `status` | `is_active` | ✅ 매핑 필요 | "활성"/"비활성" ↔ "Y"/"N" | +| `businessNo` | - | ❌ **백엔드 없음** | 추가 필요 | +| `businessType` | - | ❌ **백엔드 없음** | 추가 필요 | +| `businessItem` | - | ❌ **백엔드 없음** | 추가 필요 | +| - | `tenant_id` | ✅ 백엔드 전용 | 자동 처리 | +| - | `client_group_id` | ⚠️ 프론트 없음 | 그룹 기능 미구현 | + +### 3.2 백엔드 모델 필드 (Client.php) + +```php +protected $fillable = [ + 'tenant_id', + 'client_group_id', + 'client_code', // 거래처 코드 + 'name', // 거래처명 + 'contact_person', // 담당자 + 'phone', // 전화번호 + 'email', // 이메일 + 'address', // 주소 + 'is_active', // 활성 상태 (Y/N) +]; +``` + +--- + +## 4. 백엔드 수정 요청 사항 + +### 4.1 1차 필드 추가 ✅ 완료 (2025-12-04) + +| 필드명 | 타입 | 설명 | 상태 | +|--------|------|------|------| +| `business_no` | string(20) | 사업자등록번호 | ✅ 추가됨 | +| `business_type` | string(50) | 업태 | ✅ 추가됨 | +| `business_item` | string(100) | 업종 | ✅ 추가됨 | + +--- + +### 4.2 🚨 2차 필드 추가 요청 (sam-design 기준) - 2025-12-04 + +> **참고**: `sam-design/src/components/ClientRegistration.tsx` 기준으로 UI 구현 필요 +> 현재 백엔드 API에 누락된 필드들 추가 요청 + +#### 섹션 1: 기본 정보 추가 필드 +| 필드명 | 타입 | 설명 | nullable | 비고 | +|--------|------|------|----------|------| +| `client_type` | enum('매입','매출','매입매출') | 거래처 유형 | NO | 기본값 '매입' | + +#### 섹션 2: 연락처 정보 추가 필드 +| 필드명 | 타입 | 설명 | nullable | +|--------|------|------|----------| +| `mobile` | string(20) | 모바일 번호 | YES | +| `fax` | string(20) | 팩스 번호 | YES | + +#### 섹션 3: 담당자 정보 추가 필드 +| 필드명 | 타입 | 설명 | nullable | +|--------|------|------|----------| +| `manager_name` | string(50) | 담당자명 | YES | +| `manager_tel` | string(20) | 담당자 전화 | YES | +| `system_manager` | string(50) | 시스템 관리자 | YES | + +#### 섹션 4: 발주처 설정 추가 필드 +| 필드명 | 타입 | 설명 | nullable | +|--------|------|------|----------| +| `account_id` | string(50) | 계정 ID | YES | +| `account_password` | string(255) | 비밀번호 (암호화) | YES | +| `purchase_payment_day` | string(20) | 매입 결제일 | YES | +| `sales_payment_day` | string(20) | 매출 결제일 | YES | + +#### 섹션 5: 약정 세금 추가 필드 +| 필드명 | 타입 | 설명 | nullable | +|--------|------|------|----------| +| `tax_agreement` | boolean | 세금 약정 여부 | YES | +| `tax_amount` | decimal(15,2) | 약정 금액 | YES | +| `tax_start_date` | date | 약정 시작일 | YES | +| `tax_end_date` | date | 약정 종료일 | YES | + +#### 섹션 6: 악성채권 정보 추가 필드 +| 필드명 | 타입 | 설명 | nullable | +|--------|------|------|----------| +| `bad_debt` | boolean | 악성채권 여부 | YES | +| `bad_debt_amount` | decimal(15,2) | 악성채권 금액 | YES | +| `bad_debt_receive_date` | date | 채권 발생일 | YES | +| `bad_debt_end_date` | date | 채권 만료일 | YES | +| `bad_debt_progress` | enum('협의중','소송중','회수완료','대손처리') | 진행 상태 | YES | + +#### 섹션 7: 기타 정보 추가 필드 +| 필드명 | 타입 | 설명 | nullable | +|--------|------|------|----------| +| `memo` | text | 메모 | YES | + +--- + +### 4.3 마이그레이션 예시 + +```sql +-- 기본 정보 +ALTER TABLE clients ADD COLUMN client_type ENUM('매입','매출','매입매출') DEFAULT '매입'; + +-- 연락처 정보 +ALTER TABLE clients ADD COLUMN mobile VARCHAR(20) NULL; +ALTER TABLE clients ADD COLUMN fax VARCHAR(20) NULL; + +-- 담당자 정보 +ALTER TABLE clients ADD COLUMN manager_name VARCHAR(50) NULL; +ALTER TABLE clients ADD COLUMN manager_tel VARCHAR(20) NULL; +ALTER TABLE clients ADD COLUMN system_manager VARCHAR(50) NULL; + +-- 발주처 설정 +ALTER TABLE clients ADD COLUMN account_id VARCHAR(50) NULL; +ALTER TABLE clients ADD COLUMN account_password VARCHAR(255) NULL; +ALTER TABLE clients ADD COLUMN purchase_payment_day VARCHAR(20) NULL; +ALTER TABLE clients ADD COLUMN sales_payment_day VARCHAR(20) NULL; + +-- 약정 세금 +ALTER TABLE clients ADD COLUMN tax_agreement TINYINT(1) DEFAULT 0; +ALTER TABLE clients ADD COLUMN tax_amount DECIMAL(15,2) NULL; +ALTER TABLE clients ADD COLUMN tax_start_date DATE NULL; +ALTER TABLE clients ADD COLUMN tax_end_date DATE NULL; + +-- 악성채권 정보 +ALTER TABLE clients ADD COLUMN bad_debt TINYINT(1) DEFAULT 0; +ALTER TABLE clients ADD COLUMN bad_debt_amount DECIMAL(15,2) NULL; +ALTER TABLE clients ADD COLUMN bad_debt_receive_date DATE NULL; +ALTER TABLE clients ADD COLUMN bad_debt_end_date DATE NULL; +ALTER TABLE clients ADD COLUMN bad_debt_progress ENUM('협의중','소송중','회수완료','대손처리') NULL; + +-- 기타 정보 +ALTER TABLE clients ADD COLUMN memo TEXT NULL; +``` + +--- + +### 4.4 수정 필요 파일 목록 + +| 파일 | 수정 내용 | +|------|----------| +| `app/Models/Orders/Client.php` | fillable에 새 필드 추가, casts 설정 | +| `database/migrations/xxxx_add_client_extended_fields.php` | 마이그레이션 생성 | +| `app/Services/ClientService.php` | 새 필드 처리 로직 추가 | +| `app/Http/Requests/Client/ClientStoreRequest.php` | 유효성 검증 규칙 추가 | +| `app/Http/Requests/Client/ClientUpdateRequest.php` | 유효성 검증 규칙 추가 | +| `app/Swagger/v1/ClientApi.php` | API 문서 업데이트 | + +--- + +## 5. 프론트엔드 API 연동 구현 계획 + +### 5.1 필요한 작업 + +| # | 작업 | 우선순위 | 상태 | +|---|------|---------|------| +| 1 | Next.js API Proxy 생성 (`/api/proxy/clients/[...path]`) | 🔴 HIGH | ⬜ 미완료 | +| 2 | 커스텀 훅 생성 (`useClientList`) | 🔴 HIGH | ⬜ 미완료 | +| 3 | 타입 정의 업데이트 (`CustomerAccount` → API 응답 매핑) | 🟡 MEDIUM | ⬜ 미완료 | +| 4 | CRUD 함수를 API 호출로 변경 | 🔴 HIGH | ⬜ 미완료 | +| 5 | 거래처 그룹 기능 추가 (선택) | 🟢 LOW | ⬜ 미완료 | + +### 5.2 API Proxy 구현 패턴 + +```typescript +// /src/app/api/proxy/clients/route.ts +import { NextRequest, NextResponse } from 'next/server'; + +const BACKEND_URL = process.env.NEXT_PUBLIC_API_URL; + +export async function GET(request: NextRequest) { + const token = request.cookies.get('access_token')?.value; + const searchParams = request.nextUrl.searchParams; + + const response = await fetch( + `${BACKEND_URL}/api/v1/clients?${searchParams.toString()}`, + { + headers: { + 'Authorization': `Bearer ${token}`, + 'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '', + }, + } + ); + + return NextResponse.json(await response.json()); +} +``` + +### 5.3 useClientList 훅 구현 패턴 + +```typescript +// /src/hooks/useClientList.ts +export function useClientList() { + const [clients, setClients] = useState([]); + const [pagination, setPagination] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + const fetchClients = async (params: FetchParams) => { + setIsLoading(true); + const searchParams = new URLSearchParams({ + page: String(params.page || 1), + size: String(params.size || 20), + ...(params.q && { q: params.q }), + ...(params.onlyActive !== undefined && { only_active: String(params.onlyActive) }), + }); + + const response = await fetch(`/api/proxy/clients?${searchParams}`); + const data = await response.json(); + + setClients(data.data.data); + setPagination({ + currentPage: data.data.current_page, + lastPage: data.data.last_page, + total: data.data.total, + }); + setIsLoading(false); + }; + + return { clients, pagination, isLoading, fetchClients }; +} +``` + +--- + +## 6. 데이터 변환 유틸리티 + +### 6.1 API 응답 → 프론트엔드 타입 변환 + +```typescript +// API 응답 타입 +interface ClientApiResponse { + id: number; + client_code: string; + name: string; + contact_person: string | null; + phone: string | null; + email: string | null; + address: string | null; + is_active: 'Y' | 'N'; + created_at: string; + updated_at: string; +} + +// 프론트엔드 타입으로 변환 +function transformClient(api: ClientApiResponse): CustomerAccount { + return { + id: String(api.id), + code: api.client_code, + name: api.name, + representative: api.contact_person || '', + phone: api.phone || '', + email: api.email || '', + address: api.address || '', + businessNo: '', // TODO: 백엔드 필드 추가 후 매핑 + businessType: '', // TODO: 백엔드 필드 추가 후 매핑 + businessItem: '', // TODO: 백엔드 필드 추가 후 매핑 + registeredDate: api.created_at.split(' ')[0], + status: api.is_active === 'Y' ? '활성' : '비활성', + }; +} +``` + +### 6.2 프론트엔드 → API 요청 변환 + +```typescript +function transformToApiRequest(form: FormData): ClientCreateRequest { + return { + client_code: form.code, + name: form.name, + contact_person: form.representative || null, + phone: form.phone || null, + email: form.email || null, + address: form.address || null, + is_active: 'Y', + }; +} +``` + +--- + +## 7. 결론 및 권장 사항 + +### 7.1 즉시 진행 가능 (백엔드 변경 없이) + +1. ✅ API Proxy 생성 +2. ✅ useClientList 훅 구현 +3. ✅ 기본 CRUD 연동 (현재 백엔드 필드만 사용) + +### 7.2 백엔드 변경 필요 + +1. ⚠️ `business_no`, `business_type`, `business_item` 필드 추가 +2. ⚠️ ClientService, ClientStoreRequest, ClientUpdateRequest 업데이트 +3. ⚠️ Swagger 문서 업데이트 + +### 7.3 선택적 개선 + +1. 거래처 그룹 기능 프론트엔드 구현 +2. 거래처 상세 페이지 구현 +3. 엑셀 내보내기/가져오기 기능 + +--- + +## 참고 파일 + +### 백엔드 (sam-api) +- `app/Http/Controllers/Api/V1/ClientController.php` +- `app/Http/Controllers/Api/V1/ClientGroupController.php` +- `app/Services/ClientService.php` +- `app/Services/ClientGroupService.php` +- `app/Models/Orders/Client.php` +- `app/Models/Orders/ClientGroup.php` +- `app/Swagger/v1/ClientApi.php` +- `routes/api.php` (Line 316-333) + +### 프론트엔드 (sam-react-prod) +- `src/app/[locale]/(protected)/sales/client-management-sales-admin/page.tsx` \ No newline at end of file diff --git a/claudedocs/sales/[API-2025-12-04] quote-api-request.md b/claudedocs/sales/[API-2025-12-04] quote-api-request.md new file mode 100644 index 00000000..ebe91746 --- /dev/null +++ b/claudedocs/sales/[API-2025-12-04] quote-api-request.md @@ -0,0 +1,675 @@ +# 견적관리 API 요청서 + +> **작성일**: 2025-12-04 +> **목적**: 견적관리 기능을 위한 백엔드 API 요청 +> **참조**: sam-design/QuoteManagement3Write.tsx, QuoteManagement3Detail.tsx + +--- + +## 1. 개요 + +### 1.1 기능 요약 +견적관리 시스템은 다음 기능을 지원해야 합니다: +- 견적 CRUD (등록, 조회, 수정, 삭제) +- 견적 상태 관리 (임시저장 → 확정 → 수주전환) +- 견적 수정 이력 관리 (버전 관리) +- 견적 품목(BOM) 관리 +- 자동 견적 산출 (수식 기반 계산) ← **백엔드 구현** + +### 1.2 특이사항 +- **자동 견적 산출 로직**은 백엔드에서 구현 예정 (수식 계산 엔진) +- 프론트엔드는 입력값을 전달하고 계산 결과를 받아서 표시 + +--- + +## 2. 데이터 모델 + +### 2.1 Quote (견적) - 메인 엔티티 + +```typescript +interface Quote { + // === 기본 정보 === + id: number; + tenant_id: number; + quote_number: string; // 견적번호 (예: KD-SC-251204-01) + registration_date: string; // 등록일 (YYYY-MM-DD) + receipt_date: string; // 접수일 + author: string; // 작성자 + + // === 발주처 정보 === + client_id: number | null; // 거래처 ID (FK) + client_name: string; // 거래처명 (직접입력 대응) + manager: string | null; // 담당자 + contact: string | null; // 연락처 + + // === 현장 정보 === + site_id: number | null; // 현장 ID (FK, 별도 테이블 필요시) + site_name: string | null; // 현장명 + site_code: string | null; // 현장코드 + + // === 제품 정보 === + product_category: 'SCREEN' | 'STEEL'; // 제품 카테고리 + product_id: number | null; // 선택된 제품 ID (품목마스터 FK) + product_code: string | null; // 제품코드 + product_name: string | null; // 제품명 + + // === 규격 정보 === + open_size_width: number | null; // 오픈사이즈 폭 (mm) + open_size_height: number | null; // 오픈사이즈 높이 (mm) + quantity: number; // 수량 (기본값: 1) + unit_symbol: string | null; // 부호 + floors: string | null; // 층수 + + // === 금액 정보 === + material_cost: number; // 재료비 합계 + labor_cost: number; // 노무비 + install_cost: number; // 설치비 + subtotal: number; // 소계 + discount_rate: number; // 할인율 (%) + discount_amount: number; // 할인금액 + total_amount: number; // 최종 금액 + + // === 상태 관리 === + status: 'draft' | 'sent' | 'approved' | 'rejected' | 'finalized' | 'converted'; + current_revision: number; // 현재 수정 차수 (0부터 시작) + is_final: boolean; // 최종확정 여부 + finalized_at: string | null; // 확정일시 + finalized_by: number | null; // 확정자 ID + + // === 기타 정보 === + completion_date: string | null; // 납기일 + remarks: string | null; // 비고 + memo: string | null; // 메모 + notes: string | null; // 특이사항 + + // === 시스템 필드 === + created_at: string; + updated_at: string; + created_by: number | null; + updated_by: number | null; + deleted_at: string | null; // Soft Delete +} +``` + +### 2.2 QuoteItem (견적 품목) - BOM 계산 결과 + +```typescript +interface QuoteItem { + id: number; + quote_id: number; // 견적 ID (FK) + tenant_id: number; + + // === 품목 정보 === + item_id: number | null; // 품목마스터 ID (FK) + item_code: string; // 품목코드 + item_name: string; // 품명 + specification: string | null; // 규격 + unit: string; // 단위 + + // === 수량/금액 === + base_quantity: number; // 기본수량 + calculated_quantity: number; // 계산된 수량 + unit_price: number; // 단가 + total_price: number; // 금액 (수량 × 단가) + + // === 수식 정보 === + formula: string | null; // 수식 (예: "W/1000 + 0.1") + formula_source: string | null; // 수식 출처 (BOM템플릿, 제품BOM 등) + formula_category: string | null; // 수식 카테고리 + data_source: string | null; // 데이터 출처 + + // === 기타 === + delivery_date: string | null; // 품목별 납기일 + note: string | null; // 비고 + sort_order: number; // 정렬순서 + + created_at: string; + updated_at: string; +} +``` + +### 2.3 QuoteRevision (견적 수정 이력) + +```typescript +interface QuoteRevision { + id: number; + quote_id: number; // 견적 ID (FK) + tenant_id: number; + + revision_number: number; // 수정 차수 (1, 2, 3...) + revision_date: string; // 수정일 + revision_by: number; // 수정자 ID + revision_by_name: string; // 수정자 이름 + revision_reason: string | null; // 수정 사유 + + // 이전 버전 데이터 (JSON) + previous_data: object; // 수정 전 견적 전체 데이터 (스냅샷) + + created_at: string; +} +``` + +--- + +## 3. API 엔드포인트 + +### 3.1 견적 CRUD + +| Method | Endpoint | 설명 | 비고 | +|--------|----------|------|------| +| `GET` | `/api/v1/quotes` | 목록 조회 | 페이지네이션, 필터, 검색 | +| `GET` | `/api/v1/quotes/{id}` | 단건 조회 | 품목(items), 이력(revisions) 포함 | +| `POST` | `/api/v1/quotes` | 생성 | 품목 배열 포함 | +| `PUT` | `/api/v1/quotes/{id}` | 수정 | 수정이력 자동 생성 | +| `DELETE` | `/api/v1/quotes/{id}` | 삭제 | Soft Delete | +| `DELETE` | `/api/v1/quotes` | 일괄 삭제 | `ids[]` 파라미터 | + +### 3.2 견적 상태 관리 + +| Method | Endpoint | 설명 | 비고 | +|--------|----------|------|------| +| `PATCH` | `/api/v1/quotes/{id}/finalize` | 최종확정 | status → 'finalized', is_final → true | +| `PATCH` | `/api/v1/quotes/{id}/convert-to-order` | 수주전환 | status → 'converted', 수주 데이터 생성 | +| `PATCH` | `/api/v1/quotes/{id}/cancel-finalize` | 확정취소 | is_final → false (조건부) | + +### 3.3 자동 견적 산출 (핵심 기능) + +| Method | Endpoint | 설명 | 비고 | +|--------|----------|------|------| +| `POST` | `/api/v1/quotes/calculate` | 자동 산출 | **수식 계산 엔진** | +| `POST` | `/api/v1/quotes/{id}/recalculate` | 재계산 | 기존 견적 재산출 | + +### 3.4 견적 문서 출력 + +| Method | Endpoint | 설명 | 비고 | +|--------|----------|------|------| +| `GET` | `/api/v1/quotes/{id}/document/quote` | 견적서 PDF | | +| `GET` | `/api/v1/quotes/{id}/document/calculation` | 산출내역서 PDF | | +| `GET` | `/api/v1/quotes/{id}/document/purchase-order` | 발주서 PDF | | + +### 3.5 문서 발송 API ⭐ 신규 요청 + +| Method | Endpoint | 설명 | 비고 | +|--------|----------|------|------| +| `POST` | `/api/v1/quotes/{id}/send/email` | 이메일 발송 | 첨부파일 포함 | +| `POST` | `/api/v1/quotes/{id}/send/fax` | 팩스 발송 | 팩스 서비스 연동 | +| `POST` | `/api/v1/quotes/{id}/send/kakao` | 카카오톡 발송 | 알림톡/친구톡 | + +### 3.6 견적번호 생성 + +| Method | Endpoint | 설명 | 비고 | +|--------|----------|------|------| +| `GET` | `/api/v1/quotes/generate-number` | 견적번호 생성 | `?category=SCREEN` | + +--- + +## 4. 상세 API 명세 + +### 4.1 목록 조회 `GET /api/v1/quotes` + +**Query Parameters:** +``` +page: number (default: 1) +size: number (default: 20) +q: string (검색어 - 견적번호, 발주처, 담당자, 현장명) +status: string (상태 필터) +product_category: string (제품 카테고리) +client_id: number (발주처 ID) +date_from: string (등록일 시작) +date_to: string (등록일 종료) +sort_by: string (정렬 컬럼) +sort_order: 'asc' | 'desc' +``` + +**Response:** +```json +{ + "success": true, + "data": { + "current_page": 1, + "data": [ + { + "id": 1, + "quote_number": "KD-SC-251204-01", + "registration_date": "2025-12-04", + "client_name": "ABC건설", + "site_name": "강남 오피스텔 현장", + "product_category": "SCREEN", + "product_name": "전동스크린 A형", + "quantity": 10, + "total_amount": 15000000, + "status": "draft", + "current_revision": 0, + "is_final": false, + "created_at": "2025-12-04T10:00:00Z" + } + ], + "last_page": 5, + "per_page": 20, + "total": 100 + } +} +``` + +### 4.2 단건 조회 `GET /api/v1/quotes/{id}` + +**Response:** +```json +{ + "success": true, + "data": { + "id": 1, + "quote_number": "KD-SC-251204-01", + "registration_date": "2025-12-04", + "receipt_date": "2025-12-04", + "author": "김철수", + + "client_id": 10, + "client_name": "ABC건설", + "manager": "이영희", + "contact": "010-1234-5678", + + "site_id": 5, + "site_name": "강남 오피스텔 현장", + "site_code": "PJ-20251204-01", + + "product_category": "SCREEN", + "product_id": 100, + "product_code": "SCR-001", + "product_name": "전동스크린 A형", + + "open_size_width": 2000, + "open_size_height": 3000, + "quantity": 10, + "unit_symbol": "A", + "floors": "3층", + + "material_cost": 12000000, + "labor_cost": 1500000, + "install_cost": 1500000, + "subtotal": 15000000, + "discount_rate": 0, + "discount_amount": 0, + "total_amount": 15000000, + + "status": "draft", + "current_revision": 2, + "is_final": false, + + "completion_date": "2025-12-31", + "remarks": "급하게 진행 필요", + "memo": "", + "notes": "", + + "items": [ + { + "id": 1, + "item_code": "SCR-MOTOR-001", + "item_name": "스크린 모터", + "specification": "220V, 1/4HP", + "unit": "EA", + "base_quantity": 1, + "calculated_quantity": 10, + "unit_price": 150000, + "total_price": 1500000, + "formula": "Q", + "formula_source": "제품BOM", + "sort_order": 1 + } + ], + + "revisions": [ + { + "revision_number": 2, + "revision_date": "2025-12-04", + "revision_by_name": "김철수", + "revision_reason": "고객 요청으로 수량 변경" + }, + { + "revision_number": 1, + "revision_date": "2025-12-03", + "revision_by_name": "김철수", + "revision_reason": "단가 조정" + } + ], + + "created_at": "2025-12-04T10:00:00Z", + "updated_at": "2025-12-04T15:30:00Z" + } +} +``` + +### 4.3 생성 `POST /api/v1/quotes` + +**Request Body:** +```json +{ + "registration_date": "2025-12-04", + "receipt_date": "2025-12-04", + + "client_id": 10, + "client_name": "ABC건설", + "manager": "이영희", + "contact": "010-1234-5678", + + "site_id": 5, + "site_name": "강남 오피스텔 현장", + "site_code": "PJ-20251204-01", + + "product_category": "SCREEN", + "product_id": 100, + + "open_size_width": 2000, + "open_size_height": 3000, + "quantity": 10, + "unit_symbol": "A", + "floors": "3층", + + "completion_date": "2025-12-31", + "remarks": "급하게 진행 필요", + + "items": [ + { + "item_id": 50, + "item_code": "SCR-MOTOR-001", + "item_name": "스크린 모터", + "unit": "EA", + "base_quantity": 1, + "calculated_quantity": 10, + "unit_price": 150000, + "total_price": 1500000, + "formula": "Q", + "sort_order": 1 + } + ] +} +``` + +### 4.4 자동 산출 `POST /api/v1/quotes/calculate` ⭐ 핵심 + +**Request Body:** +```json +{ + "product_id": 100, + "product_category": "SCREEN", + "open_size_width": 2000, + "open_size_height": 3000, + "quantity": 10, + "floors": "3층", + "unit_symbol": "A", + + "options": { + "guide_rail_install_type": "벽부형", + "motor_power": "1/4HP", + "controller": "표준형", + "edge_wing_size": 50, + "inspection_fee": 0 + } +} +``` + +**Response:** +```json +{ + "success": true, + "data": { + "product_id": 100, + "product_name": "전동스크린 A형", + "product_category": "SCREEN", + + "open_size": { + "width": 2000, + "height": 3000 + }, + "quantity": 10, + + "items": [ + { + "item_id": 50, + "item_code": "SCR-MOTOR-001", + "item_name": "스크린 모터", + "specification": "220V, 1/4HP", + "unit": "EA", + "base_quantity": 1, + "calculated_quantity": 10, + "unit_price": 150000, + "total_price": 1500000, + "formula": "Q", + "formula_result": "10 × 1 = 10", + "formula_source": "제품BOM: 전동스크린 A형", + "data_source": "품목마스터 [SCR-MOTOR-001]" + }, + { + "item_id": 51, + "item_code": "SCR-RAIL-001", + "item_name": "가이드레일", + "specification": "알루미늄", + "unit": "M", + "base_quantity": 1, + "calculated_quantity": 60, + "unit_price": 15000, + "total_price": 900000, + "formula": "H/1000 × 2 × Q", + "formula_result": "(3000/1000) × 2 × 10 = 60", + "formula_source": "BOM템플릿: 스크린_표준", + "data_source": "품목마스터 [SCR-RAIL-001]" + } + ], + + "summary": { + "material_cost": 12000000, + "labor_cost": 1500000, + "install_cost": 1500000, + "subtotal": 15000000, + "total_amount": 15000000 + }, + + "calculation_info": { + "bom_template_used": "스크린_표준", + "formula_variables": { + "W": 2000, + "H": 3000, + "Q": 10 + }, + "calculated_at": "2025-12-04T10:00:00Z" + } + } +} +``` + +--- + +## 5. 수식 계산 엔진 (백엔드 구현 요청) + +### 5.1 수식 변수 + +| 변수 | 설명 | 예시 | +|------|------|------| +| `W` | 오픈사이즈 폭 (mm) | 2000 | +| `H` | 오픈사이즈 높이 (mm) | 3000 | +| `Q` | 수량 | 10 | + +### 5.2 수식 예시 + +``` +수량 그대로: Q +높이 기반: H/1000 +폭+높이: (W + H) / 1000 +가이드레일: H/1000 × 2 × Q +스크린원단: (W/1000 + 0.1) × (H/1000 + 0.3) × Q +``` + +### 5.3 반올림 규칙 + +| 규칙 | 설명 | +|------|------| +| `ceil` | 올림 | +| `floor` | 내림 | +| `round` | 반올림 | + +### 5.4 BOM 템플릿 연동 + +- 제품별 BOM 템플릿에서 수식 조회 +- 템플릿이 없으면 품목마스터 BOM 사용 +- 수식 + 단가로 자동 금액 계산 + +--- + +## 6. 상태 흐름도 + +``` +[신규등록] + ↓ +[draft] 임시저장 + ↓ (최종확정) +[finalized] 확정 + ↓ (수주전환) +[converted] 수주전환 +``` + +### 6.1 상태별 제약 + +| 상태 | 수정 가능 | 삭제 가능 | 비고 | +|------|----------|----------|------| +| `draft` | O | O | 자유롭게 수정 | +| `sent` | O | O | 발송 후 수정 가능 (이력 기록) | +| `finalized` | X | X | 확정 후 수정 불가 | +| `converted` | X | X | 수주전환 후 불변 | + +--- + +## 7. 프론트엔드 구현 현황 (2025-12-04 업데이트) + +### 7.1 구현 완료된 파일 + +| 파일 | 설명 | 상태 | +|------|------|------| +| `quote-management/page.tsx` | 견적 목록 페이지 | ✅ 완료 (샘플 데이터) | +| `quote-management/new/page.tsx` | 견적 등록 페이지 | ✅ 완료 | +| `quote-management/[id]/page.tsx` | 견적 상세 페이지 | ✅ 완료 | +| `quote-management/[id]/edit/page.tsx` | 견적 수정 페이지 | ✅ 완료 | +| `components/quotes/QuoteRegistration.tsx` | 견적 등록/수정 컴포넌트 | ✅ 완료 | +| `components/quotes/QuoteDocument.tsx` | 견적서 문서 컴포넌트 | ✅ 완료 | +| `components/quotes/QuoteCalculationReport.tsx` | 산출내역서 문서 컴포넌트 | ✅ 완료 | +| `components/quotes/PurchaseOrderDocument.tsx` | 발주서 문서 컴포넌트 | ✅ 완료 | + +### 7.2 UI 기능 구현 현황 + +| 기능 | 상태 | 비고 | +|------|------|------| +| 견적 목록 조회 | ✅ UI 완료 | 샘플 데이터, API 연동 필요 | +| 견적 검색/필터 | ✅ UI 완료 | 로컬 필터링, API 연동 필요 | +| 견적 등록 폼 | ✅ UI 완료 | API 연동 필요 | +| 견적 상세 페이지 | ✅ UI 완료 | API 연동 필요 | +| 견적 수정 폼 | ✅ UI 완료 | API 연동 필요 | +| 견적 삭제 | ✅ UI 완료 | 로컬 상태, API 연동 필요 | +| 견적 일괄 삭제 | ✅ UI 완료 | 로컬 상태, API 연동 필요 | +| 자동 견적 산출 | ⏳ 버튼만 | 백엔드 수식 엔진 필요 | +| 발주처 선택 | ⏳ 샘플 데이터 | `/api/v1/clients` 연동 필요 | +| 현장 선택 | ⏳ 샘플 데이터 | 발주처 연동 후 현장 API 필요 | +| 제품 선택 | ⏳ 샘플 데이터 | `/api/v1/item-masters` 연동 필요 | +| **견적서 모달** | ✅ UI 완료 | PDF/이메일/팩스/카톡 버튼, **발송 API 필요** | +| **산출내역서 모달** | ✅ UI 완료 | PDF/이메일/팩스/카톡 버튼, **발송 API 필요** | +| **발주서 모달** | ✅ UI 완료 | PDF/이메일/팩스/카톡 버튼, **발송 API 필요** | +| 최종확정 버튼 | ✅ UI 완료 | API 연동 필요 | + +### 7.3 견적 등록/수정 폼 필드 (구현 완료) + +**기본 정보 섹션:** +- 등록일 (readonly, 오늘 날짜) +- 작성자 (readonly, 로그인 사용자) +- 발주처 선택 * (필수) +- 현장명 (발주처 선택 시 연동) +- 발주 담당자 +- 연락처 +- 납기일 +- 비고 + +**자동 견적 산출 섹션 (동적 항목):** +- 층수 +- 부호 +- 제품 카테고리 (PC) * +- 제품명 * +- 오픈사이즈 (W0) * +- 오픈사이즈 (H0) * +- 가이드레일 설치 유형 (GT) * +- 모터 전원 (MP) * +- 연동제어기 (CT) * +- 수량 (QTY) * +- 마구리 날개치수 (WS) +- 검사비 (INSP) + +**기능:** +- 견적 항목 추가/복사/삭제 +- 자동 견적 산출 버튼 +- 샘플 데이터 생성 버튼 + +### 7.4 다음 단계 (API 연동) + +```typescript +// useQuoteList 훅 (목록) +const { + quotes, + pagination, + isLoading, + fetchQuotes, + deleteQuote, + bulkDelete +} = useQuoteList(); + +// useQuote 훅 (단건 CRUD) +const { + quote, + isLoading, + fetchQuote, + createQuote, + updateQuote, + finalizeQuote, + convertToOrder +} = useQuote(); + +// useQuoteCalculation 훅 (자동 산출) +const { + calculationResult, + isCalculating, + calculate, + recalculate +} = useQuoteCalculation(); +``` + +--- + +## 8. 관련 참조 + +### 8.1 거래처 API 연동 +- 발주처 선택 시 `/api/v1/clients` 연동 +- 직접입력 시 자동 등록 가능 + +### 8.2 현장 API (추후) +- 현장 선택 시 `/api/v1/sites` 연동 (별도 API 필요시) + +### 8.3 품목마스터 연동 +- 제품 선택 시 `/api/v1/item-masters` 연동 +- BOM 조회 시 품목마스터 BOM 활용 + +--- + +## 9. 요청 우선순위 + +| 순위 | API | 설명 | +|------|-----|------| +| P1 | 견적 CRUD | 기본 목록/등록/수정/삭제 | +| P1 | 자동 산출 | 수식 계산 엔진 (핵심) | +| P1 | 견적번호 생성 | 자동 채번 | +| P2 | 상태 관리 | 확정/수주전환 | +| P2 | 수정 이력 | 버전 관리 | +| P3 | 문서 출력 | PDF 생성 | + +--- + +## 10. 질문사항 + +1. **현장(Site) 테이블**: 별도 테이블로 관리할지? (거래처 하위 개념) +2. **수식 계산**: BOM 템플릿 테이블 구조는? +3. **문서 출력**: PDF 라이브러리 선정 (TCPDF, Dompdf 등) +4. **알림**: 견적 발송 시 이메일/카카오톡 연동 계획? diff --git a/claudedocs/sales/[IMPL-2025-12-04] client-management-api-integration.md b/claudedocs/sales/[IMPL-2025-12-04] client-management-api-integration.md new file mode 100644 index 00000000..1350c9c7 --- /dev/null +++ b/claudedocs/sales/[IMPL-2025-12-04] client-management-api-integration.md @@ -0,0 +1,170 @@ +# 거래처관리 API 연동 체크리스트 + +> **작성일**: 2025-12-04 +> **목적**: 거래처관리 페이지 API 연동 및 sam-design 기준 UI 구현 +ㅇ> **최종 업데이트**: 2025-12-04 ✅ 구현 완료 + +--- + +## ✅ 구현 완료 상태 + +> **완료**: sam-design 기준으로 전체 페이지 재구현 완료 +> - 등록, 수정, 상세 보기 페이지 생성 +> - 목록 페이지에서 모달 삭제 및 페이지 기반 네비게이션으로 변경 + +### sam-design 참조 파일 +- `sam-design/src/components/ClientRegistration.tsx` +- `sam-design/src/components/templates/ResponsiveFormTemplate.tsx` + +--- + +## 작업 현황 + +### ✅ 1차 완료 작업 (기본 API 연동) + +#### 1. 기반 작업 +- [x] API 분석 문서 생성 (`[API-2025-12-04] client-api-analysis.md`) +- [x] Catch-all API Proxy 확인 (`/api/proxy/[...path]` 존재) +- [x] PATCH 메서드 프록시 추가 - toggle 엔드포인트용 +- [x] 백엔드 1차 필드 추가 완료 (business_no, business_type, business_item) + +#### 2. 기본 훅 구현 +- [x] useClientList 훅 생성 (기본 CRUD) +- [x] useClientGroupList 훅 생성 (그룹 CRUD) + +#### 3. 기본 페이지 연동 +- [x] 목록 조회, 페이지네이션, 검색 연동 +- [x] 기본 CRUD 연동 (생성, 수정, 삭제) + +--- + +### ✅ 2차 완료 작업 (sam-design 기준 재구현) - 2025-12-04 + +#### Phase 1: 백엔드 API 필드 추가 요청 ⏳ 대기중 + +**추가 요청 필드 (총 19개)**: + +| 섹션 | 필드명 | 설명 | 프론트 지원 | +|------|--------|------|------------| +| 기본 정보 | `client_type` | 거래처 유형 (매입/매출/매입매출) | ✅ | +| 연락처 | `mobile` | 모바일 번호 | ✅ | +| 연락처 | `fax` | 팩스 번호 | ✅ | +| 담당자 | `manager_name` | 담당자명 | ✅ | +| 담당자 | `manager_tel` | 담당자 전화 | ✅ | +| 담당자 | `system_manager` | 시스템 관리자 | ✅ | +| 발주처 | `account_id` | 계정 ID | ✅ | +| 발주처 | `account_password` | 비밀번호 | ✅ | +| 발주처 | `purchase_payment_day` | 매입 결제일 | ✅ | +| 발주처 | `sales_payment_day` | 매출 결제일 | ✅ | +| 약정세금 | `tax_agreement` | 세금 약정 여부 | ✅ | +| 약정세금 | `tax_amount` | 약정 금액 | ✅ | +| 약정세금 | `tax_start_date` | 약정 시작일 | ✅ | +| 약정세금 | `tax_end_date` | 약정 종료일 | ✅ | +| 악성채권 | `bad_debt` | 악성채권 여부 | ✅ | +| 악성채권 | `bad_debt_amount` | 악성채권 금액 | ✅ | +| 악성채권 | `bad_debt_receive_date` | 채권 발생일 | ✅ | +| 악성채권 | `bad_debt_end_date` | 채권 만료일 | ✅ | +| 악성채권 | `bad_debt_progress` | 진행 상태 | ✅ | +| 기타 | `memo` | 메모 | ✅ | + +> **참고**: 프론트엔드는 모든 필드를 지원하도록 구현 완료. 백엔드 API 필드 추가 후 즉시 사용 가능. + +--- + +#### Phase 2: 프론트엔드 재구현 ✅ 완료 + +- [x] **useClientList 훅 확장** + - [x] 새 필드들 타입 정의 추가 (19개 필드) + - [x] 변환 함수 업데이트 (transformClientToApiCreate, transformClientToApiUpdate, clientToFormData) + +- [x] **거래처 등록/수정 페이지 생성** + - [x] `ClientRegistration.tsx` 컴포넌트 생성 (sam-design 복제) + - [x] ResponsiveFormTemplate 적용 + - [x] 7개 섹션 폼 구현 + - [x] 기본 정보 섹션 + - [x] 연락처 정보 섹션 + - [x] 담당자 정보 섹션 + - [x] 발주처 설정 섹션 + - [x] 약정 세금 섹션 + - [x] 악성채권 정보 섹션 + - [x] 기타 정보 섹션 + - [x] 유효성 검사 구현 + - [x] API 연동 + +- [x] **거래처 상세 페이지 생성** + - [x] `ClientDetail.tsx` 컴포넌트 생성 + - [x] 4개 섹션 (기본정보, 연락처, 결제정보, 악성채권) + - [x] 삭제 확인 다이얼로그 + +- [x] **라우팅 설정** + - [x] 등록 페이지: `/sales/client-management-sales-admin/new` + - [x] 상세 페이지: `/sales/client-management-sales-admin/[id]` + - [x] 수정 페이지: `/sales/client-management-sales-admin/[id]/edit` + +- [x] **목록 페이지 수정** + - [x] "거래처 등록" 버튼 → 등록 페이지 이동 + - [x] 수정 버튼 → 수정 페이지 이동 + - [x] 행 클릭 → 상세 페이지 이동 + - [x] 기존 모달 삭제 + +--- + +## API 엔드포인트 정리 + +### Client (거래처) API +| Method | Endpoint | 설명 | 프록시 | +|--------|----------|------|--------| +| `GET` | `/api/proxy/clients` | 목록 조회 | ✅ | +| `GET` | `/api/proxy/clients/{id}` | 단건 조회 | ✅ | +| `POST` | `/api/proxy/clients` | 생성 | ✅ | +| `PUT` | `/api/proxy/clients/{id}` | 수정 | ✅ | +| `DELETE` | `/api/proxy/clients/{id}` | 삭제 | ✅ | +| `PATCH` | `/api/proxy/clients/{id}/toggle` | 활성/비활성 | ✅ | + +### Client Group (거래처 그룹) API +| Method | Endpoint | 설명 | 프록시 | +|--------|----------|------|--------| +| `GET` | `/api/proxy/client-groups` | 목록 조회 | ✅ | +| `POST` | `/api/proxy/client-groups` | 생성 | ✅ | +| `PUT` | `/api/proxy/client-groups/{id}` | 수정 | ✅ | +| `DELETE` | `/api/proxy/client-groups/{id}` | 삭제 | ✅ | + +--- + +## 파일 변경 목록 + +### 1차 완료 +| 파일 | 작업 | 상태 | +|------|------|------| +| `/src/app/api/proxy/[...path]/route.ts` | PATCH 메서드 추가 | ✅ | +| `/src/hooks/useClientList.ts` | 기본 CRUD 훅 | ✅ | +| `/src/hooks/useClientGroupList.ts` | 그룹 CRUD 훅 | ✅ | + +### 2차 완료 +| 파일 | 작업 | 상태 | +|------|------|------| +| `/src/hooks/useClientList.ts` | 확장 필드 추가 (19개) | ✅ 완료 | +| `/src/components/clients/ClientRegistration.tsx` | 신규 생성 (sam-design 복제) | ✅ 완료 | +| `/src/components/clients/ClientDetail.tsx` | 상세 보기 컴포넌트 | ✅ 완료 | +| `/src/components/ui/radio-group.tsx` | RadioGroup UI 컴포넌트 | ✅ 완료 | +| `/src/app/[locale]/(protected)/sales/client-management-sales-admin/new/page.tsx` | 등록 페이지 | ✅ 완료 | +| `/src/app/[locale]/(protected)/sales/client-management-sales-admin/[id]/page.tsx` | 상세 페이지 | ✅ 완료 | +| `/src/app/[locale]/(protected)/sales/client-management-sales-admin/[id]/edit/page.tsx` | 수정 페이지 | ✅ 완료 | +| `/src/app/[locale]/(protected)/sales/client-management-sales-admin/page.tsx` | 목록 페이지 (모달 삭제, 페이지 이동) | ✅ 완료 | + +--- + +## 참고 문서 + +- `[API-2025-12-04] client-api-analysis.md` - 백엔드 API 상세 분석 및 추가 요청 +- `sam-design/src/components/ClientRegistration.tsx` - UI 참조 +- `sam-design/src/components/templates/ResponsiveFormTemplate.tsx` - 템플릿 참조 + +--- + +## 다음 단계 (백엔드 의존) + +백엔드에서 19개 필드 추가 완료 후: +1. API 응답에서 새 필드들 확인 +2. 필요시 변환 함수 조정 +3. UI 테스트 및 검증 \ No newline at end of file diff --git a/claudedocs/sales/[PLAN-2025-12-04] quote-management-implementation.md b/claudedocs/sales/[PLAN-2025-12-04] quote-management-implementation.md new file mode 100644 index 00000000..7cef9fee --- /dev/null +++ b/claudedocs/sales/[PLAN-2025-12-04] quote-management-implementation.md @@ -0,0 +1,346 @@ +# 견적관리 API 연동 작업계획서 + +> **작성일**: 2025-12-04 +> **목적**: 견적관리 페이지 API 연동 및 기능 구현 +> **선행 조건**: 백엔드 API 완료 후 진행 +> **참조**: `[API-2025-12-04] quote-api-request.md` + +--- + +## 1. 작업 개요 + +### 1.1 현재 상태 +| 항목 | 상태 | 비고 | +|------|------|------| +| 목록 페이지 UI | ✅ 완료 | sam-design 마이그레이션 완료 | +| 등록/수정 화면 | ⏳ 대기 | sam-design 마이그레이션 필요 | +| 상세 화면 | ⏳ 대기 | sam-design 마이그레이션 필요 | +| API 연동 | ❌ 미완료 | 백엔드 API 대기중 | +| 자동 산출 | ❌ 미완료 | 백엔드 수식 엔진 대기중 | + +### 1.2 작업 목표 +1. API 훅 생성 (useQuoteList, useQuote, useQuoteCalculation) +2. 목록 페이지 API 연동 +3. 등록/수정 화면 마이그레이션 및 API 연동 +4. 상세 화면 마이그레이션 및 API 연동 +5. 자동 산출 기능 연동 + +--- + +## 2. 작업 단계 + +### Phase 1: 기반 작업 (백엔드 API 완료 후) + +#### 1.1 API Proxy 확인 +- [ ] `/api/proxy/[...path]` 라우트에 quotes 엔드포인트 지원 확인 +- [ ] PATCH 메서드 지원 확인 (상태 변경용) + +#### 1.2 타입 정의 +- [ ] `/src/types/quote.ts` 생성 + - Quote 인터페이스 + - QuoteItem 인터페이스 + - QuoteRevision 인터페이스 + - QuoteFormData 인터페이스 + - QuoteCalculationRequest/Response 인터페이스 + +--- + +### Phase 2: API 훅 생성 + +#### 2.1 useQuoteList 훅 +``` +파일: /src/hooks/useQuoteList.ts +``` + +**기능:** +- [ ] 목록 조회 (페이지네이션, 검색, 필터) +- [ ] 단건 삭제 +- [ ] 일괄 삭제 +- [ ] 상태 필터링 (전체/최초작성/수정중/최종확정/수주전환) + +**구현 내용:** +```typescript +export function useQuoteList() { + return { + quotes, + pagination, + isLoading, + error, + fetchQuotes, + deleteQuote, + bulkDeleteQuotes, + }; +} +``` + +#### 2.2 useQuote 훅 +``` +파일: /src/hooks/useQuote.ts +``` + +**기능:** +- [ ] 단건 조회 (품목, 이력 포함) +- [ ] 생성 +- [ ] 수정 (이력 자동 생성) +- [ ] 최종확정 +- [ ] 수주전환 +- [ ] 견적번호 생성 + +**구현 내용:** +```typescript +export function useQuote() { + return { + quote, + isLoading, + isSaving, + error, + fetchQuote, + createQuote, + updateQuote, + finalizeQuote, + convertToOrder, + generateQuoteNumber, + }; +} +``` + +#### 2.3 useQuoteCalculation 훅 +``` +파일: /src/hooks/useQuoteCalculation.ts +``` + +**기능:** +- [ ] 자동 견적 산출 요청 +- [ ] 재계산 요청 +- [ ] 계산 결과 변환 + +**구현 내용:** +```typescript +export function useQuoteCalculation() { + return { + calculationResult, + isCalculating, + error, + calculate, + recalculate, + clearResult, + }; +} +``` + +--- + +### Phase 3: 목록 페이지 API 연동 + +``` +파일: /src/app/[locale]/(protected)/sales/quote-management/page.tsx +``` + +#### 3.1 기존 목업 데이터 교체 +- [ ] SAMPLE_QUOTES 삭제 +- [ ] useQuoteList 훅 연결 +- [ ] useEffect로 초기 데이터 로드 + +#### 3.2 페이지네이션 연동 +- [ ] currentPage, totalPages API 연결 +- [ ] 페이지 변경 시 fetchQuotes 호출 + +#### 3.3 검색/필터 연동 +- [ ] 검색어 디바운스 처리 (300ms) +- [ ] 탭(상태) 변경 시 필터 적용 +- [ ] 검색 파라미터 API 전달 + +#### 3.4 삭제 기능 연동 +- [ ] 단건 삭제 API 연결 +- [ ] 일괄 삭제 API 연결 +- [ ] 삭제 후 목록 새로고침 + +#### 3.5 통계 데이터 연동 +- [ ] 이번 달 견적 금액 +- [ ] 진행중 견적 금액 +- [ ] 이번 주 신규 견적 +- [ ] 이번 달 수주 전환율 + +--- + +### Phase 4: 등록/수정 화면 마이그레이션 + +#### 4.1 컴포넌트 마이그레이션 +``` +소스: sam-design/QuoteManagement3Write.tsx (1,790줄) +타겟: /src/app/[locale]/(protected)/sales/quote-management/write/page.tsx +``` + +- [ ] 기본 정보 섹션 마이그레이션 +- [ ] 발주처 선택 (직접입력 포함) +- [ ] 현장 선택 (직접입력 포함) +- [ ] 자동 산출 섹션 연동 + +#### 4.2 라우팅 설정 +``` +/sales/quote-management/write → 신규 등록 +/sales/quote-management/[id]/edit → 수정 +``` + +#### 4.3 자동 산출 연동 (핵심) +- [ ] useQuoteCalculation 훅 연결 +- [ ] 제품 선택 → 오픈사이즈 입력 → 자동 산출 호출 +- [ ] 계산 결과 BOM 테이블 표시 +- [ ] 품목별 수량/단가 수정 가능 + +#### 4.4 저장 로직 +- [ ] 신규 등록: createQuote API 호출 +- [ ] 수정: updateQuote API 호출 (수정사유 입력 필요) +- [ ] 저장 후 목록 또는 상세로 이동 + +--- + +### Phase 5: 상세 화면 마이그레이션 + +#### 5.1 컴포넌트 마이그레이션 +``` +소스: sam-design/QuoteManagement3Detail.tsx (878줄) +타겟: /src/app/[locale]/(protected)/sales/quote-management/[id]/page.tsx +``` + +- [ ] 기본 정보 표시 +- [ ] 자동 산출 정보 표시 +- [ ] BOM 계산 결과 테이블 +- [ ] 수정 이력 표시 + +#### 5.2 액션 버튼 연동 +- [ ] 수정 버튼 → edit 페이지로 이동 +- [ ] 최종확정 → finalizeQuote API +- [ ] 수주전환 → convertToOrder API + +#### 5.3 문서 출력 (Phase 6에서 진행) +- [ ] 견적서 PDF +- [ ] 산출내역서 PDF +- [ ] 발주서 PDF + +--- + +### Phase 6: 문서 출력 기능 (Optional) + +#### 6.1 견적서 다이얼로그 +- [ ] QuoteCalculationReport 컴포넌트 마이그레이션 +- [ ] PDF 다운로드 기능 + +#### 6.2 산출내역서 다이얼로그 +- [ ] 표시 옵션 (산출내역서/소요자재 체크박스) +- [ ] PDF 다운로드 기능 + +#### 6.3 발주서 다이얼로그 +- [ ] PurchaseOrderDocument 컴포넌트 마이그레이션 +- [ ] PDF 다운로드 기능 + +--- + +## 3. 파일 구조 + +``` +src/ +├── types/ +│ └── quote.ts # 타입 정의 +├── hooks/ +│ ├── useQuoteList.ts # 목록 훅 +│ ├── useQuote.ts # CRUD 훅 +│ └── useQuoteCalculation.ts # 자동 산출 훅 +└── app/[locale]/(protected)/sales/quote-management/ + ├── page.tsx # 목록 (기존) + ├── write/ + │ └── page.tsx # 등록 + ├── [id]/ + │ ├── page.tsx # 상세 + │ └── edit/ + │ └── page.tsx # 수정 + └── components/ + ├── QuoteForm.tsx # 등록/수정 폼 + ├── QuoteDetail.tsx # 상세 뷰 + ├── QuoteCalculation.tsx # 자동 산출 섹션 + ├── QuoteBOMTable.tsx # BOM 테이블 + └── QuoteDocuments.tsx # 문서 출력 다이얼로그 +``` + +--- + +## 4. 체크리스트 + +### Phase 1: 기반 작업 +- [ ] API Proxy 확인 +- [ ] 타입 정의 파일 생성 + +### Phase 2: API 훅 +- [ ] useQuoteList.ts 생성 +- [ ] useQuote.ts 생성 +- [ ] useQuoteCalculation.ts 생성 + +### Phase 3: 목록 API 연동 +- [ ] 목업 데이터 삭제 +- [ ] 훅 연결 및 초기 로드 +- [ ] 페이지네이션 연동 +- [ ] 검색/필터 연동 +- [ ] 삭제 기능 연동 +- [ ] 통계 데이터 연동 + +### Phase 4: 등록/수정 화면 +- [ ] write/page.tsx 생성 +- [ ] [id]/edit/page.tsx 생성 +- [ ] QuoteForm 컴포넌트 생성 +- [ ] 발주처/현장 선택 기능 +- [ ] 자동 산출 연동 +- [ ] 저장 로직 연동 + +### Phase 5: 상세 화면 +- [ ] [id]/page.tsx 생성 +- [ ] QuoteDetail 컴포넌트 생성 +- [ ] 최종확정/수주전환 연동 +- [ ] 수정 이력 표시 + +### Phase 6: 문서 출력 (Optional) +- [ ] 견적서 PDF +- [ ] 산출내역서 PDF +- [ ] 발주서 PDF + +--- + +## 5. 의존성 + +### 5.1 백엔드 API 의존 +| API | 필요 시점 | 상태 | +|-----|----------|------| +| 견적 CRUD | Phase 2 | ⏳ 대기 | +| 자동 산출 | Phase 4 | ⏳ 대기 | +| 상태 변경 | Phase 5 | ⏳ 대기 | +| 문서 출력 | Phase 6 | ⏳ 대기 | + +### 5.2 프론트엔드 의존 +| 컴포넌트 | 상태 | 비고 | +|----------|------|------| +| IntegratedListTemplateV2 | ✅ 완료 | 목록 템플릿 | +| useClientList | ✅ 완료 | 발주처 선택 | +| useClientGroupList | ✅ 완료 | 발주처 그룹 | + +--- + +## 6. 예상 작업량 + +| Phase | 작업 내용 | 예상 시간 | +|-------|----------|----------| +| Phase 1 | 기반 작업 | 1-2시간 | +| Phase 2 | API 훅 생성 | 2-3시간 | +| Phase 3 | 목록 API 연동 | 2-3시간 | +| Phase 4 | 등록/수정 마이그레이션 | 4-6시간 | +| Phase 5 | 상세 화면 마이그레이션 | 2-3시간 | +| Phase 6 | 문서 출력 (Optional) | 2-3시간 | +| **총합** | | **13-20시간** | + +--- + +## 7. 참고 문서 + +- `[API-2025-12-04] quote-api-request.md` - API 요청서 +- `[PLAN-2025-12-02] sales-pages-migration.md` - 마이그레이션 계획 +- `/src/hooks/useClientList.ts` - 거래처 훅 참고 +- sam-design/QuoteManagement3Write.tsx - 등록/수정 소스 +- sam-design/QuoteManagement3Detail.tsx - 상세 화면 소스 diff --git a/package-lock.json b/package-lock.json index 61ae6c92..d443f8ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-popover": "^1.1.6", "@radix-ui/react-progress": "^1.1.8", + "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-slot": "^1.2.4", @@ -2489,6 +2490,79 @@ } } }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz", + "integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-roving-focus": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", diff --git a/package.json b/package.json index 9a73cca8..d8393794 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-popover": "^1.1.6", "@radix-ui/react-progress": "^1.1.8", + "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-slot": "^1.2.4", diff --git a/src/app/[locale]/(protected)/items/[id]/edit/page.tsx b/src/app/[locale]/(protected)/items/[id]/edit/page.tsx index a9508292..09b6e818 100644 --- a/src/app/[locale]/(protected)/items/[id]/edit/page.tsx +++ b/src/app/[locale]/(protected)/items/[id]/edit/page.tsx @@ -1,208 +1,347 @@ /** * 품목 수정 페이지 + * + * API 연동: + * - GET /api/proxy/items/code/{itemCode}?include_bom=true (품목 조회) + * - PUT /api/proxy/items/{id} (품목 수정) */ 'use client'; import { useEffect, useState } from 'react'; -import { useParams, useRouter } from 'next/navigation'; -import ItemForm from '@/components/items/ItemForm'; -import type { ItemMaster } from '@/types/item'; -import type { CreateItemFormData } from '@/lib/utils/validation'; +import { useParams, useRouter, useSearchParams } from 'next/navigation'; +import DynamicItemForm from '@/components/items/DynamicItemForm'; +import type { DynamicFormData } from '@/components/items/DynamicItemForm/types'; +import type { ItemType } from '@/types/item'; +import { Loader2 } from 'lucide-react'; -// Mock 데이터 (API 연동 전 임시) -const mockItems: ItemMaster[] = [ - { - id: '1', - itemCode: 'KD-FG-001', - itemName: '스크린 제품 A', - itemType: 'FG', - unit: 'EA', - specification: '2000x2000', - isActive: true, - category1: '본체부품', - category2: '가이드시스템', - salesPrice: 150000, - purchasePrice: 100000, - marginRate: 33.3, - processingCost: 20000, - laborCost: 15000, - installCost: 10000, - productCategory: 'SCREEN', - lotAbbreviation: 'KD', - note: '스크린 제품 샘플입니다.', - safetyStock: 10, - leadTime: 7, - currentRevision: 0, - isFinal: false, - createdAt: '2025-01-10T00:00:00Z', - updatedAt: '2025-01-12T00:00:00Z', - bom: [ - { - id: 'bom-1', - childItemCode: 'KD-PT-001', - childItemName: '가이드레일(벽면형)', - quantity: 2, - unit: 'EA', - unitPrice: 35000, - quantityFormula: 'H / 1000', - }, - { - id: 'bom-2', - childItemCode: 'KD-PT-002', - childItemName: '절곡품 샘플', - quantity: 4, - unit: 'EA', - unitPrice: 30000, - isBending: true, - }, - { - id: 'bom-3', - childItemCode: 'KD-SM-001', - childItemName: '볼트 M6x20', - quantity: 20, - unit: 'EA', - unitPrice: 50, - }, - ], - }, - { - id: '2', - itemCode: 'KD-PT-001', - itemName: '가이드레일(벽면형)', - itemType: 'PT', - unit: 'EA', - specification: '2438mm', - isActive: true, - category1: '본체부품', - category2: '가이드시스템', - category3: '가이드레일', - salesPrice: 50000, - purchasePrice: 35000, - marginRate: 30, - partType: 'ASSEMBLY', - partUsage: 'GUIDE_RAIL', - installationType: '벽면형', - assemblyType: 'M', - assemblyLength: '2438', - currentRevision: 0, - isFinal: false, - createdAt: '2025-01-10T00:00:00Z', - }, - { - id: '3', - itemCode: 'KD-PT-002', - itemName: '절곡품 샘플', - itemType: 'PT', - unit: 'EA', - specification: 'EGI 1.55T', - isActive: true, - partType: 'BENDING', - material: 'EGI 1.55T', - length: '2000', - salesPrice: 30000, - currentRevision: 0, - isFinal: false, - createdAt: '2025-01-10T00:00:00Z', - }, - { - id: '4', - itemCode: 'KD-RM-001', - itemName: 'SPHC-SD', - itemType: 'RM', - unit: 'KG', - specification: '1.6T x 1219 x 2438', - isActive: true, - category1: '철강재', - purchasePrice: 1500, - material: 'SPHC-SD', - currentRevision: 0, - isFinal: false, - createdAt: '2025-01-10T00:00:00Z', - }, - { - id: '5', - itemCode: 'KD-SM-001', - itemName: '볼트 M6x20', - itemType: 'SM', - unit: 'EA', - specification: 'M6x20', - isActive: true, - category1: '구조재/부속품', - category2: '볼트/너트', - purchasePrice: 50, - currentRevision: 0, - isFinal: false, - createdAt: '2025-01-10T00:00:00Z', - }, -]; +// Materials 타입 (SM, RM, CS는 Material 테이블 사용) +const MATERIAL_TYPES = ['SM', 'RM', 'CS']; + +/** + * API 응답 타입 (백엔드 Product 모델 기준) + * + * 백엔드 필드명: code, name, product_type (item_code, item_name, item_type 아님!) + */ +interface ItemApiResponse { + id: number; + // 백엔드 Product 모델 필드 + code: string; + name: string; + product_type: string; + // 기존 필드도 fallback으로 유지 + item_code?: string; + item_name?: string; + item_type?: string; + unit?: string; + specification?: string; + is_active?: boolean; + description?: string; + note?: string; + part_type?: string; + part_usage?: string; + material?: string; + length?: string; + thickness?: string; + installation_type?: string; + assembly_type?: string; + assembly_length?: string; + side_spec_width?: string; + side_spec_height?: string; + product_category?: string; + lot_abbreviation?: string; + certification_number?: string; + certification_start_date?: string; + certification_end_date?: string; + [key: string]: unknown; +} + +/** + * API 응답을 DynamicFormData로 변환 + * + * API snake_case 필드를 폼 field_key로 매핑 + * (품목기준관리 API의 field_key가 snake_case 형식) + */ +function mapApiResponseToFormData(data: ItemApiResponse): DynamicFormData { + const formData: DynamicFormData = {}; + + // 백엔드 Product 모델 필드: code, name, product_type + // 프론트엔드 폼 필드: item_name, item_code 등 (snake_case) + + // 기본 필드 (백엔드 name → 폼 item_name) + const itemName = data.name || data.item_name; + if (itemName) formData['item_name'] = itemName; + if (data.unit) formData['unit'] = data.unit; + if (data.specification) formData['specification'] = data.specification; + if (data.description) formData['description'] = data.description; + if (data.note) formData['note'] = data.note; + formData['is_active'] = data.is_active ?? true; + + // 부품 관련 필드 (PT) + if (data.part_type) formData['part_type'] = data.part_type; + if (data.part_usage) formData['part_usage'] = data.part_usage; + if (data.material) formData['material'] = data.material; + if (data.length) formData['length'] = data.length; + if (data.thickness) formData['thickness'] = data.thickness; + + // 조립 부품 관련 + if (data.installation_type) formData['installation_type'] = data.installation_type; + if (data.assembly_type) formData['assembly_type'] = data.assembly_type; + if (data.assembly_length) formData['assembly_length'] = data.assembly_length; + if (data.side_spec_width) formData['side_spec_width'] = data.side_spec_width; + if (data.side_spec_height) formData['side_spec_height'] = data.side_spec_height; + + // 제품 관련 필드 (FG) + if (data.product_category) formData['product_category'] = data.product_category; + if (data.lot_abbreviation) formData['lot_abbreviation'] = data.lot_abbreviation; + + // 인정 정보 + if (data.certification_number) formData['certification_number'] = data.certification_number; + if (data.certification_start_date) formData['certification_start_date'] = data.certification_start_date; + if (data.certification_end_date) formData['certification_end_date'] = data.certification_end_date; + + // 기타 동적 필드들 (API에서 받은 모든 필드를 포함) + Object.entries(data).forEach(([key, value]) => { + // 이미 처리한 특수 필드들 제외 (백엔드 필드명 + 기존 필드명) + const excludeKeys = [ + 'id', 'code', 'name', 'product_type', // 백엔드 Product 모델 필드 + 'item_code', 'item_name', 'item_type', // 기존 호환 필드 + 'created_at', 'updated_at', 'deleted_at', 'bom', + 'tenant_id', 'category_id', 'category', 'component_lines', + ]; + if (!excludeKeys.includes(key) && value !== null && value !== undefined) { + // 아직 설정 안된 필드만 추가 + if (!(key in formData)) { + formData[key] = value as DynamicFormData[string]; + } + } + }); + + return formData; +} export default function EditItemPage() { const params = useParams(); const router = useRouter(); - const [item, setItem] = useState(null); + const searchParams = useSearchParams(); + const [itemId, setItemId] = useState(null); + const [itemType, setItemType] = useState(null); + const [initialData, setInitialData] = useState(null); const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + // URL에서 type과 id 쿼리 파라미터 읽기 + const urlItemType = searchParams.get('type') || 'FG'; + const urlItemId = searchParams.get('id'); + + // 품목 데이터 로드 useEffect(() => { - // TODO: API 연동 시 fetchItemByCode() 호출 const fetchItem = async () => { - setIsLoading(true); + if (!params.id || typeof params.id !== 'string') { + setError('잘못된 품목 ID입니다.'); + setIsLoading(false); + return; + } + try { - // params.id 타입 체크 - if (!params.id || typeof params.id !== 'string') { - alert('잘못된 품목 ID입니다.'); - router.push('/items'); + setIsLoading(true); + const itemCode = decodeURIComponent(params.id); + // console.log('[EditItem] Fetching item:', { itemCode, urlItemType, urlItemId }); + + let response: Response; + + // Materials (SM, RM, CS)는 다른 API 엔드포인트 사용 + if (MATERIAL_TYPES.includes(urlItemType) && urlItemId) { + // GET /api/proxy/items/{id}?item_type=MATERIAL + // console.log('[EditItem] Using Material API'); + response = await fetch(`/api/proxy/items/${urlItemId}?item_type=MATERIAL`); + } else { + // Products (FG, PT): GET /api/proxy/items/code/{itemCode}?include_bom=true + // console.log('[EditItem] Using Product API'); + response = await fetch(`/api/proxy/items/code/${encodeURIComponent(itemCode)}?include_bom=true`); + } + + if (!response.ok) { + if (response.status === 404) { + setError('품목을 찾을 수 없습니다.'); + } else { + const errorData = await response.json().catch(() => null); + setError(errorData?.message || `오류 발생 (${response.status})`); + } + setIsLoading(false); return; } - // Mock: 데이터 조회 - const itemCode = decodeURIComponent(params.id); - const foundItem = mockItems.find((item) => item.itemCode === itemCode); + const result = await response.json(); + // console.log('[EditItem] API Response:', result); - if (foundItem) { - setItem(foundItem); + if (result.success && result.data) { + const apiData = result.data as ItemApiResponse; + + // ID, 품목 유형 저장 + // Product: product_type, Material: material_type 또는 type_code + setItemId(apiData.id); + const resolvedItemType = apiData.product_type || (apiData as Record).material_type || (apiData as Record).type_code || apiData.item_type; + // console.log('[EditItem] Resolved itemType:', resolvedItemType); + setItemType(resolvedItemType as ItemType); + + // 폼 데이터로 변환 + const formData = mapApiResponseToFormData(apiData); + // console.log('[EditItem] Mapped form data:', formData); + setInitialData(formData); } else { - alert('품목을 찾을 수 없습니다.'); - router.push('/items'); + setError(result.message || '품목 정보를 불러올 수 없습니다.'); } - } catch { - alert('품목 조회에 실패했습니다.'); - router.push('/items'); + } catch (err) { + console.error('[EditItem] Error:', err); + setError('품목 정보를 불러오는 중 오류가 발생했습니다.'); } finally { setIsLoading(false); } }; fetchItem(); - }, [params.id, router]); + }, [params.id, urlItemType, urlItemId]); - const handleSubmit = async (data: CreateItemFormData) => { - // TODO: API 연동 시 updateItem() 호출 - console.log('품목 수정 데이터:', data); + /** + * 품목 수정 제출 핸들러 + * + * API 엔드포인트: + * - Products (FG, PT): PUT /api/proxy/items/{id} + * - Materials (SM, RM, CS): PATCH /api/proxy/products/materials/{id} + * + * 주의: 리다이렉트는 DynamicItemForm에서 처리하므로 여기서는 API 호출만 수행 + */ + const handleSubmit = async (data: DynamicFormData) => { + if (!itemId) { + throw new Error('품목 ID가 없습니다.'); + } - // Mock: 성공 메시지 - alert(`품목 "${data.itemName}" (${data.itemCode})이(가) 수정되었습니다.`); + // console.log('[EditItem] Submitting update:', { itemId, itemType, data }); - // API 연동 예시: - // const updatedItem = await updateItem(item.itemCode, data); - // router.push(`/items/${updatedItem.itemCode}`); + // Materials (SM, RM, CS)는 /products/materials 엔드포인트 + PATCH 메서드 사용 + // Products (FG, PT)는 /items 엔드포인트 + PUT 메서드 사용 + const isMaterial = itemType ? MATERIAL_TYPES.includes(itemType) : false; + const updateUrl = isMaterial + ? `/api/proxy/products/materials/${itemId}` + : `/api/proxy/items/${itemId}`; + const method = isMaterial ? 'PATCH' : 'PUT'; + + // console.log('[EditItem] Update URL:', updateUrl, '(method:', method, ', isMaterial:', isMaterial, ')'); + + // 수정 시 code/material_code는 변경하지 않음 (UNIQUE 제약조건 위반 방지) + // DynamicItemForm에서 자동생성되는 code를 제외해야 함 + let submitData = { ...data }; + + // FG(제품)의 경우: 품목코드 = 품목명이므로, name 변경 시 code도 함께 변경 + // 다른 타입: code 제외 (UNIQUE 제약조건) + if (itemType === 'FG') { + // FG는 품목명이 품목코드가 되므로 name 값으로 code 설정 + submitData.code = submitData.name; + } else { + delete submitData.code; + } + + // 공통: spec → specification 필드명 변환 (백엔드 API 규격) + if (submitData.spec !== undefined) { + submitData.specification = submitData.spec; + delete submitData.spec; + } + + if (isMaterial) { + // Materials의 경우 추가 필드명 변환 + // DynamicItemForm에서 오는 데이터: name, product_type 등 + // Material API가 기대하는 데이터: name, material_type 등 + submitData = { + ...submitData, + // Material API 필드명 매핑 + material_type: submitData.product_type || itemType, + // 불필요한 필드 제거 + product_type: undefined, + material_code: undefined, // 수정 시 코드 변경 불가 + }; + // console.log('[EditItem] Material submitData:', submitData); + } else { + // Products (FG, PT)의 경우 + // console.log('[EditItem] Product submitData:', submitData); + } + + // API 호출 + console.log('========== [EditItem] PUT 요청 데이터 =========='); + console.log('URL:', updateUrl); + console.log('Method:', method); + console.log('전송 데이터:', JSON.stringify(submitData, null, 2)); + console.log('================================================'); + + const response = await fetch(updateUrl, { + method, + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(submitData), + }); + + const result = await response.json(); + console.log('========== [EditItem] PUT 응답 =========='); + console.log('Response:', JSON.stringify(result, null, 2)); + console.log('=========================================='); + + if (!response.ok || !result.success) { + throw new Error(result.message || '품목 수정에 실패했습니다.'); + } + + // 성공 메시지만 표시 (리다이렉트는 DynamicItemForm에서 처리) + // alert 제거 - DynamicItemForm에서 router.push('/items')로 이동 }; + // 로딩 상태 if (isLoading) { return ( -
-
로딩 중...
+
+ +

품목 정보 로딩 중...

); } - if (!item) { - return null; + // 에러 상태 + if (error) { + return ( +
+

{error}

+ +
+ ); + } + + // 데이터 없음 + if (!itemType || !initialData) { + return ( +
+

품목 정보를 불러올 수 없습니다.

+ +
+ ); } return (
- +
); } \ No newline at end of file diff --git a/src/app/[locale]/(protected)/items/[id]/page.tsx b/src/app/[locale]/(protected)/items/[id]/page.tsx index 58ad4e3e..a0a20a93 100644 --- a/src/app/[locale]/(protected)/items/[id]/page.tsx +++ b/src/app/[locale]/(protected)/items/[id]/page.tsx @@ -1,195 +1,186 @@ /** * 품목 상세 조회 페이지 + * + * API 연동: GET /api/proxy/items/code/{itemCode}?include_bom=true */ -import { Suspense } from 'react'; +'use client'; + +import { useEffect, useState } from 'react'; +import { useParams, useRouter, useSearchParams } from 'next/navigation'; import { notFound } from 'next/navigation'; import ItemDetailClient from '@/components/items/ItemDetailClient'; import type { ItemMaster } from '@/types/item'; +import { Loader2 } from 'lucide-react'; -// Mock 데이터 (API 연동 전 임시) -const mockItems: ItemMaster[] = [ - { - id: '1', - itemCode: 'KD-FG-001', - itemName: '스크린 제품 A', - itemType: 'FG', - unit: 'EA', - specification: '2000x2000', - isActive: true, - category1: '본체부품', - category2: '가이드시스템', - salesPrice: 150000, - purchasePrice: 100000, - marginRate: 33.3, - processingCost: 20000, - laborCost: 15000, - installCost: 10000, - productCategory: 'SCREEN', - lotAbbreviation: 'KD', - note: '스크린 제품 샘플입니다.', - safetyStock: 10, - leadTime: 7, - currentRevision: 0, - isFinal: false, - createdAt: '2025-01-10T00:00:00Z', - updatedAt: '2025-01-12T00:00:00Z', - bom: [ - { - id: 'bom-1', - childItemCode: 'KD-PT-001', - childItemName: '가이드레일(벽면형)', - quantity: 2, - unit: 'EA', - unitPrice: 35000, - quantityFormula: 'H / 1000', - }, - { - id: 'bom-2', - childItemCode: 'KD-PT-002', - childItemName: '절곡품 샘플', - quantity: 4, - unit: 'EA', - unitPrice: 30000, - isBending: true, - }, - { - id: 'bom-3', - childItemCode: 'KD-SM-001', - childItemName: '볼트 M6x20', - quantity: 20, - unit: 'EA', - unitPrice: 50, - }, - ], - }, - { - id: '2', - itemCode: 'KD-PT-001', - itemName: '가이드레일(벽면형)', - itemType: 'PT', - unit: 'EA', - specification: '2438mm', - isActive: true, - category1: '본체부품', - category2: '가이드시스템', - category3: '가이드레일', - salesPrice: 50000, - purchasePrice: 35000, - marginRate: 30, - partType: 'ASSEMBLY', - partUsage: 'GUIDE_RAIL', - installationType: '벽면형', - assemblyType: 'M', - assemblyLength: '2438', - currentRevision: 0, - isFinal: false, - createdAt: '2025-01-10T00:00:00Z', - }, - { - id: '3', - itemCode: 'KD-PT-002', - itemName: '절곡품 샘플', - itemType: 'PT', - unit: 'EA', - specification: 'EGI 1.55T', - isActive: true, - partType: 'BENDING', - material: 'EGI 1.55T', - length: '2000', - salesPrice: 30000, - currentRevision: 0, - isFinal: false, - createdAt: '2025-01-10T00:00:00Z', - }, - { - id: '4', - itemCode: 'KD-RM-001', - itemName: 'SPHC-SD', - itemType: 'RM', - unit: 'KG', - specification: '1.6T x 1219 x 2438', - isActive: true, - category1: '철강재', - purchasePrice: 1500, - material: 'SPHC-SD', - currentRevision: 0, - isFinal: false, - createdAt: '2025-01-10T00:00:00Z', - }, - { - id: '5', - itemCode: 'KD-SM-001', - itemName: '볼트 M6x20', - itemType: 'SM', - unit: 'EA', - specification: 'M6x20', - isActive: true, - category1: '구조재/부속품', - category2: '볼트/너트', - purchasePrice: 50, - currentRevision: 0, - isFinal: false, - createdAt: '2025-01-10T00:00:00Z', - }, -]; +// Materials 타입 (SM, RM, CS는 Material 테이블 사용) +const MATERIAL_TYPES = ['SM', 'RM', 'CS']; /** - * 품목 조회 함수 - * TODO: API 연동 시 fetchItemByCode()로 교체 + * API 응답을 ItemMaster 타입으로 변환 */ -async function getItemByCode(itemCode: string): Promise { - // API 연동 전 mock 데이터 반환 - // const item = await fetchItemByCode(itemCode); - const item = mockItems.find( - (item) => item.itemCode === decodeURIComponent(itemCode) - ); - return item || null; +function mapApiResponseToItemMaster(data: Record): ItemMaster { + return { + id: String(data.id || ''), + // 백엔드 필드 매핑: + // - Product: code, name, product_type + // - Material: material_code, name, material_type (또는 type_code) + itemCode: String(data.code || data.material_code || data.item_code || data.itemCode || ''), + itemName: String(data.name || data.item_name || data.itemName || ''), + itemType: String(data.product_type || data.material_type || data.type_code || data.item_type || data.itemType || 'FG'), + unit: String(data.unit || 'EA'), + specification: data.specification ? String(data.specification) : undefined, + isActive: Boolean(data.is_active ?? data.isActive ?? true), + category1: data.category1 ? String(data.category1) : undefined, + category2: data.category2 ? String(data.category2) : undefined, + category3: data.category3 ? String(data.category3) : undefined, + salesPrice: data.sales_price ? Number(data.sales_price) : undefined, + purchasePrice: data.purchase_price ? Number(data.purchase_price) : undefined, + marginRate: data.margin_rate ? Number(data.margin_rate) : undefined, + processingCost: data.processing_cost ? Number(data.processing_cost) : undefined, + laborCost: data.labor_cost ? Number(data.labor_cost) : undefined, + installCost: data.install_cost ? Number(data.install_cost) : undefined, + productCategory: data.product_category ? String(data.product_category) : undefined, + lotAbbreviation: data.lot_abbreviation ? String(data.lot_abbreviation) : undefined, + note: data.note ? String(data.note) : undefined, + description: data.description ? String(data.description) : undefined, + safetyStock: data.safety_stock ? Number(data.safety_stock) : undefined, + leadTime: data.lead_time ? Number(data.lead_time) : undefined, + currentRevision: data.current_revision ? Number(data.current_revision) : 0, + isFinal: Boolean(data.is_final ?? false), + createdAt: String(data.created_at || data.createdAt || ''), + updatedAt: data.updated_at ? String(data.updated_at) : undefined, + // 부품 관련 + partType: data.part_type ? String(data.part_type) : undefined, + partUsage: data.part_usage ? String(data.part_usage) : undefined, + installationType: data.installation_type ? String(data.installation_type) : undefined, + assemblyType: data.assembly_type ? String(data.assembly_type) : undefined, + assemblyLength: data.assembly_length ? String(data.assembly_length) : undefined, + material: data.material ? String(data.material) : undefined, + sideSpecWidth: data.side_spec_width ? String(data.side_spec_width) : undefined, + sideSpecHeight: data.side_spec_height ? String(data.side_spec_height) : undefined, + guideRailModelType: data.guide_rail_model_type ? String(data.guide_rail_model_type) : undefined, + guideRailModel: data.guide_rail_model ? String(data.guide_rail_model) : undefined, + length: data.length ? String(data.length) : undefined, + // BOM (있으면) + bom: Array.isArray(data.bom) ? data.bom.map((bomItem: Record) => ({ + id: String(bomItem.id || ''), + childItemCode: String(bomItem.child_item_code || bomItem.childItemCode || ''), + childItemName: String(bomItem.child_item_name || bomItem.childItemName || ''), + quantity: Number(bomItem.quantity || 1), + unit: String(bomItem.unit || 'EA'), + unitPrice: bomItem.unit_price ? Number(bomItem.unit_price) : undefined, + quantityFormula: bomItem.quantity_formula ? String(bomItem.quantity_formula) : undefined, + isBending: Boolean(bomItem.is_bending ?? false), + })) : undefined, + }; } /** * 품목 상세 페이지 */ -export default async function ItemDetailPage({ - params, -}: { - params: Promise<{ id: string }>; -}) { - const { id } = await params; - const item = await getItemByCode(id); +export default function ItemDetailPage() { + const params = useParams(); + const router = useRouter(); + const searchParams = useSearchParams(); + const [item, setItem] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + // URL에서 type과 id 쿼리 파라미터 읽기 + const itemType = searchParams.get('type') || 'FG'; + const itemId = searchParams.get('id'); + + useEffect(() => { + const fetchItem = async () => { + if (!params.id || typeof params.id !== 'string') { + setError('잘못된 품목 ID입니다.'); + setIsLoading(false); + return; + } + + try { + setIsLoading(true); + const itemCode = decodeURIComponent(params.id); + console.log('[ItemDetail] Fetching item:', { itemCode, itemType, itemId }); + + let response: Response; + + // Materials (SM, RM, CS)는 다른 API 엔드포인트 사용 + if (MATERIAL_TYPES.includes(itemType) && itemId) { + // GET /api/proxy/items/{id}?item_type=MATERIAL + console.log('[ItemDetail] Using Material API'); + response = await fetch(`/api/proxy/items/${itemId}?item_type=MATERIAL`); + } else { + // Products (FG, PT): GET /api/proxy/items/code/{itemCode}?include_bom=true + console.log('[ItemDetail] Using Product API'); + response = await fetch(`/api/proxy/items/code/${encodeURIComponent(itemCode)}?include_bom=true`); + } + + if (!response.ok) { + if (response.status === 404) { + setError('품목을 찾을 수 없습니다.'); + } else { + const errorData = await response.json().catch(() => null); + setError(errorData?.message || `오류 발생 (${response.status})`); + } + setIsLoading(false); + return; + } + + const result = await response.json(); + console.log('[ItemDetail] API Response:', result); + + if (result.success && result.data) { + const mappedItem = mapApiResponseToItemMaster(result.data); + setItem(mappedItem); + } else { + setError(result.message || '품목 정보를 불러올 수 없습니다.'); + } + } catch (err) { + console.error('[ItemDetail] Error:', err); + setError('품목 정보를 불러오는 중 오류가 발생했습니다.'); + } finally { + setIsLoading(false); + } + }; + + fetchItem(); + }, [params.id, itemType, itemId]); + + // 로딩 상태 + if (isLoading) { + return ( +
+ +

품목 정보 로딩 중...

+
+ ); + } + + // 에러 상태 + if (error) { + return ( +
+

{error}

+ +
+ ); + } + + // 품목 없음 if (!item) { notFound(); } return (
- 로딩 중...
}> - - +
); -} - -/** - * 메타데이터 설정 - */ -export async function generateMetadata({ - params, -}: { - params: Promise<{ id: string }>; -}) { - const { id } = await params; - const item = await getItemByCode(id); - - if (!item) { - return { - title: '품목을 찾을 수 없습니다', - }; - } - - return { - title: `${item.itemName} - 품목 상세`, - description: `${item.itemCode} 품목 정보`, - }; } \ No newline at end of file diff --git a/src/app/[locale]/(protected)/sales/client-management-sales-admin/[id]/edit/page.tsx b/src/app/[locale]/(protected)/sales/client-management-sales-admin/[id]/edit/page.tsx new file mode 100644 index 00000000..977583be --- /dev/null +++ b/src/app/[locale]/(protected)/sales/client-management-sales-admin/[id]/edit/page.tsx @@ -0,0 +1,82 @@ +/** + * 거래처 수정 페이지 + */ + +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter, useParams } from "next/navigation"; +import { ClientRegistration } from "@/components/clients/ClientRegistration"; +import { + useClientList, + ClientFormData, + clientToFormData, +} from "@/hooks/useClientList"; +import { toast } from "sonner"; +import { Loader2 } from "lucide-react"; + +export default function ClientEditPage() { + const router = useRouter(); + const params = useParams(); + const id = params.id as string; + + const { fetchClient, updateClient, isLoading: hookLoading } = useClientList(); + const [editingClient, setEditingClient] = useState( + null + ); + const [isLoading, setIsLoading] = useState(true); + + // 데이터 로드 + useEffect(() => { + const loadClient = async () => { + if (!id) return; + + setIsLoading(true); + try { + const data = await fetchClient(id); + if (data) { + setEditingClient(clientToFormData(data)); + } else { + toast.error("거래처를 찾을 수 없습니다."); + router.push("/sales/client-management-sales-admin"); + } + } catch (error) { + toast.error("데이터 로드 중 오류가 발생했습니다."); + router.push("/sales/client-management-sales-admin"); + } finally { + setIsLoading(false); + } + }; + + loadClient(); + }, [id, fetchClient, router]); + + const handleBack = () => { + router.push(`/sales/client-management-sales-admin/${id}`); + }; + + const handleSave = async (formData: ClientFormData) => { + await updateClient(id, formData); + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!editingClient) { + return null; + } + + return ( + + ); +} \ No newline at end of file diff --git a/src/app/[locale]/(protected)/sales/client-management-sales-admin/[id]/page.tsx b/src/app/[locale]/(protected)/sales/client-management-sales-admin/[id]/page.tsx new file mode 100644 index 00000000..9ff7b39f --- /dev/null +++ b/src/app/[locale]/(protected)/sales/client-management-sales-admin/[id]/page.tsx @@ -0,0 +1,128 @@ +/** + * 거래처 상세 페이지 + */ + +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter, useParams } from "next/navigation"; +import { ClientDetail } from "@/components/clients/ClientDetail"; +import { useClientList, Client } from "@/hooks/useClientList"; +import { toast } from "sonner"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Loader2 } from "lucide-react"; + +export default function ClientDetailPage() { + const router = useRouter(); + const params = useParams(); + const id = params.id as string; + + const { fetchClient, deleteClient } = useClientList(); + const [client, setClient] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isDeleting, setIsDeleting] = useState(false); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + + // 데이터 로드 + useEffect(() => { + const loadClient = async () => { + if (!id) return; + + setIsLoading(true); + try { + const data = await fetchClient(id); + if (data) { + setClient(data); + } else { + toast.error("거래처를 찾을 수 없습니다."); + router.push("/sales/client-management-sales-admin"); + } + } catch (error) { + toast.error("데이터 로드 중 오류가 발생했습니다."); + router.push("/sales/client-management-sales-admin"); + } finally { + setIsLoading(false); + } + }; + + loadClient(); + }, [id, fetchClient, router]); + + const handleBack = () => { + router.push("/sales/client-management-sales-admin"); + }; + + const handleEdit = () => { + router.push(`/sales/client-management-sales-admin/${id}/edit`); + }; + + const handleDelete = async () => { + setIsDeleting(true); + try { + await deleteClient(id); + toast.success("거래처가 삭제되었습니다."); + router.push("/sales/client-management-sales-admin"); + } catch (error) { + toast.error("삭제 중 오류가 발생했습니다."); + } finally { + setIsDeleting(false); + setShowDeleteDialog(false); + } + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!client) { + return null; + } + + return ( + <> + setShowDeleteDialog(true)} + /> + + {/* 삭제 확인 다이얼로그 */} + + + + 거래처 삭제 + + '{client.name}' 거래처를 삭제하시겠습니까? +
+ 이 작업은 되돌릴 수 없습니다. +
+
+ + 취소 + + {isDeleting ? "삭제 중..." : "삭제"} + + +
+
+ + ); +} \ No newline at end of file diff --git a/src/app/[locale]/(protected)/sales/client-management-sales-admin/new/page.tsx b/src/app/[locale]/(protected)/sales/client-management-sales-admin/new/page.tsx new file mode 100644 index 00000000..3161845d --- /dev/null +++ b/src/app/[locale]/(protected)/sales/client-management-sales-admin/new/page.tsx @@ -0,0 +1,30 @@ +/** + * 거래처 등록 페이지 + */ + +"use client"; + +import { useRouter } from "next/navigation"; +import { ClientRegistration } from "@/components/clients/ClientRegistration"; +import { useClientList, ClientFormData } from "@/hooks/useClientList"; + +export default function ClientNewPage() { + const router = useRouter(); + const { createClient, isLoading } = useClientList(); + + const handleBack = () => { + router.push("/sales/client-management-sales-admin"); + }; + + const handleSave = async (formData: ClientFormData) => { + await createClient(formData); + }; + + return ( + + ); +} \ No newline at end of file diff --git a/src/app/[locale]/(protected)/sales/client-management-sales-admin/page.tsx b/src/app/[locale]/(protected)/sales/client-management-sales-admin/page.tsx index 1bb04ca7..dae598c0 100644 --- a/src/app/[locale]/(protected)/sales/client-management-sales-admin/page.tsx +++ b/src/app/[locale]/(protected)/sales/client-management-sales-admin/page.tsx @@ -9,9 +9,15 @@ * - 체크박스 포함 DataTable (Desktop) * - 체크박스 포함 모바일 카드 (Mobile) * - 페이지네이션 + 모바일 인피니티 스크롤 + * + * API 연동: 2025-12-04 + * - useClientList 훅으로 백엔드 API 연동 + * - 페이지 기반 CRUD (등록/수정/상세 → 별도 페이지로 이동) */ -import { useState, useRef, useEffect } from "react"; +import { useState, useRef, useEffect, useCallback } from "react"; +import { useRouter } from "next/navigation"; +import { useClientList, Client } from "@/hooks/useClientList"; import { Building2, Plus, @@ -20,25 +26,16 @@ import { Users, CheckCircle, XCircle, + Eye, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; import { IntegratedListTemplateV2, TabOption, TableColumn, } from "@/components/templates/IntegratedListTemplateV2"; import { toast } from "sonner"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogFooter, -} from "@/components/ui/dialog"; import { TableRow, TableCell, @@ -56,117 +53,24 @@ import { AlertDialogTitle, } from "@/components/ui/alert-dialog"; -// 거래처 타입 -interface CustomerAccount { - id: string; - code: string; - name: string; - businessNo: string; - representative: string; - phone: string; - address: string; - email: string; - businessType: string; - businessItem: string; - registeredDate: string; - status: "활성" | "비활성"; -} - -// 샘플 거래처 데이터 -const SAMPLE_CUSTOMERS: CustomerAccount[] = [ - { - id: "1", - code: "C-001", - name: "ABC건설", - businessNo: "123-45-67890", - representative: "홍길동", - phone: "02-1234-5678", - address: "서울시 강남구 테헤란로 123", - email: "abc@company.com", - businessType: "건설업", - businessItem: "건축공사", - registeredDate: "2024-01-15", - status: "활성", - }, - { - id: "2", - code: "C-002", - name: "삼성전자", - businessNo: "234-56-78901", - representative: "김대표", - phone: "02-2345-6789", - address: "서울시 서초구 서초대로 456", - email: "samsung@company.com", - businessType: "제조업", - businessItem: "전자제품", - registeredDate: "2024-02-20", - status: "활성", - }, - { - id: "3", - code: "C-003", - name: "LG전자", - businessNo: "345-67-89012", - representative: "이사장", - phone: "02-3456-7890", - address: "서울시 영등포구 여의대로 789", - email: "lg@company.com", - businessType: "제조업", - businessItem: "가전제품", - registeredDate: "2024-03-10", - status: "활성", - }, - { - id: "4", - code: "C-004", - name: "현대건설", - businessNo: "456-78-90123", - representative: "박부장", - phone: "02-4567-8901", - address: "서울시 종로구 종로 101", - email: "hyundai@company.com", - businessType: "건설업", - businessItem: "토목공사", - registeredDate: "2024-04-05", - status: "비활성", - }, - { - id: "5", - code: "C-005", - name: "SK하이닉스", - businessNo: "567-89-01234", - representative: "최이사", - phone: "031-5678-9012", - address: "경기도 이천시 부발읍", - email: "skhynix@company.com", - businessType: "제조업", - businessItem: "반도체", - registeredDate: "2024-05-12", - status: "활성", - }, -]; - export default function CustomerAccountManagementPage() { + const router = useRouter(); + + // API 훅 사용 + const { + clients, + pagination, + isLoading, + fetchClients, + deleteClient: deleteClientApi, + } = useClientList(); + const [searchTerm, setSearchTerm] = useState(""); const [filterType, setFilterType] = useState("all"); const [selectedItems, setSelectedItems] = useState>(new Set()); const [currentPage, setCurrentPage] = useState(1); const itemsPerPage = 20; - // 모달 상태 - const [isModalOpen, setIsModalOpen] = useState(false); - const [editingCustomer, setEditingCustomer] = useState(null); - const [formData, setFormData] = useState({ - name: "", - businessNo: "", - representative: "", - phone: "", - address: "", - email: "", - businessType: "", - businessItem: "", - }); - // 삭제 확인 다이얼로그 state const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [deleteTargetId, setDeleteTargetId] = useState(null); @@ -178,46 +82,52 @@ export default function CustomerAccountManagementPage() { const [mobileDisplayCount, setMobileDisplayCount] = useState(20); const sentinelRef = useRef(null); - // 로컬 데이터 state - const [customers, setCustomers] = useState(SAMPLE_CUSTOMERS); - - // 필터링 - const filteredCustomers = customers - .filter((customer) => { - const searchLower = searchTerm.toLowerCase(); - const matchesSearch = - !searchTerm || - customer.name.toLowerCase().includes(searchLower) || - customer.code.toLowerCase().includes(searchLower) || - customer.representative.toLowerCase().includes(searchLower) || - customer.phone.includes(searchTerm) || - customer.businessNo.includes(searchTerm); - - let matchesFilter = true; - if (filterType === "active") { - matchesFilter = customer.status === "활성"; - } else if (filterType === "inactive") { - matchesFilter = customer.status === "비활성"; - } - - return matchesSearch && matchesFilter; - }) - .sort((a, b) => { - return ( - new Date(b.registeredDate).getTime() - - new Date(a.registeredDate).getTime() - ); + // 초기 데이터 로드 + useEffect(() => { + fetchClients({ + page: currentPage, + size: itemsPerPage, + q: searchTerm || undefined, + onlyActive: filterType === "active" ? true : filterType === "inactive" ? false : undefined, }); + }, [currentPage, filterType, fetchClients]); - // 페이지네이션 - const totalPages = Math.ceil(filteredCustomers.length / itemsPerPage); - const paginatedCustomers = filteredCustomers.slice( - (currentPage - 1) * itemsPerPage, - currentPage * itemsPerPage - ); + // 검색어 변경 시 디바운스 처리 + const searchTimeoutRef = useRef(null); + useEffect(() => { + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + } + searchTimeoutRef.current = setTimeout(() => { + setCurrentPage(1); + fetchClients({ + page: 1, + size: itemsPerPage, + q: searchTerm || undefined, + onlyActive: filterType === "active" ? true : filterType === "inactive" ? false : undefined, + }); + }, 300); + + return () => { + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + } + }; + }, [searchTerm]); + + // 클라이언트 사이드 필터링 (탭용) + const filteredClients = clients.filter((client) => { + if (filterType === "active") return client.status === "활성"; + if (filterType === "inactive") return client.status === "비활성"; + return true; + }); + + // 페이지네이션 (API에서 처리하므로 clients 직접 사용) + const totalPages = pagination ? pagination.lastPage : 1; + const paginatedClients = filteredClients; // 모바일용 인피니티 스크롤 데이터 - const mobileCustomers = filteredCustomers.slice(0, mobileDisplayCount); + const mobileClients = filteredClients.slice(0, mobileDisplayCount); // Intersection Observer를 이용한 인피니티 스크롤 useEffect(() => { @@ -228,10 +138,10 @@ export default function CustomerAccountManagementPage() { (entries) => { if ( entries[0].isIntersecting && - mobileDisplayCount < filteredCustomers.length + mobileDisplayCount < filteredClients.length ) { setMobileDisplayCount((prev) => - Math.min(prev + 20, filteredCustomers.length) + Math.min(prev + 20, filteredClients.length) ); } }, @@ -248,17 +158,17 @@ export default function CustomerAccountManagementPage() { return () => { observer.disconnect(); }; - }, [mobileDisplayCount, filteredCustomers.length]); + }, [mobileDisplayCount, filteredClients.length]); // 탭이나 검색어 변경 시 모바일 표시 개수 초기화 useEffect(() => { setMobileDisplayCount(20); }, [searchTerm, filterType]); - // 통계 - const totalCustomers = customers.length; - const activeCustomers = customers.filter((c) => c.status === "활성").length; - const inactiveCustomers = customers.filter((c) => c.status === "비활성").length; + // 통계 (API에서 가져온 전체 데이터 기반) + const totalCustomers = pagination?.total || clients.length; + const activeCustomers = clients.filter((c) => c.status === "활성").length; + const inactiveCustomers = clients.filter((c) => c.status === "비활성").length; const stats = [ { @@ -281,62 +191,27 @@ export default function CustomerAccountManagementPage() { }, ]; - // 핸들러 + // 데이터 새로고침 함수 + const refreshData = useCallback(() => { + fetchClients({ + page: currentPage, + size: itemsPerPage, + q: searchTerm || undefined, + onlyActive: filterType === "active" ? true : filterType === "inactive" ? false : undefined, + }); + }, [currentPage, itemsPerPage, searchTerm, filterType, fetchClients]); + + // 핸들러 - 페이지 기반 네비게이션 const handleAddNew = () => { - setEditingCustomer(null); - setFormData({ - name: "", - businessNo: "", - representative: "", - phone: "", - address: "", - email: "", - businessType: "", - businessItem: "", - }); - setIsModalOpen(true); + router.push("/sales/client-management-sales-admin/new"); }; - const handleEdit = (customer: CustomerAccount) => { - setEditingCustomer(customer); - setFormData({ - name: customer.name, - businessNo: customer.businessNo, - representative: customer.representative, - phone: customer.phone, - address: customer.address, - email: customer.email, - businessType: customer.businessType, - businessItem: customer.businessItem, - }); - setIsModalOpen(true); + const handleEdit = (customer: Client) => { + router.push(`/sales/client-management-sales-admin/${customer.id}/edit`); }; - const handleSave = () => { - if (editingCustomer) { - setCustomers( - customers.map((c) => - c.id === editingCustomer.id ? { ...c, ...formData } : c - ) - ); - toast.success("거래처 정보가 수정되었습니다"); - } else { - const newCode = `C-${String(customers.length + 1).padStart(3, "0")}`; - const newCustomer: CustomerAccount = { - id: String(customers.length + 1), - code: newCode, - ...formData, - registeredDate: new Date().toISOString().split("T")[0], - status: "활성", - }; - setCustomers([...customers, newCustomer]); - toast.success("새 거래처가 등록되었습니다"); - } - setIsModalOpen(false); - }; - - const handleView = (customer: CustomerAccount) => { - toast.info(`상세보기: ${customer.name}`); + const handleView = (customer: Client) => { + router.push(`/sales/client-management-sales-admin/${customer.id}`); }; const handleDelete = (customerId: string) => { @@ -344,13 +219,19 @@ export default function CustomerAccountManagementPage() { setIsDeleteDialogOpen(true); }; - const handleConfirmDelete = () => { + const handleConfirmDelete = async () => { if (deleteTargetId) { - const customer = customers.find((c) => c.id === deleteTargetId); - setCustomers(customers.filter((c) => c.id !== deleteTargetId)); - toast.success(`거래처가 삭제되었습니다${customer ? `: ${customer.name}` : ""}`); - setIsDeleteDialogOpen(false); - setDeleteTargetId(null); + try { + const customer = clients.find((c) => c.id === deleteTargetId); + await deleteClientApi(deleteTargetId); + toast.success(`거래처가 삭제되었습니다${customer ? `: ${customer.name}` : ""}`); + setIsDeleteDialogOpen(false); + setDeleteTargetId(null); + refreshData(); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "삭제 중 오류가 발생했습니다"; + toast.error(errorMessage); + } } }; @@ -367,12 +248,12 @@ export default function CustomerAccountManagementPage() { const toggleSelectAll = () => { if ( - selectedItems.size === paginatedCustomers.length && - paginatedCustomers.length > 0 + selectedItems.size === paginatedClients.length && + paginatedClients.length > 0 ) { setSelectedItems(new Set()); } else { - setSelectedItems(new Set(paginatedCustomers.map((c) => c.id))); + setSelectedItems(new Set(paginatedClients.map((c) => c.id))); } }; @@ -385,11 +266,19 @@ export default function CustomerAccountManagementPage() { setIsBulkDeleteDialogOpen(true); }; - const handleConfirmBulkDelete = () => { - setCustomers(customers.filter((c) => !selectedItems.has(c.id))); - toast.success(`${selectedItems.size}개의 거래처가 삭제되었습니다`); - setSelectedItems(new Set()); - setIsBulkDeleteDialogOpen(false); + const handleConfirmBulkDelete = async () => { + try { + // 선택된 항목들을 순차적으로 삭제 + const deletePromises = Array.from(selectedItems).map((id) => deleteClientApi(id)); + await Promise.all(deletePromises); + toast.success(`${selectedItems.size}개의 거래처가 삭제되었습니다`); + setSelectedItems(new Set()); + setIsBulkDeleteDialogOpen(false); + refreshData(); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "삭제 중 오류가 발생했습니다"; + toast.error(errorMessage); + } }; // 상태 뱃지 @@ -413,7 +302,7 @@ export default function CustomerAccountManagementPage() { { value: "all", label: "전체", - count: customers.length, + count: totalCustomers, color: "blue", }, { @@ -446,7 +335,7 @@ export default function CustomerAccountManagementPage() { // 테이블 행 렌더링 const renderTableRow = ( - customer: CustomerAccount, + customer: Client, index: number, globalIndex: number ) => { @@ -504,7 +393,7 @@ export default function CustomerAccountManagementPage() { // 모바일 카드 렌더링 const renderMobileCard = ( - customer: CustomerAccount, + customer: Client, index: number, globalIndex: number, isSelected: boolean, @@ -544,32 +433,34 @@ export default function CustomerAccountManagementPage() { } actions={ -
- - -
+ isSelected ? ( +
+ + +
+ ) : undefined } /> ); @@ -598,10 +489,10 @@ export default function CustomerAccountManagementPage() { setCurrentPage(1); }} tableColumns={tableColumns} - tableTitle={`${tabs.find((t) => t.value === filterType)?.label || "전체"} (${filteredCustomers.length}개)`} - data={paginatedCustomers} - totalCount={filteredCustomers.length} - allData={mobileCustomers} + tableTitle={`${tabs.find((t) => t.value === filterType)?.label || "전체"} (${filteredClients.length}개)`} + data={paginatedClients} + totalCount={filteredClients.length} + allData={mobileClients} mobileDisplayCount={mobileDisplayCount} infinityScrollSentinelRef={sentinelRef} selectedItems={selectedItems} @@ -611,124 +502,16 @@ export default function CustomerAccountManagementPage() { getItemId={(customer) => customer.id} renderTableRow={renderTableRow} renderMobileCard={renderMobileCard} + isLoading={isLoading} pagination={{ currentPage, totalPages, - totalItems: filteredCustomers.length, + totalItems: pagination?.total || filteredClients.length, itemsPerPage, onPageChange: setCurrentPage, }} /> - {/* 등록/수정 모달 */} - - - - - {editingCustomer ? "거래처 수정" : "거래처 등록"} - - 거래처 정보를 입력하세요 - -
-
- - - setFormData({ ...formData, name: e.target.value }) - } - placeholder="ABC건설" - /> -
-
- - - setFormData({ ...formData, businessNo: e.target.value }) - } - placeholder="123-45-67890" - /> -
-
- - - setFormData({ ...formData, representative: e.target.value }) - } - placeholder="홍길동" - /> -
-
- - - setFormData({ ...formData, phone: e.target.value }) - } - placeholder="02-1234-5678" - /> -
-
- - - setFormData({ ...formData, address: e.target.value }) - } - placeholder="서울시 강남구 테헤란로 123" - /> -
-
- - - setFormData({ ...formData, email: e.target.value }) - } - placeholder="abc@company.com" - /> -
-
- - - setFormData({ ...formData, businessType: e.target.value }) - } - placeholder="건설업" - /> -
-
- - - setFormData({ ...formData, businessItem: e.target.value }) - } - placeholder="건축공사" - /> -
-
- - - - -
-
- {/* 삭제 확인 다이얼로그 */} @@ -736,7 +519,7 @@ export default function CustomerAccountManagementPage() { 거래처 삭제 확인 {deleteTargetId - ? `거래처: ${customers.find((c) => c.id === deleteTargetId)?.name || deleteTargetId}` + ? `거래처: ${clients.find((c) => c.id === deleteTargetId)?.name || deleteTargetId}` : ""}
이 거래처를 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다. diff --git a/src/app/[locale]/(protected)/sales/quote-management/[id]/edit/page.tsx b/src/app/[locale]/(protected)/sales/quote-management/[id]/edit/page.tsx new file mode 100644 index 00000000..d1442bd5 --- /dev/null +++ b/src/app/[locale]/(protected)/sales/quote-management/[id]/edit/page.tsx @@ -0,0 +1,102 @@ +/** + * 견적 수정 페이지 + */ + +"use client"; + +import { useRouter, useParams } from "next/navigation"; +import { useState, useEffect } from "react"; +import { QuoteRegistration, QuoteFormData, INITIAL_QUOTE_FORM } from "@/components/quotes/QuoteRegistration"; +import { toast } from "sonner"; + +// 샘플 견적 데이터 (TODO: API에서 가져오기) +const SAMPLE_QUOTE: QuoteFormData = { + id: "Q2024-001", + registrationDate: "2025-10-29", + writer: "드미트리", + clientId: "client-1", + clientName: "인천건설 - 최담당", + siteName: "인천 송도 현장", // 직접 입력 + manager: "김영업", + contact: "010-1234-5678", + dueDate: "2025-11-30", + remarks: "스크린 셔터 부품구성표 기반 자동 견적", + items: [ + { + id: "item-1", + floor: "1층", + code: "A", + productCategory: "screen", + productName: "SCR-001", + openWidth: "2000", + openHeight: "2500", + guideRailType: "wall", + motorPower: "single", + controller: "basic", + quantity: 1, + wingSize: "50", + inspectionFee: 50000, + }, + ], +}; + +export default function QuoteEditPage() { + const router = useRouter(); + const params = useParams(); + const quoteId = params.id as string; + + const [quote, setQuote] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + // TODO: API에서 견적 데이터 가져오기 + const fetchQuote = async () => { + setIsLoading(true); + try { + // 임시: 샘플 데이터 사용 + await new Promise((resolve) => setTimeout(resolve, 300)); + setQuote({ ...SAMPLE_QUOTE, id: quoteId }); + } catch (error) { + toast.error("견적 정보를 불러오는데 실패했습니다."); + router.push("/sales/quote-management"); + } finally { + setIsLoading(false); + } + }; + + fetchQuote(); + }, [quoteId, router]); + + const handleBack = () => { + router.push("/sales/quote-management"); + }; + + const handleSave = async (formData: QuoteFormData) => { + // TODO: API 연동 + console.log("견적 수정 데이터:", formData); + + // 임시: 성공 시뮬레이션 + await new Promise((resolve) => setTimeout(resolve, 500)); + + toast.success("견적이 수정되었습니다. (API 연동 필요)"); + }; + + if (isLoading) { + return ( +
+
+
+

견적 정보를 불러오는 중...

+
+
+ ); + } + + return ( + + ); +} diff --git a/src/app/[locale]/(protected)/sales/quote-management/[id]/page.tsx b/src/app/[locale]/(protected)/sales/quote-management/[id]/page.tsx new file mode 100644 index 00000000..b9497a59 --- /dev/null +++ b/src/app/[locale]/(protected)/sales/quote-management/[id]/page.tsx @@ -0,0 +1,631 @@ +/** + * 견적 상세 페이지 + * - 기본 정보 표시 + * - 자동 견적 산출 정보 + * - 견적서 / 산출내역서 / 발주서 모달 + */ + +"use client"; + +import { useRouter, useParams } from "next/navigation"; +import { useState, useEffect } from "react"; +import { QuoteFormData, INITIAL_QUOTE_FORM } from "@/components/quotes/QuoteRegistration"; +import { QuoteDocument } from "@/components/quotes/QuoteDocument"; +import { QuoteCalculationReport } from "@/components/quotes/QuoteCalculationReport"; +import { PurchaseOrderDocument } from "@/components/quotes/PurchaseOrderDocument"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + FileText, + Edit, + List, + Printer, + FileOutput, + Download, + Mail, + MessageCircle, + X, + FileCheck, + ShoppingCart, +} from "lucide-react"; + +// 샘플 견적 데이터 (TODO: API에서 가져오기) +const SAMPLE_QUOTE: QuoteFormData = { + id: "Q2024-001", + registrationDate: "2025-10-29", + writer: "드미트리", + clientId: "client-1", + clientName: "인천건설", + siteName: "송도 오피스텔 A동", + manager: "김영업", + contact: "010-1234-5678", + dueDate: "2025-11-30", + remarks: "스크린 셔터 부품구성표 기반 자동 견적", + items: [ + { + id: "item-1", + floor: "1층", + code: "A", + productCategory: "screen", + productName: "스크린 셔터 (표준형)", + openWidth: "2000", + openHeight: "2500", + guideRailType: "wall", + motorPower: "single", + controller: "basic", + quantity: 1, + wingSize: "50", + inspectionFee: 337000, + }, + ], +}; + +export default function QuoteDetailPage() { + const router = useRouter(); + const params = useParams(); + const quoteId = params.id as string; + + const [quote, setQuote] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + // 다이얼로그 상태 + const [isQuoteDocumentOpen, setIsQuoteDocumentOpen] = useState(false); + const [isCalculationReportOpen, setIsCalculationReportOpen] = useState(false); + const [isPurchaseOrderOpen, setIsPurchaseOrderOpen] = useState(false); + + // 산출내역서 표시 옵션 + const [showDetailedBreakdown, setShowDetailedBreakdown] = useState(true); + const [showMaterialList, setShowMaterialList] = useState(true); + + useEffect(() => { + // TODO: API에서 견적 데이터 가져오기 + const fetchQuote = async () => { + setIsLoading(true); + try { + // 임시: 샘플 데이터 사용 + await new Promise((resolve) => setTimeout(resolve, 300)); + setQuote({ ...SAMPLE_QUOTE, id: quoteId }); + } catch (error) { + toast.error("견적 정보를 불러오는데 실패했습니다."); + router.push("/sales/quote-management"); + } finally { + setIsLoading(false); + } + }; + + fetchQuote(); + }, [quoteId, router]); + + const handleBack = () => { + router.push("/sales/quote-management"); + }; + + const handleEdit = () => { + router.push(`/sales/quote-management/${quoteId}/edit`); + }; + + const handleFinalize = () => { + toast.success("견적이 최종 확정되었습니다. (API 연동 필요)"); + }; + + const handleConvertToOrder = () => { + toast.info("수주 등록 화면으로 이동합니다. (API 연동 필요)"); + }; + + const formatDate = (dateStr: string) => { + if (!dateStr) return "-"; + return dateStr; + }; + + const formatAmount = (amount: number | undefined) => { + if (!amount) return "0"; + return amount.toLocaleString("ko-KR"); + }; + + // 총 금액 계산 + const totalAmount = + quote?.items?.reduce((sum, item) => { + return sum + (item.inspectionFee || 0) * (item.quantity || 1); + }, 0) || 0; + + if (isLoading) { + return ( +
+
+
+

+ 견적 정보를 불러오는 중... +

+
+
+ ); + } + + if (!quote) { + return ( +
+

견적 정보를 찾을 수 없습니다.

+ +
+ ); + } + + return ( +
+ {/* 헤더 */} +
+
+

+ + 견적 상세 +

+

견적번호: {quote.id}

+
+ +
+ {/* 문서 버튼들 */} + + + + + {/* 액션 버튼들 */} + + + +
+
+ + {/* 기본 정보 */} + + + 기본 정보 + + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + {quote.remarks && ( +
+ +