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,
}}
/>
- {/* 등록/수정 모달 */}
-
-
{/* 삭제 확인 다이얼로그 */}
@@ -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 && (
+
+
+
+
+ )}
+
+
+
+ {/* 자동 견적 산출 정보 */}
+
+
+ 자동 견적 산출 정보
+
+
+ {quote.items && quote.items.length > 0 ? (
+
+ {quote.items.map((item, index) => (
+
+
+ 항목 {index + 1}
+ {item.floor}
+
+
+
+
+
제품명
+
{item.productName}
+
+
+
오픈사이즈
+
+ {item.openWidth} × {item.openHeight} mm
+
+
+
+
수량
+
{item.quantity} SET
+
+
+
금액
+
+ ₩{formatAmount((item.inspectionFee || 0) * (item.quantity || 1))}
+
+
+
+
+ ))}
+
+ {/* 합계 */}
+
+
+ 총 견적금액
+
+ ₩{formatAmount(totalAmount)}
+
+
+
+
+ ) : (
+
+ 산출 항목이 없습니다.
+
+ )}
+
+
+
+ {/* 견적서 다이얼로그 */}
+
+
+ {/* 산출내역서 다이얼로그 */}
+
+
+ {/* 발주서 다이얼로그 */}
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/app/[locale]/(protected)/sales/quote-management/new/page.tsx b/src/app/[locale]/(protected)/sales/quote-management/new/page.tsx
new file mode 100644
index 00000000..dd6009ea
--- /dev/null
+++ b/src/app/[locale]/(protected)/sales/quote-management/new/page.tsx
@@ -0,0 +1,34 @@
+/**
+ * 견적 등록 페이지
+ */
+
+"use client";
+
+import { useRouter } from "next/navigation";
+import { QuoteRegistration, QuoteFormData } from "@/components/quotes/QuoteRegistration";
+import { toast } from "sonner";
+
+export default function QuoteNewPage() {
+ const router = useRouter();
+
+ 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 연동 필요)");
+ };
+
+ return (
+
+ );
+}
diff --git a/src/app/[locale]/(protected)/sales/quote-management/page.tsx b/src/app/[locale]/(protected)/sales/quote-management/page.tsx
index 3773cbc0..0e4b52c5 100644
--- a/src/app/[locale]/(protected)/sales/quote-management/page.tsx
+++ b/src/app/[locale]/(protected)/sales/quote-management/page.tsx
@@ -11,6 +11,7 @@
*/
import { useState, useRef, useEffect } from "react";
+import { useRouter } from "next/navigation";
import {
FileText,
Edit,
@@ -167,6 +168,7 @@ const SAMPLE_QUOTES: Quote[] = [
];
export default function QuoteManagementPage() {
+ const router = useRouter();
const [searchTerm, setSearchTerm] = useState("");
const [filterType, setFilterType] = useState("all");
const [isCalculationDialogOpen, setIsCalculationDialogOpen] = useState(false);
@@ -337,11 +339,11 @@ export default function QuoteManagementPage() {
// 핸들러
const handleView = (quote: Quote) => {
- toast.info(`상세보기: ${quote.quoteNumber}`);
+ router.push(`/sales/quote-management/${quote.id}`);
};
const handleEdit = (quote: Quote) => {
- toast.info(`수정: ${quote.quoteNumber}`);
+ router.push(`/sales/quote-management/${quote.id}/edit`);
};
const handleDelete = (quoteId: string) => {
@@ -600,48 +602,50 @@ export default function QuoteManagementPage() {
}
actions={
-
- {quote.currentRevision > 0 && (
+ isSelected ? (
+
+ {quote.currentRevision > 0 && (
+
+ )}
- )}
-
- {!quote.isFinal && (
-
- )}
-
+ {!quote.isFinal && (
+
+ )}
+
+ ) : undefined
}
/>
);
@@ -655,7 +659,7 @@ export default function QuoteManagementPage() {
description="견적서 작성 및 관리"
icon={FileText}
headerActions={
-
+ {/* 2025-12-04: MVP에서 회원가입 버튼 제거 (운영 페이지로 이동 예정) */}
- router.push("/signup")} className="rounded-xl">
- {t('signUp')}
-
@@ -291,37 +289,7 @@ export function LoginPage() {
-
-
-
- {tCommon('or')}
-
-
-
-
- router.push("/signup")}
- className="w-full rounded-xl"
- >
- {t('createAccount')}
-
-
-
-
- {/* Signup Link */}
-
-
- {t('noAccount')}{" "}
- router.push("/signup")}
- className="text-primary font-medium hover:underline"
- >
- {t('signUp')}
-
-
+ {/* 2025-12-04: MVP에서 회원가입 섹션 제거 (운영 페이지로 이동 예정) */}
diff --git a/src/components/clients/ClientDetail.tsx b/src/components/clients/ClientDetail.tsx
new file mode 100644
index 00000000..14091dc0
--- /dev/null
+++ b/src/components/clients/ClientDetail.tsx
@@ -0,0 +1,247 @@
+/**
+ * 거래처 상세 보기 컴포넌트
+ *
+ * 스크린샷 기준 4개 섹션:
+ * 1. 기본 정보
+ * 2. 연락처 정보
+ * 3. 결제 정보
+ * 4. 악성채권 정보 (있는 경우 빨간 테두리)
+ */
+
+"use client";
+
+import { useRouter } from "next/navigation";
+import { Button } from "../ui/button";
+import { Badge } from "../ui/badge";
+import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
+import {
+ Building2,
+ Phone,
+ CreditCard,
+ AlertTriangle,
+ ArrowLeft,
+ Pencil,
+ Trash2,
+ MapPin,
+ Mail,
+} from "lucide-react";
+import { Client } from "../../hooks/useClientList";
+
+interface ClientDetailProps {
+ client: Client;
+ onBack: () => void;
+ onEdit: () => void;
+ onDelete: () => void;
+}
+
+// 상세 항목 표시 컴포넌트
+function DetailItem({
+ label,
+ value,
+ icon,
+ valueClassName,
+}: {
+ label: string;
+ value: React.ReactNode;
+ icon?: React.ReactNode;
+ valueClassName?: string;
+}) {
+ return (
+
+
{label}
+
+ {icon}
+ {value || "-"}
+
+
+ );
+}
+
+export function ClientDetail({
+ client,
+ onBack,
+ onEdit,
+ onDelete,
+}: ClientDetailProps) {
+ const router = useRouter();
+
+ // 금액 포맷
+ const formatCurrency = (amount: string) => {
+ if (!amount) return "-";
+ const num = Number(amount);
+ return `₩${num.toLocaleString()}`;
+ };
+
+ return (
+
+ {/* 헤더 */}
+
+
+
+
{client.name}
+
+
+
+
+ 목록
+
+
+
+ 수정
+
+
+
+ 삭제
+
+
+
+
+ {/* 1. 기본 정보 */}
+
+
+
+
+ 기본 정보
+
+
+
+
+
+
+
+ {client.clientType}
+
+ }
+ />
+
+
+
+
+
+
+ {client.status}
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+
+ {client.memo && (
+
+ )}
+
+
+
+ {/* 2. 연락처 정보 */}
+
+
+
+
+
+
+ }
+ />
+ }
+ />
+
+
+
+
+ }
+ />
+
+
+
+
+
+
+
+
+
+ {/* 3. 결제 정보 */}
+
+
+
+
+ 결제 정보
+
+
+
+
+
+
+
+
+
+
+ {/* 4. 악성채권 정보 (있는 경우에만 표시) */}
+ {client.badDebt && (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/clients/ClientRegistration.tsx b/src/components/clients/ClientRegistration.tsx
new file mode 100644
index 00000000..1bec63fc
--- /dev/null
+++ b/src/components/clients/ClientRegistration.tsx
@@ -0,0 +1,607 @@
+/**
+ * 거래처 등록/수정 컴포넌트
+ *
+ * ResponsiveFormTemplate 적용
+ * - 데스크톱/태블릿/모바일 통합 폼 레이아웃
+ * - 섹션 기반 정보 입력
+ * - 유효성 검사 및 에러 표시
+ */
+
+"use client";
+
+import { useState, useEffect } from "react";
+import { Input } from "../ui/input";
+import { Textarea } from "../ui/textarea";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "../ui/select";
+import { RadioGroup, RadioGroupItem } from "../ui/radio-group";
+import { Checkbox } from "../ui/checkbox";
+import { Label } from "../ui/label";
+import {
+ Building2,
+ UserCircle,
+ Phone,
+ CreditCard,
+ FileText,
+ AlertTriangle,
+ Calculator,
+} from "lucide-react";
+import { toast } from "sonner";
+import {
+ ResponsiveFormTemplate,
+ FormSection,
+ FormFieldGrid,
+} from "../templates/ResponsiveFormTemplate";
+import { FormField } from "../molecules/FormField";
+import {
+ ClientFormData,
+ INITIAL_CLIENT_FORM,
+ ClientType,
+ BadDebtProgress,
+} from "../../hooks/useClientList";
+
+interface ClientRegistrationProps {
+ onBack: () => void;
+ onSave: (client: ClientFormData) => Promise;
+ editingClient?: ClientFormData | null;
+ isLoading?: boolean;
+}
+
+export function ClientRegistration({
+ onBack,
+ onSave,
+ editingClient,
+ isLoading = false,
+}: ClientRegistrationProps) {
+ const [formData, setFormData] = useState(
+ editingClient || INITIAL_CLIENT_FORM
+ );
+ const [errors, setErrors] = useState>({});
+ const [isSaving, setIsSaving] = useState(false);
+
+ // editingClient가 변경되면 formData 업데이트
+ useEffect(() => {
+ if (editingClient) {
+ setFormData(editingClient);
+ }
+ }, [editingClient]);
+
+ // 유효성 검사
+ const validateForm = (): boolean => {
+ const newErrors: Record = {};
+
+ if (!formData.name || formData.name.length < 2) {
+ newErrors.name = "거래처명은 2자 이상 입력해주세요";
+ }
+
+ if (!formData.businessNo || !/^\d{10}$/.test(formData.businessNo)) {
+ newErrors.businessNo = "사업자등록번호는 10자리 숫자여야 합니다";
+ }
+
+ if (!formData.representative || formData.representative.length < 2) {
+ newErrors.representative = "대표자명은 2자 이상 입력해주세요";
+ }
+
+ // 전화번호 형식 검사 (선택적)
+ const phonePattern = /^[0-9-]+$/;
+ if (formData.phone && !phonePattern.test(formData.phone)) {
+ newErrors.phone = "올바른 전화번호 형식이 아닙니다";
+ }
+
+ if (formData.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
+ newErrors.email = "올바른 이메일 형식이 아닙니다";
+ }
+
+ setErrors(newErrors);
+ return Object.keys(newErrors).length === 0;
+ };
+
+ const handleSubmit = async () => {
+ if (!validateForm()) {
+ toast.error("입력 내용을 확인해주세요.");
+ return;
+ }
+
+ setIsSaving(true);
+ try {
+ await onSave(formData);
+ toast.success(
+ editingClient ? "거래처가 수정되었습니다." : "거래처가 등록되었습니다."
+ );
+ onBack();
+ } catch (error) {
+ toast.error("저장 중 오류가 발생했습니다.");
+ } finally {
+ setIsSaving(false);
+ }
+ };
+
+ const handleFieldChange = (
+ field: keyof ClientFormData,
+ value: string | boolean
+ ) => {
+ setFormData({ ...formData, [field]: value });
+ if (errors[field]) {
+ setErrors((prev) => {
+ const newErrors = { ...prev };
+ delete newErrors[field];
+ return newErrors;
+ });
+ }
+ };
+
+ return (
+
+ {/* 1. 기본 정보 */}
+
+
+
+ handleFieldChange("businessNo", e.target.value)}
+ />
+
+
+
+
+
+
+
+
+
+ handleFieldChange("name", e.target.value)}
+ />
+
+
+
+
+ handleFieldChange("representative", e.target.value)
+ }
+ />
+
+
+
+
+
+ handleFieldChange("clientType", value as ClientType)
+ }
+ className="flex gap-4"
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ handleFieldChange("businessType", e.target.value)
+ }
+ />
+
+
+
+
+ handleFieldChange("businessItem", e.target.value)
+ }
+ />
+
+
+
+
+ {/* 2. 연락처 정보 */}
+
+
+ handleFieldChange("address", e.target.value)}
+ />
+
+
+
+
+ handleFieldChange("phone", e.target.value)}
+ />
+
+
+
+ handleFieldChange("mobile", e.target.value)}
+ />
+
+
+
+ handleFieldChange("fax", e.target.value)}
+ />
+
+
+
+
+ handleFieldChange("email", e.target.value)}
+ />
+
+
+
+ {/* 3. 담당자 정보 */}
+
+
+
+ handleFieldChange("managerName", e.target.value)}
+ />
+
+
+
+ handleFieldChange("managerTel", e.target.value)}
+ />
+
+
+
+
+
+ handleFieldChange("systemManager", e.target.value)
+ }
+ />
+
+
+
+ {/* 4. 발주처 설정 */}
+
+
+
+ handleFieldChange("accountId", e.target.value)}
+ />
+
+
+
+
+ handleFieldChange("accountPassword", e.target.value)
+ }
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* 5. 약정 세금 */}
+
+
+
+
+ handleFieldChange("taxAgreement", checked as boolean)
+ }
+ />
+
+
+
+
+ {formData.taxAgreement && (
+ <>
+
+ handleFieldChange("taxAmount", e.target.value)}
+ />
+
+
+
+
+
+ handleFieldChange("taxStartDate", e.target.value)
+ }
+ />
+
+
+
+
+ handleFieldChange("taxEndDate", e.target.value)
+ }
+ />
+
+
+ >
+ )}
+
+
+ {/* 6. 악성채권 */}
+
+
+
+
+ handleFieldChange("badDebt", checked as boolean)
+ }
+ />
+
+
+
+
+ {formData.badDebt && (
+ <>
+
+
+ handleFieldChange("badDebtAmount", e.target.value)
+ }
+ />
+
+
+
+
+
+ handleFieldChange("badDebtReceiveDate", e.target.value)
+ }
+ />
+
+
+
+
+ handleFieldChange("badDebtEndDate", e.target.value)
+ }
+ />
+
+
+
+
+
+
+ >
+ )}
+
+
+ {/* 7. 기타 */}
+
+
+
+
+
+
+ handleFieldChange("isActive", value === "활성")
+ }
+ className="flex gap-4"
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/items/DynamicItemForm/hooks/useDynamicFormState.ts b/src/components/items/DynamicItemForm/hooks/useDynamicFormState.ts
index d8add395..2202ea6f 100644
--- a/src/components/items/DynamicItemForm/hooks/useDynamicFormState.ts
+++ b/src/components/items/DynamicItemForm/hooks/useDynamicFormState.ts
@@ -9,7 +9,7 @@
'use client';
-import { useState, useCallback } from 'react';
+import { useState, useCallback, useEffect, useRef } from 'react';
import type {
DynamicFormData,
DynamicFormErrors,
@@ -25,6 +25,21 @@ export function useDynamicFormState(
const [errors, setErrors] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
+ // 2025-12-04: Edit 모드에서 initialData가 비동기로 로드될 때 formData 동기화
+ // useState의 초기값은 첫 렌더 시에만 사용되므로,
+ // initialData가 나중에 변경되면 formData를 업데이트해야 함
+ const isInitialDataLoaded = useRef(false);
+
+ useEffect(() => {
+ // initialData가 있고, 아직 로드되지 않았을 때만 동기화
+ // (사용자가 수정 중인 데이터를 덮어쓰지 않도록)
+ if (initialData && Object.keys(initialData).length > 0 && !isInitialDataLoaded.current) {
+ console.log('[useDynamicFormState] initialData 동기화:', initialData);
+ setFormData(initialData);
+ isInitialDataLoaded.current = true;
+ }
+ }, [initialData]);
+
// 필드 값 설정
const setFieldValue = useCallback((fieldKey: string, value: DynamicFieldValue) => {
setFormData((prev) => ({
@@ -149,17 +164,21 @@ export function useDynamicFormState(
);
// 폼 제출
+ // 2025-12-04: 실패 시에만 버튼 다시 활성화 (로그인 방식)
+ // 성공 시에는 페이지 이동하므로 버튼 비활성화 상태 유지
const handleSubmit = useCallback(
async (onSubmit: (data: DynamicFormData) => Promise) => {
setIsSubmitting(true);
try {
await onSubmit(formData);
+ // 성공 시: setIsSubmitting(false)를 호출하지 않음
+ // 페이지 이동하므로 버튼 비활성화 상태 유지 → 중복 클릭 방지
} catch (err) {
console.error('폼 제출 실패:', err);
- throw err;
- } finally {
+ // 실패 시에만 버튼 다시 활성화 → 재시도 가능
setIsSubmitting(false);
+ throw err;
}
},
[formData]
diff --git a/src/components/items/DynamicItemForm/index.tsx b/src/components/items/DynamicItemForm/index.tsx
index 31ff23cb..fa1e81a2 100644
--- a/src/components/items/DynamicItemForm/index.tsx
+++ b/src/components/items/DynamicItemForm/index.tsx
@@ -32,6 +32,7 @@ import {
generateAssemblyItemNameSimple,
generateAssemblySpecification,
generateBendingItemCodeSimple,
+ generatePurchasedItemCode,
} from './utils/itemCodeGenerator';
import type { DynamicItemFormProps, DynamicFormData, DynamicSection, DynamicFieldValue, BOMLine, BOMSearchState } from './types';
import type { ItemType, BendingDetail } from '@/types/item';
@@ -255,6 +256,10 @@ export default function DynamicItemForm({
const [bendingDetails, setBendingDetails] = useState([]);
const [widthSum, setWidthSum] = useState('');
+ // FG(제품) 전용 파일 업로드 상태 관리
+ const [specificationFile, setSpecificationFile] = useState(null);
+ const [certificationFile, setCertificationFile] = useState(null);
+
// 조건부 표시 관리
const { shouldShowSection, shouldShowField } = useConditionalDisplay(structure, formData);
@@ -274,7 +279,7 @@ export default function DynamicItemForm({
.map((item: { code?: string; item_code?: string }) => item.code || item.item_code || '')
.filter((code: string) => code);
setExistingItemCodes(codes);
- console.log('[DynamicItemForm] PT 기존 품목코드 로드:', codes.length, '개');
+ // console.log('[DynamicItemForm] PT 기존 품목코드 로드:', codes.length, '개');
}
} catch (err) {
console.error('[DynamicItemForm] PT 품목코드 조회 실패:', err);
@@ -287,7 +292,7 @@ export default function DynamicItemForm({
}
}, [selectedItemType]);
- // 품목 유형 변경 시 폼 초기화
+ // 품목 유형 변경 시 폼 초기화 (create 모드)
useEffect(() => {
if (selectedItemType && mode === 'create' && structure) {
// 기본값 설정
@@ -322,6 +327,82 @@ export default function DynamicItemForm({
}
}, [selectedItemType, structure, mode, resetForm]);
+ // Edit 모드: structure 로드 후 initialData를 field_key 형식으로 변환
+ // 2025-12-04: initialData 키(item_name)와 structure의 field_key(98_item_name)가 다른 문제 해결
+ const [isEditDataMapped, setIsEditDataMapped] = useState(false);
+
+ useEffect(() => {
+ if (mode !== 'edit' || !structure || !initialData || isEditDataMapped) return;
+
+ // console.log('[DynamicItemForm] Edit mode: mapping initialData to field_key format');
+
+ // initialData의 간단한 키를 structure의 field_key로 매핑
+ // 예: { item_name: '테스트' } → { '98_item_name': '테스트' }
+ const mappedData: DynamicFormData = {};
+
+ // field_key에서 실제 필드명 추출하는 함수
+ // 예: '98_item_name' → 'item_name', '110_품목명' → '품목명'
+ const extractFieldName = (fieldKey: string): string => {
+ const underscoreIndex = fieldKey.indexOf('_');
+ if (underscoreIndex > 0) {
+ return fieldKey.substring(underscoreIndex + 1);
+ }
+ return fieldKey;
+ };
+
+ // structure에서 모든 필드의 field_key 수집
+ const fieldKeyMap: Record = {}; // 간단한 키 → field_key 매핑
+
+ structure.sections.forEach((section) => {
+ section.fields.forEach((f) => {
+ const field = f.field;
+ const fieldKey = field.field_key || `field_${field.id}`;
+ const simpleName = extractFieldName(fieldKey);
+ fieldKeyMap[simpleName] = fieldKey;
+
+ // field_name도 매핑에 추가 (한글 필드명 지원)
+ if (field.field_name) {
+ fieldKeyMap[field.field_name] = fieldKey;
+ }
+ });
+ });
+
+ structure.directFields.forEach((f) => {
+ const field = f.field;
+ const fieldKey = field.field_key || `field_${field.id}`;
+ const simpleName = extractFieldName(fieldKey);
+ fieldKeyMap[simpleName] = fieldKey;
+
+ if (field.field_name) {
+ fieldKeyMap[field.field_name] = fieldKey;
+ }
+ });
+
+ // console.log('[DynamicItemForm] fieldKeyMap:', fieldKeyMap);
+
+ // initialData를 field_key 형식으로 변환
+ Object.entries(initialData).forEach(([key, value]) => {
+ // 이미 field_key 형식인 경우 그대로 사용
+ if (key.includes('_') && /^\d+_/.test(key)) {
+ mappedData[key] = value;
+ }
+ // 간단한 키인 경우 field_key로 변환
+ else if (fieldKeyMap[key]) {
+ mappedData[fieldKeyMap[key]] = value;
+ }
+ // 매핑 없는 경우 그대로 유지
+ else {
+ mappedData[key] = value;
+ }
+ });
+
+ // console.log('[DynamicItemForm] Mapped initialData:', mappedData);
+
+ // 변환된 데이터로 폼 리셋
+ resetForm(mappedData);
+ setIsEditDataMapped(true);
+ }, [mode, structure, initialData, isEditDataMapped, resetForm]);
+
// 모든 필드 목록 (밸리데이션용) - 숨겨진 섹션/필드 제외
const allFields = useMemo(() => {
if (!structure) return [];
@@ -440,10 +521,10 @@ export default function DynamicItemForm({
return allSpecificationKeys[0] || '';
}, [structure, allSpecificationKeys, shouldShowSection, shouldShowField]);
- // 부품 유형 필드 탐지 (PT 품목에서 절곡/조립 부품 판별용)
- const { partTypeFieldKey, selectedPartType, isBendingPart, isAssemblyPart } = useMemo(() => {
+ // 부품 유형 필드 탐지 (PT 품목에서 절곡/조립/구매 부품 판별용)
+ const { partTypeFieldKey, selectedPartType, isBendingPart, isAssemblyPart, isPurchasedPart } = useMemo(() => {
if (!structure || selectedItemType !== 'PT') {
- return { partTypeFieldKey: '', selectedPartType: '', isBendingPart: false, isAssemblyPart: false };
+ return { partTypeFieldKey: '', selectedPartType: '', isBendingPart: false, isAssemblyPart: false, isPurchasedPart: false };
}
let foundPartTypeKey = '';
@@ -477,19 +558,17 @@ export default function DynamicItemForm({
const isBending = currentPartType.includes('절곡') || currentPartType.toUpperCase() === 'BENDING';
// "조립 부품", "ASSEMBLY", "조립부품" 등 다양한 형태 지원
const isAssembly = currentPartType.includes('조립') || currentPartType.toUpperCase() === 'ASSEMBLY';
+ // "구매 부품", "PURCHASED", "구매부품" 등 다양한 형태 지원
+ const isPurchased = currentPartType.includes('구매') || currentPartType.toUpperCase() === 'PURCHASED';
- console.log('[DynamicItemForm] 부품 유형 감지:', {
- partTypeFieldKey: foundPartTypeKey,
- currentPartType,
- isBending,
- isAssembly,
- });
+ // console.log('[DynamicItemForm] 부품 유형 감지:', { partTypeFieldKey: foundPartTypeKey, currentPartType, isBending, isAssembly, isPurchased });
return {
partTypeFieldKey: foundPartTypeKey,
selectedPartType: currentPartType,
isBendingPart: isBending,
isAssemblyPart: isAssembly,
+ isPurchasedPart: isPurchased,
};
}, [structure, selectedItemType, formData]);
@@ -508,7 +587,7 @@ export default function DynamicItemForm({
// 이전 값이 있고, 현재 값과 다른 경우에만 초기화
if (prevPartType && prevPartType !== currentPartType) {
- console.log('[DynamicItemForm] 부품 유형 변경 감지:', prevPartType, '→', currentPartType);
+ // console.log('[DynamicItemForm] 부품 유형 변경 감지:', prevPartType, '→', currentPartType);
// setTimeout으로 다음 틱에서 초기화 실행
// → 부품 유형 Select 값 변경이 먼저 완료된 후 초기화
@@ -555,7 +634,7 @@ export default function DynamicItemForm({
// 중복 제거 후 초기화
const uniqueFields = [...new Set(fieldsToReset)];
- console.log('[DynamicItemForm] 초기화할 필드:', uniqueFields);
+ // console.log('[DynamicItemForm] 초기화할 필드:', uniqueFields);
uniqueFields.forEach((fieldKey) => {
setFieldValue(fieldKey, '');
@@ -612,12 +691,12 @@ export default function DynamicItemForm({
// bending_parts는 무조건 우선 (덮어쓰기)
if (isBendingItemNameField) {
- console.log('[checkField] 절곡부품 품목명 필드 발견!', { fieldKey, fieldName });
+ // console.log('[checkField] 절곡부품 품목명 필드 발견!', { fieldKey, fieldName });
bendingItemNameKey = fieldKey;
}
// 일반 품목명은 아직 없을 때만
else if (isGeneralItemNameField && !bendingItemNameKey) {
- console.log('[checkField] 일반 품목명 필드 발견!', { fieldKey, fieldName });
+ // console.log('[checkField] 일반 품목명 필드 발견!', { fieldKey, fieldName });
bendingItemNameKey = fieldKey;
}
@@ -686,19 +765,7 @@ export default function DynamicItemForm({
const autoCode = generateBendingItemCodeSimple(itemNameValue, categoryValue, shapeLengthValue);
- console.log('[DynamicItemForm] 절곡부품 필드 탐지:', {
- bendingItemNameKey,
- itemNameKey,
- effectiveItemNameKey,
- materialKey,
- categoryKeysWithIds,
- activeCategoryKey,
- widthSumKey,
- shapeLengthKey,
- formDataKeys: Object.keys(formData),
- values: { itemNameValue, categoryValue, shapeLengthValue },
- autoCode,
- });
+ // console.log('[DynamicItemForm] 절곡부품 필드 탐지:', { bendingItemNameKey, materialKey, activeCategoryKey, autoCode });
return {
bendingFieldKeys: {
@@ -726,13 +793,13 @@ export default function DynamicItemForm({
// 품목명이 변경되었고, 이전 값이 있었을 때만 종류 필드 초기화
if (prevItemNameValue && prevItemNameValue !== currentItemNameValue) {
- console.log('[DynamicItemForm] 품목명 변경 감지:', prevItemNameValue, '→', currentItemNameValue);
+ // console.log('[DynamicItemForm] 품목명 변경 감지:', prevItemNameValue, '→', currentItemNameValue);
// 모든 종류 필드 값 초기화
allCategoryKeysWithIds.forEach(({ key }) => {
const currentVal = (formData[key] as string) || '';
if (currentVal) {
- console.log('[DynamicItemForm] 종류 필드 초기화:', key);
+ // console.log('[DynamicItemForm] 종류 필드 초기화:', key);
setFieldValue(key, '');
}
});
@@ -763,12 +830,7 @@ export default function DynamicItemForm({
fieldKey.includes('부품구성');
if (isCheckbox && isBomRelated) {
- console.log('[DynamicItemForm] BOM 체크박스 필드 발견:', {
- fieldKey,
- fieldName,
- fieldType,
- resultKey: field.field_key || `field_${field.id}`,
- });
+ // console.log('[DynamicItemForm] BOM 체크박스 필드 발견:', { fieldKey, fieldName });
return field.field_key || `field_${field.id}`;
}
}
@@ -789,17 +851,12 @@ export default function DynamicItemForm({
fieldKey.includes('부품구성');
if (isCheckbox && isBomRelated) {
- console.log('[DynamicItemForm] BOM 체크박스 필드 발견 (직접필드):', {
- fieldKey,
- fieldName,
- fieldType,
- resultKey: field.field_key || `field_${field.id}`,
- });
+ // console.log('[DynamicItemForm] BOM 체크박스 필드 발견 (직접필드):', { fieldKey, fieldName });
return field.field_key || `field_${field.id}`;
}
}
- console.log('[DynamicItemForm] BOM 체크박스 필드를 찾지 못함');
+ // console.log('[DynamicItemForm] BOM 체크박스 필드를 찾지 못함');
return '';
}, [structure]);
@@ -878,15 +935,7 @@ export default function DynamicItemForm({
// 규격: 가로x세로x길이(네자리)
const autoSpec = generateAssemblySpecification(sideSpecWidth, sideSpecHeight, assemblyLength);
- console.log('[DynamicItemForm] 조립 부품 필드 탐지:', {
- isAssembly,
- sideSpecWidthKey,
- sideSpecHeightKey,
- assemblyLengthKey,
- values: { sideSpecWidth, sideSpecHeight, assemblyLength },
- autoItemName,
- autoSpec,
- });
+ // console.log('[DynamicItemForm] 조립 부품 필드 탐지:', { isAssembly, autoItemName, autoSpec });
return {
hasAssemblyFields: isAssembly,
@@ -900,6 +949,97 @@ export default function DynamicItemForm({
};
}, [structure, selectedItemType, formData, itemNameKey]);
+ // 구매 부품(전동개폐기) 필드 탐지 - 품목명, 용량, 전원
+ // 2025-12-04: 구매 부품 품목코드 자동생성 추가
+ const { purchasedFieldKeys, autoPurchasedItemCode } = useMemo(() => {
+ if (!structure || selectedItemType !== 'PT' || !isPurchasedPart) {
+ return {
+ purchasedFieldKeys: {
+ itemName: '', // 품목명 (전동개폐기 등)
+ capacity: '', // 용량 (150, 300, etc.)
+ power: '', // 전원 (220V, 380V)
+ },
+ autoPurchasedItemCode: '',
+ };
+ }
+
+ let purchasedItemNameKey = '';
+ let capacityKey = '';
+ let powerKey = '';
+
+ const checkField = (fieldKey: string, field: ItemFieldResponse) => {
+ const fieldName = field.field_name || '';
+ const lowerKey = fieldKey.toLowerCase();
+
+ // 구매 부품 품목명 필드 탐지 - PurchasedItemName 우선 탐지
+ const isPurchasedItemNameField = lowerKey.includes('purchaseditemname');
+ const isItemNameField =
+ isPurchasedItemNameField ||
+ lowerKey.includes('item_name') ||
+ lowerKey.includes('품목명') ||
+ fieldName.includes('품목명') ||
+ fieldName === '품목명';
+
+ // PurchasedItemName을 우선적으로 사용 (더 정확한 매칭)
+ if (isPurchasedItemNameField) {
+ purchasedItemNameKey = fieldKey; // 덮어쓰기 (우선순위 높음)
+ } else if (isItemNameField && !purchasedItemNameKey) {
+ purchasedItemNameKey = fieldKey;
+ }
+
+ // 용량 필드 탐지
+ const isCapacityField =
+ lowerKey.includes('capacity') ||
+ lowerKey.includes('용량') ||
+ fieldName.includes('용량') ||
+ fieldName === '용량';
+ if (isCapacityField && !capacityKey) {
+ capacityKey = fieldKey;
+ }
+
+ // 전원 필드 탐지
+ const isPowerField =
+ lowerKey.includes('power') ||
+ lowerKey.includes('전원') ||
+ fieldName.includes('전원') ||
+ fieldName === '전원';
+ if (isPowerField && !powerKey) {
+ powerKey = fieldKey;
+ }
+ };
+
+ // 모든 필드 검사
+ structure.sections.forEach((section) => {
+ section.fields.forEach((f) => {
+ const key = f.field.field_key || `field_${f.field.id}`;
+ checkField(key, f.field);
+ });
+ });
+
+ structure.directFields.forEach((f) => {
+ const key = f.field.field_key || `field_${f.field.id}`;
+ checkField(key, f.field);
+ });
+
+ // 품목코드 자동생성: 품목명 + 용량 + 전원
+ const itemNameValue = purchasedItemNameKey ? (formData[purchasedItemNameKey] as string) || '' : '';
+ const capacityValue = capacityKey ? (formData[capacityKey] as string) || '' : '';
+ const powerValue = powerKey ? (formData[powerKey] as string) || '' : '';
+
+ const autoCode = generatePurchasedItemCode(itemNameValue, capacityValue, powerValue);
+
+ // console.log('[DynamicItemForm] 구매 부품 필드 탐지:', { purchasedItemNameKey, autoCode });
+
+ return {
+ purchasedFieldKeys: {
+ itemName: purchasedItemNameKey,
+ capacity: capacityKey,
+ power: powerKey,
+ },
+ autoPurchasedItemCode: autoCode,
+ };
+ }, [structure, selectedItemType, isPurchasedPart, formData]);
+
// 품목코드 자동생성 값
// PT(부품): 영문약어-순번 (예: GR-001, MOTOR-002)
// 기타 품목: 품목명-규격 (기존 방식)
@@ -949,6 +1089,7 @@ export default function DynamicItemForm({
// 2025-12-03: 한글 field_key 지원 추가
const fieldKeyToBackendKey: Record = {
'item_name': 'name',
+ 'productName': 'name', // FG(제품) 품목명 필드
'품목명': 'name', // 한글 field_key 지원
'specification': 'spec',
'standard': 'spec', // 규격 대체 필드명
@@ -972,11 +1113,16 @@ export default function DynamicItemForm({
};
// formData를 백엔드 필드명으로 변환
+ // console.log('[DynamicItemForm] formData before conversion:', formData);
const convertedData: Record = {};
Object.entries(formData).forEach(([key, value]) => {
- // "{id}_{fieldKey}" 형식에서 fieldKey 추출
- const underscoreIndex = key.indexOf('_');
- if (underscoreIndex > 0) {
+ // "{id}_{fieldKey}" 형식 체크: 숫자로 시작하고 _가 있는 경우
+ // 예: "98_item_name" → true, "item_name" → false
+ const isFieldKeyFormat = /^\d+_/.test(key);
+
+ if (isFieldKeyFormat) {
+ // "{id}_{fieldKey}" 형식에서 fieldKey 추출
+ const underscoreIndex = key.indexOf('_');
const fieldKey = key.substring(underscoreIndex + 1);
const backendKey = fieldKeyToBackendKey[fieldKey] || fieldKey;
@@ -990,10 +1136,19 @@ export default function DynamicItemForm({
convertedData[backendKey] = value;
}
} else {
- // 변환 불필요한 필드는 그대로
- convertedData[key] = value;
+ // field_key 형식이 아닌 경우, 매핑 테이블에서 변환 시도
+ const backendKey = fieldKeyToBackendKey[key] || key;
+
+ if (backendKey === 'is_active') {
+ const isActive = value === true || value === 'true' || value === '1' ||
+ value === 1 || value === '활성' || value === 'active';
+ convertedData[backendKey] = isActive;
+ } else {
+ convertedData[backendKey] = value;
+ }
}
});
+ // console.log('[DynamicItemForm] convertedData after conversion:', convertedData);
// 품목명 값 추출 (품목코드와 품목명 모두 필요)
// 2025-12-04: 절곡 부품은 별도 품목명 필드(bendingFieldKeys.itemName) 사용
@@ -1004,9 +1159,10 @@ export default function DynamicItemForm({
? (formData[effectiveItemNameKeyForSubmit] as string) || ''
: '';
- // 조립/절곡 부품 자동생성 값 결정
+ // 조립/절곡/구매 부품 자동생성 값 결정
// 조립 부품: 품목명 = "품목명 가로x세로", 규격 = "가로x세로x길이"
// 절곡 부품: 품목명 = bendingFieldKeys.itemName에서 선택한 값, 규격 = 없음 (품목코드로 대체)
+ // 구매 부품: 품목명 = purchasedFieldKeys.itemName에서 선택한 값
let finalName: string;
let finalSpec: string | undefined;
@@ -1018,27 +1174,29 @@ export default function DynamicItemForm({
// 절곡 부품: bendingFieldKeys.itemName의 값 사용
finalName = itemNameValue || convertedData.name || '';
finalSpec = convertedData.spec;
+ } else if (isPurchasedPart) {
+ // 구매 부품: purchasedFieldKeys.itemName의 값 사용
+ const purchasedItemNameValue = purchasedFieldKeys.itemName
+ ? (formData[purchasedFieldKeys.itemName] as string) || ''
+ : '';
+ finalName = purchasedItemNameValue || convertedData.name || '';
+ finalSpec = convertedData.spec;
} else {
// 기타: 기존 로직
finalName = convertedData.name || itemNameValue;
finalSpec = convertedData.spec;
}
- console.log('[DynamicItemForm] 품목명/규격 결정:', {
- isAssemblyPart,
- autoAssemblyItemName,
- autoAssemblySpec,
- convertedDataName: convertedData.name,
- convertedDataSpec: convertedData.spec,
- finalName,
- finalSpec,
- });
+ // console.log('[DynamicItemForm] 품목명/규격 결정:', { finalName, finalSpec });
// 품목코드 결정
// 2025-12-04: 절곡 부품은 autoBendingItemCode 사용
+ // 2025-12-04: 구매 부품은 autoPurchasedItemCode 사용
let finalCode: string;
if (isBendingPart && autoBendingItemCode) {
finalCode = autoBendingItemCode;
+ } else if (isPurchasedPart && autoPurchasedItemCode) {
+ finalCode = autoPurchasedItemCode;
} else if (hasAutoItemCode && autoGeneratedItemCode) {
finalCode = autoGeneratedItemCode;
} else {
@@ -1078,16 +1236,17 @@ export default function DynamicItemForm({
part_type: 'ASSEMBLY',
bending_diagram: bendingDiagram || null, // 조립품도 동일한 전개도 필드 사용
} : {}),
+ // 구매품 데이터 (PT - 구매 부품 전용)
+ ...(selectedItemType === 'PT' && isPurchasedPart ? {
+ part_type: 'PURCHASED',
+ } : {}),
+ // FG(제품)은 단위 필드가 없으므로 기본값 'EA' 설정
+ ...(selectedItemType === 'FG' && !convertedData.unit ? {
+ unit: 'EA',
+ } : {}),
};
- // is_active 디버깅 로그
- console.log('[DynamicItemForm] is_active 디버깅:', {
- formDataKeys: Object.keys(formData).filter(k => k.includes('active') || k.includes('상태') || k.includes('status')),
- convertedIsActive: convertedData.is_active,
- submitDataIsActive: submitData.is_active,
- formDataValues: Object.entries(formData).filter(([k]) => k.includes('active') || k.includes('상태') || k.includes('status')),
- });
- console.log('[DynamicItemForm] 제출 데이터:', submitData);
+ // console.log('[DynamicItemForm] 제출 데이터:', submitData);
await handleSubmit(async () => {
await onSubmit(submitData);
@@ -1211,10 +1370,17 @@ export default function DynamicItemForm({
const isSpecField = fieldKey === activeSpecificationKey;
const isStatusField = fieldKey === statusFieldKey;
+ // 품목명 필드인지 체크 (FG 품목코드 자동생성 위치)
+ const isItemNameField = fieldKey === itemNameKey;
// 비고 필드인지 체크 (절곡부품 품목코드 자동생성 위치)
const fieldName = field.field_name || '';
const isNoteField = fieldKey.includes('note') || fieldKey.includes('비고') ||
fieldName.includes('비고') || fieldName === '비고';
+ // 인정 유효기간 종료일 필드인지 체크 (FG 시방서/인정서 파일 업로드 위치)
+ const isCertEndDateField = fieldKey.includes('certification_end') ||
+ fieldKey.includes('인정_유효기간_종료') ||
+ fieldName.includes('인정 유효기간 종료') ||
+ fieldName.includes('유효기간 종료');
// 절곡부품 박스 스타일링 (재질, 폭합계, 모양&길이)
const isBendingBoxField = isBendingPart && (
@@ -1283,6 +1449,87 @@ export default function DynamicItemForm({
)}
+ {/* 비고 필드 다음에 구매부품(전동개폐기) 품목코드 자동생성 */}
+ {isNoteField && isPurchasedPart && (
+
+
+
+
+ * 품목코드는 '품목명+용량+전원' 형식으로 자동 생성됩니다 (예: 전동개폐기150KG380V)
+
+
+ )}
+ {/* FG(제품) 전용: 품목명 필드 다음에 품목코드 자동생성 */}
+ {isItemNameField && selectedItemType === 'FG' && (
+
+
+
+
+ * 제품(FG)의 품목코드는 품목명과 동일하게 설정됩니다
+
+
+ )}
+ {/* 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}
+
+ )}
+
+
+
+ )}
);
})}
@@ -1402,12 +1649,7 @@ export default function DynamicItemForm({
const isBomRequired = bomValue === true || bomValue === 'true' || bomValue === '1' || bomValue === 1;
// 디버깅 로그
- console.log('[DynamicItemForm] BOM 체크 디버깅:', {
- bomRequiredFieldKey,
- bomValue,
- isBomRequired,
- formDataKeys: Object.keys(formData),
- });
+ // console.log('[DynamicItemForm] BOM 체크 디버깅:', { bomRequiredFieldKey, bomValue, isBomRequired });
if (!isBomRequired) return null;
diff --git a/src/components/items/DynamicItemForm/utils/itemCodeGenerator.ts b/src/components/items/DynamicItemForm/utils/itemCodeGenerator.ts
index 3fd60a66..857d1d26 100644
--- a/src/components/items/DynamicItemForm/utils/itemCodeGenerator.ts
+++ b/src/components/items/DynamicItemForm/utils/itemCodeGenerator.ts
@@ -355,6 +355,41 @@ export function generateAssemblySpecification(
return `${sideSpecWidth}x${sideSpecHeight}x${assemblyLength}`;
}
+// ============================================
+// 구매 부품 (전동개폐기) 품목코드 자동생성
+// 2025-12-04 추가
+// ============================================
+
+/**
+ * 전동개폐기 품목코드 생성 (품목명 + 용량 + 전원)
+ * @param itemName 품목명 (예: "전동개폐기")
+ * @param capacity 용량 (예: "150", "300")
+ * @param power 전원 (예: "220V", "380V")
+ * @returns 품목코드 (예: "전동개폐기150KG380V")
+ */
+export function generatePurchasedItemCode(
+ itemName: string,
+ capacity?: string,
+ power?: string
+): string {
+ if (!itemName) return '';
+
+ // 품목명에서 괄호 앞부분만 추출 (예: "전동개폐기 (E)" → "전동개폐기")
+ const cleanItemName = itemName.replace(/\s*\([^)]*\)\s*$/, '').trim();
+
+ if (!capacity || !power) {
+ return cleanItemName;
+ }
+
+ // 용량에서 'KG' 제외하고 숫자만 추출 (이미 "100KG" 형태로 들어올 수 있음)
+ const cleanCapacity = capacity.replace(/KG$/i, '');
+
+ // 전원에서 'V' 제외하고 숫자만 추출 후 다시 V 붙이기 (일관성 유지)
+ const cleanPower = power.replace(/V$/i, '') + 'V';
+
+ return `${cleanItemName}${cleanCapacity}KG${cleanPower}`;
+}
+
// ============================================
// 하드코딩 내역 목록 (문서화용)
// ============================================
diff --git a/src/components/items/ItemDetailClient.tsx b/src/components/items/ItemDetailClient.tsx
index 61ac6e58..9f73872f 100644
--- a/src/components/items/ItemDetailClient.tsx
+++ b/src/components/items/ItemDetailClient.tsx
@@ -90,7 +90,7 @@ export default function ItemDetailClient({ item }: ItemDetailClientProps) {
router.push(`/items/${encodeURIComponent(item.itemCode)}/edit`)}
+ onClick={() => router.push(`/items/${encodeURIComponent(item.itemCode)}/edit?type=${item.itemType}&id=${item.id}`)}
>
수정
diff --git a/src/components/items/ItemForm/forms/ProductForm.tsx b/src/components/items/ItemForm/forms/ProductForm.tsx
index 6fa92a02..446055a2 100644
--- a/src/components/items/ItemForm/forms/ProductForm.tsx
+++ b/src/components/items/ItemForm/forms/ProductForm.tsx
@@ -109,20 +109,16 @@ export default function ProductForm({
)}
-
+
@@ -159,6 +155,15 @@ export default function ProductForm({
* 비활성 시 품목 사용이 제한됩니다
+
+
+
+ setRemarks(e.target.value)}
+ />
+
>
);
}
@@ -199,7 +204,7 @@ export function ProductCertificationSection({
{/* 인정번호, 유효기간, 파일 업로드, 비고 */}
-
+
-
-
-
{
- const file = e.target.files?.[0];
- if (file) {
- setSpecificationFile(file);
- }
- }}
- className="flex-1"
- disabled={isSubmitting}
- />
+
+
+
+
+ {specificationFile ? specificationFile.name : '선택된 파일 없음'}
+
{specificationFile && (
setSpecificationFile(null)}
disabled={isSubmitting}
+ className="h-6 w-6 p-0"
>
)}
- {specificationFile && (
-
- 첨부됨: {specificationFile.name}
-
- )}
{/* 인정서 파일 */}
-
-
-
{
- const file = e.target.files?.[0];
- if (file) {
- setCertificationFile(file);
- }
- }}
- className="flex-1"
- disabled={isSubmitting}
- />
+
+
+
+
+ {certificationFile ? certificationFile.name : '선택된 파일 없음'}
+
{certificationFile && (
setCertificationFile(null)}
disabled={isSubmitting}
+ className="h-6 w-6 p-0"
>
)}
- {certificationFile && (
-
- 첨부됨: {certificationFile.name}
-
- )}
{/* 비고 */}
-
+
- {/* 품목코드 자동생성 */}
-
-
-
-
- * 품목코드는 '품목명-규격' 형식으로 자동 생성됩니다
-
-
+ {/* 품목코드 자동생성 - 전동개폐기만 표시 */}
+ {selectedCategory1 === 'electric_opener' && (
+
+
+
+
+ * 품목코드는 '품목명+용량+전원' 형식으로 자동 생성됩니다 (예: 전동개폐기150KG380V)
+
+
+ )}
{/* 품목 상태 */}
diff --git a/src/components/items/ItemListClient.tsx b/src/components/items/ItemListClient.tsx
index 841b03ed..916ed24f 100644
--- a/src/components/items/ItemListClient.tsx
+++ b/src/components/items/ItemListClient.tsx
@@ -90,7 +90,10 @@ export default function ItemListClient() {
// 삭제 다이얼로그 상태
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
- const [itemToDelete, setItemToDelete] = useState<{ id: string; code: string } | null>(null);
+ const [itemToDelete, setItemToDelete] = useState<{ id: string; code: string; itemType: string } | null>(null);
+
+ // Materials 타입 (SM, RM, CS는 Material 테이블 사용)
+ const MATERIAL_TYPES = ['SM', 'RM', 'CS'];
// API에서 품목 목록 및 테이블 컬럼 조회 (서버 사이드 검색/필터링)
const {
@@ -148,17 +151,19 @@ export default function ItemListClient() {
});
};
- const handleView = (itemCode: string) => {
- router.push(`/items/${encodeURIComponent(itemCode)}`);
+ const handleView = (itemCode: string, itemType: string, itemId: string) => {
+ // itemType을 query param으로 전달 (Materials 조회를 위해)
+ router.push(`/items/${encodeURIComponent(itemCode)}?type=${itemType}&id=${itemId}`);
};
- const handleEdit = (itemCode: string) => {
- router.push(`/items/${encodeURIComponent(itemCode)}/edit`);
+ const handleEdit = (itemCode: string, itemType: string, itemId: string) => {
+ // itemType을 query param으로 전달 (Materials 조회를 위해)
+ router.push(`/items/${encodeURIComponent(itemCode)}/edit?type=${itemType}&id=${itemId}`);
};
// 삭제 확인 다이얼로그 열기
- const openDeleteDialog = (itemId: string, itemCode: string) => {
- setItemToDelete({ id: itemId, code: itemCode });
+ const openDeleteDialog = (itemId: string, itemCode: string, itemType: string) => {
+ setItemToDelete({ id: itemId, code: itemCode, itemType });
setDeleteDialogOpen(true);
};
@@ -168,7 +173,17 @@ export default function ItemListClient() {
try {
console.log('[Delete] 삭제 요청:', itemToDelete);
- const response = await fetch(`/api/proxy/items/${itemToDelete.id}`, {
+
+ // Materials (SM, RM, CS)는 /products/materials 엔드포인트 사용
+ // Products (FG, PT)는 /items 엔드포인트 사용
+ const isMaterial = MATERIAL_TYPES.includes(itemToDelete.itemType);
+ const deleteUrl = isMaterial
+ ? `/api/proxy/products/materials/${itemToDelete.id}`
+ : `/api/proxy/items/${itemToDelete.id}`;
+
+ console.log('[Delete] URL:', deleteUrl, '(isMaterial:', isMaterial, ')');
+
+ const response = await fetch(deleteUrl, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
@@ -222,7 +237,15 @@ export default function ItemListClient() {
for (const id of itemIds) {
try {
- const response = await fetch(`/api/proxy/items/${id}`, {
+ // 해당 품목의 itemType 찾기
+ const item = items.find((i) => i.id === id);
+ const isMaterial = item ? MATERIAL_TYPES.includes(item.itemType) : false;
+ // Materials는 /products/materials 엔드포인트, Products는 /items 엔드포인트
+ const deleteUrl = isMaterial
+ ? `/api/proxy/products/materials/${id}`
+ : `/api/proxy/items/${id}`;
+
+ const response = await fetch(deleteUrl, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
});
@@ -329,7 +352,7 @@ export default function ItemListClient() {
handleView(item.itemCode)}
+ onClick={(e) => { e.stopPropagation(); handleView(item.itemCode, item.itemType, item.id); }}
title="상세 보기"
>
@@ -337,7 +360,7 @@ export default function ItemListClient() {
handleEdit(item.itemCode)}
+ onClick={(e) => { e.stopPropagation(); handleEdit(item.itemCode, item.itemType, item.id); }}
title="수정"
>
@@ -345,7 +368,7 @@ export default function ItemListClient() {
openDeleteDialog(item.id, item.itemCode)}
+ onClick={(e) => { e.stopPropagation(); openDeleteDialog(item.id, item.itemCode, item.itemType); }}
title="삭제"
>
@@ -388,7 +411,7 @@ export default function ItemListClient() {
}
isSelected={isSelected}
onToggleSelection={onToggle}
- onCardClick={() => handleView(item.itemCode)}
+ onCardClick={() => handleView(item.itemCode, item.itemType, item.id)}
infoGrid={
{item.specification && (
@@ -400,34 +423,37 @@ export default function ItemListClient() {
}
actions={
-
- { e.stopPropagation(); handleView(item.itemCode); }}
- className="h-8 px-3"
- >
-
- 상세
-
- { e.stopPropagation(); handleEdit(item.itemCode); }}
- className="h-8 px-3"
- >
-
- 수정
-
- { e.stopPropagation(); openDeleteDialog(item.id, item.itemCode); }}
- className="h-8 px-2"
- >
-
-
-
+ isSelected ? (
+
+ { e.stopPropagation(); handleView(item.itemCode, item.itemType, item.id); }}
+ >
+
+ 상세
+
+ { e.stopPropagation(); handleEdit(item.itemCode, item.itemType, item.id); }}
+ >
+
+ 수정
+
+ { e.stopPropagation(); openDeleteDialog(item.id, item.itemCode, item.itemType); }}
+ >
+
+ 삭제
+
+
+ ) : undefined
}
/>
);
diff --git a/src/components/items/ItemMasterDataManagement/dialogs/FieldDialog.tsx b/src/components/items/ItemMasterDataManagement/dialogs/FieldDialog.tsx
index c12008f8..1f168cf6 100644
--- a/src/components/items/ItemMasterDataManagement/dialogs/FieldDialog.tsx
+++ b/src/components/items/ItemMasterDataManagement/dialogs/FieldDialog.tsx
@@ -426,9 +426,25 @@ export function FieldDialog({
취소
{
+ console.log('[FieldDialog] 🔵 저장 버튼 클릭!', {
+ fieldInputMode,
+ editingFieldId,
+ selectedMasterFieldId,
+ newFieldName,
+ newFieldKey,
+ isNameEmpty,
+ isKeyEmpty,
+ isKeyInvalid,
+ });
setIsSubmitted(true);
// 2025-11-28: field_key validation 추가
- if ((fieldInputMode === 'custom' || editingFieldId) && (isNameEmpty || isKeyEmpty || isKeyInvalid)) return;
+ const shouldValidate = fieldInputMode === 'custom' || editingFieldId;
+ console.log('[FieldDialog] 🔵 shouldValidate:', shouldValidate);
+ if (shouldValidate && (isNameEmpty || isKeyEmpty || isKeyInvalid)) {
+ console.log('[FieldDialog] ❌ 유효성 검사 실패로 return');
+ return;
+ }
+ console.log('[FieldDialog] ✅ handleAddField 호출');
await handleAddField();
setIsSubmitted(false);
}}>저장
diff --git a/src/components/items/ItemMasterDataManagement/hooks/useFieldManagement.ts b/src/components/items/ItemMasterDataManagement/hooks/useFieldManagement.ts
index 8ac8f950..53aaab9f 100644
--- a/src/components/items/ItemMasterDataManagement/hooks/useFieldManagement.ts
+++ b/src/components/items/ItemMasterDataManagement/hooks/useFieldManagement.ts
@@ -117,8 +117,8 @@ export function useFieldManagement(): UseFieldManagementReturn {
const masterField = itemMasterFields.find(f => f.id === Number(selectedMasterFieldId));
if (masterField) {
setNewFieldName(masterField.field_name);
- // 2025-11-28: field_key 사용 (없으면 빈 문자열로 사용자가 입력하도록)
- setNewFieldKey('');
+ // 2025-12-04: master 모드에서 field_key를 field_{id} 형태로 설정 (백엔드 검증 통과용)
+ setNewFieldKey(`field_${selectedMasterFieldId}`);
setNewFieldInputType(masterField.field_type || 'textbox');
// properties에서 required 확인, 또는 validation_rules에서 확인
const isRequired = (masterField.properties as any)?.required || false;
@@ -139,7 +139,22 @@ export function useFieldManagement(): UseFieldManagementReturn {
// 필드 추가 (2025-11-27: async/await 추가 - 다른 탭 실시간 동기화)
const handleAddField = async (selectedPage: ItemPage | undefined) => {
+ console.log('[useFieldManagement] 🟢 handleAddField 시작!', {
+ selectedPage: selectedPage?.id,
+ selectedSectionForField,
+ newFieldName,
+ newFieldKey,
+ fieldInputMode,
+ selectedMasterFieldId,
+ });
+
if (!selectedPage || !selectedSectionForField || !newFieldName.trim() || !newFieldKey.trim()) {
+ console.log('[useFieldManagement] ❌ 필수값 누락으로 return', {
+ selectedPage: !!selectedPage,
+ selectedSectionForField,
+ newFieldName: newFieldName.trim(),
+ newFieldKey: newFieldKey.trim(),
+ });
toast.error('모든 필수 항목을 입력해주세요');
return;
}
diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx
index 2aad8b3e..e871c601 100644
--- a/src/components/layout/Sidebar.tsx
+++ b/src/components/layout/Sidebar.tsx
@@ -60,11 +60,11 @@ export default function Sidebar({
{menuItems.map((item) => {
const IconComponent = item.icon;
@@ -82,7 +82,7 @@ export default function Sidebar({
handleMenuClick(item.id, item.path, !!hasChildren)}
className={`w-full flex items-center rounded-xl transition-all duration-200 ease-out touch-manipulation group relative overflow-hidden sidebar-menu-item ${
- sidebarCollapsed ? 'p-4 justify-center' : 'space-x-3 p-4 md:p-5'
+ sidebarCollapsed ? 'p-3 justify-center' : 'space-x-2.5 p-3 md:p-3.5'
} ${
isActive
? "text-white clean-shadow scale-[0.98]"
@@ -91,8 +91,8 @@ export default function Sidebar({
style={isActive ? { backgroundColor: '#3B82F6' } : {}}
title={sidebarCollapsed ? item.label : undefined}
>
-
+
{item.children?.map((subItem) => {
const SubIcon = subItem.icon;
const isSubActive = activeMenu === subItem.id;
@@ -134,7 +134,7 @@ export default function Sidebar({
>
handleMenuClick(subItem.id, subItem.path, false)}
- className={`w-full flex items-center rounded-lg transition-all duration-200 p-3 space-x-3 group ${
+ className={`w-full flex items-center rounded-lg transition-all duration-200 p-2.5 space-x-2.5 group ${
isSubActive
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
diff --git a/src/components/molecules/FormField.tsx b/src/components/molecules/FormField.tsx
new file mode 100644
index 00000000..d73b05c1
--- /dev/null
+++ b/src/components/molecules/FormField.tsx
@@ -0,0 +1,185 @@
+/**
+ * FormField - 통합 폼 필드 컴포넌트
+ */
+
+import { ReactNode } from "react";
+import { Label } from "../ui/label";
+import { Input } from "../ui/input";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select";
+import { Textarea } from "../ui/textarea";
+import { AlertCircle } from "lucide-react";
+
+export type FormFieldType = 'text' | 'number' | 'date' | 'select' | 'textarea' | 'custom' | 'password';
+
+export interface SelectOption {
+ value: string;
+ label: string;
+ disabled?: boolean;
+}
+
+export interface FormFieldProps {
+ label: string;
+ required?: boolean;
+ type?: FormFieldType;
+ value?: string | number;
+ onChange?: (value: string) => void;
+ placeholder?: string;
+ disabled?: boolean;
+ error?: string;
+ helpText?: string;
+ options?: SelectOption[];
+ selectPlaceholder?: string;
+ children?: ReactNode;
+ className?: string;
+ inputClassName?: string;
+ rows?: number;
+ min?: number;
+ max?: number;
+ step?: number;
+ htmlFor?: string;
+}
+
+export function FormField({
+ label,
+ required = false,
+ type = 'text',
+ value,
+ onChange,
+ placeholder,
+ disabled = false,
+ error,
+ helpText,
+ options = [],
+ selectPlaceholder = "선택하세요",
+ children,
+ className = "",
+ inputClassName = "",
+ rows = 3,
+ min,
+ max,
+ step,
+ htmlFor,
+}: FormFieldProps) {
+
+ const renderInput = () => {
+ switch (type) {
+ case 'select':
+ return (
+
+ );
+
+ case 'textarea':
+ return (
+