fix: 품목관리 수정 기능 버그 수정 및 Sales 페이지 추가
## 품목관리 수정 버그 수정 - 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 <noreply@anthropic.com>
This commit is contained in:
BIN
claudedocs/.DS_Store
vendored
Normal file
BIN
claudedocs/.DS_Store
vendored
Normal file
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
# claudedocs 문서 맵
|
||||
|
||||
> 프로젝트 기술 문서 인덱스 (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` | 📋 견적관리/거래처관리 마이그레이션 계획 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
74
claudedocs/auth/[IMPL-2025-12-04] signup-page-blocking.md
Normal file
74
claudedocs/auth/[IMPL-2025-12-04] signup-page-blocking.md
Normal file
@@ -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`으로 리다이렉트
|
||||
- [ ] 기존 로그인 기능 정상 동작
|
||||
|
||||
---
|
||||
|
||||
## 향후 작업
|
||||
|
||||
- 회원가입 기능을 운영 페이지(관리자용)로 이동
|
||||
- 운영 페이지에서 사용자 등록 기능 구현
|
||||
@@ -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<ItemType, string> = {
|
||||
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' && (
|
||||
<div className="mt-4">
|
||||
<Label htmlFor="fg_item_code_auto">품목코드 (자동생성)</Label>
|
||||
<Input
|
||||
id="fg_item_code_auto"
|
||||
value={(formData[itemNameKey] as string) || ''}
|
||||
placeholder="품목명이 입력되면 자동으로 동일하게 생성됩니다"
|
||||
disabled
|
||||
className="bg-muted text-muted-foreground"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
* 제품(FG)의 품목코드는 품목명과 동일하게 설정됩니다
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
**제품(FG) 특징**:
|
||||
- 품목코드 = 품목명 (단순 복사)
|
||||
- 조합식이나 영문약어 매핑 없음
|
||||
- `isItemNameField` 플래그로 품목명 필드 다음에 자동으로 표시
|
||||
|
||||
### 2-1. 영문약어 매핑 테이블
|
||||
|
||||
### 내용
|
||||
```typescript
|
||||
export const ITEM_CODE_PREFIX_MAP: Record<string, string> = {
|
||||
// 부품 - 조립품
|
||||
@@ -69,21 +132,8 @@ export const ITEM_CODE_PREFIX_MAP: Record<string, string> = {
|
||||
};
|
||||
```
|
||||
|
||||
### 마이그레이션 방안
|
||||
- 품목기준관리 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<string, string> = {
|
||||
'standard': '표준형',
|
||||
@@ -157,79 +190,359 @@ export const INSTALLATION_TYPE_MAP: Record<string, string> = {
|
||||
};
|
||||
```
|
||||
|
||||
### 사용 예시
|
||||
- 품목명 `가이드레일` + 설치유형 `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 && (
|
||||
<BendingDiagramSection
|
||||
title="조립품 전개도"
|
||||
description="조립품 전개도(바라시)를 그리거나 편집합니다."
|
||||
...
|
||||
/>
|
||||
)}
|
||||
```
|
||||
|
||||
#### 3-2. 절곡품 전개도 섹션
|
||||
```typescript
|
||||
{/* 절곡품 전개도 섹션 (PT - 절곡 부품 전용) */}
|
||||
{selectedItemType === 'PT' && isBendingPart && bendingFields.material && (
|
||||
<BendingDiagramSection
|
||||
title="절곡품 전개도"
|
||||
description="절곡품 전개도를 그리거나 편집합니다."
|
||||
...
|
||||
/>
|
||||
)}
|
||||
```
|
||||
|
||||
### 문제점
|
||||
- 전개도 섹션 표시 조건이 코드에 고정
|
||||
- 조립/절곡 외 다른 부품유형에 전개도 추가 불가
|
||||
- 전개도 섹션 필드 구성 변경 불가
|
||||
|
||||
### 데이터 구조 (저장 시)
|
||||
```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
|
||||
// 고정된 컬럼들
|
||||
<TableHead>품목코드</TableHead>
|
||||
<TableHead>품목명</TableHead>
|
||||
<TableHead>규격</TableHead>
|
||||
<TableHead>재질</TableHead>
|
||||
<TableHead className="w-24">수량</TableHead>
|
||||
<TableHead className="w-20">단위</TableHead>
|
||||
<TableHead>비고</TableHead>
|
||||
```
|
||||
|
||||
#### 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<File | null>(null);
|
||||
const [certificationFile, setCertificationFile] = useState<File | null>(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' && (
|
||||
<div className="mt-4 space-y-4">
|
||||
{/* 시방서 파일 업로드 */}
|
||||
<div>
|
||||
<Label htmlFor="specification_file">시방서 (PDF)</Label>
|
||||
<div className="mt-1.5">
|
||||
<Input
|
||||
id="specification_file"
|
||||
type="file"
|
||||
accept=".pdf"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0] || null;
|
||||
setSpecificationFile(file);
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
{specificationFile && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
선택된 파일: {specificationFile.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* 인정서 파일 업로드 */}
|
||||
<div>
|
||||
<Label htmlFor="certification_file">인정서 (PDF)</Label>
|
||||
<div className="mt-1.5">
|
||||
<Input
|
||||
id="certification_file"
|
||||
type="file"
|
||||
accept=".pdf"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0] || null;
|
||||
setCertificationFile(file);
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
{certificationFile && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
선택된 파일: {certificationFile.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
### 표시 위치
|
||||
- **조건**: `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 | 하드코딩 내역 문서화 |
|
||||
| 2025-12-03 | 품목코드/품목명 자동생성 문서화 |
|
||||
| 2025-12-04 | 전체 하드코딩 항목 종합 문서로 확장 |
|
||||
| 2025-12-04 | 품목유형, 전개도/바라시, BOM 섹션 추가 |
|
||||
| 2025-12-04 | 제품(FG) 품목코드 규칙 추가 (품목명=품목코드) - DynamicItemForm으로 이동 |
|
||||
| 2025-12-04 | FG 전용 시방서/인정서 파일업로드 추가 |
|
||||
382
claudedocs/sales/[API-2025-12-04] client-api-analysis.md
Normal file
382
claudedocs/sales/[API-2025-12-04] client-api-analysis.md
Normal file
@@ -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<Client[]>([]);
|
||||
const [pagination, setPagination] = useState<PaginationInfo | null>(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`
|
||||
675
claudedocs/sales/[API-2025-12-04] quote-api-request.md
Normal file
675
claudedocs/sales/[API-2025-12-04] quote-api-request.md
Normal file
@@ -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. **알림**: 견적 발송 시 이메일/카카오톡 연동 계획?
|
||||
@@ -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 테스트 및 검증
|
||||
@@ -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 - 상세 화면 소스
|
||||
74
package-lock.json
generated
74
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<ItemMaster | null>(null);
|
||||
const searchParams = useSearchParams();
|
||||
const [itemId, setItemId] = useState<number | null>(null);
|
||||
const [itemType, setItemType] = useState<ItemType | null>(null);
|
||||
const [initialData, setInitialData] = useState<DynamicFormData | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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<string, unknown>).material_type || (apiData as Record<string, unknown>).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 (
|
||||
<div className="py-6">
|
||||
<div className="text-center py-8">로딩 중...</div>
|
||||
<div className="flex flex-col items-center justify-center py-12 gap-4">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-muted-foreground">품목 정보 로딩 중...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!item) {
|
||||
return null;
|
||||
// 에러 상태
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 gap-4">
|
||||
<p className="text-destructive">{error}</p>
|
||||
<button
|
||||
onClick={() => router.push('/items')}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
품목 목록으로 돌아가기
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 데이터 없음
|
||||
if (!itemType || !initialData) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 gap-4">
|
||||
<p className="text-muted-foreground">품목 정보를 불러올 수 없습니다.</p>
|
||||
<button
|
||||
onClick={() => router.push('/items')}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
품목 목록으로 돌아가기
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-6">
|
||||
<ItemForm mode="edit" initialData={item} onSubmit={handleSubmit} />
|
||||
<DynamicItemForm
|
||||
mode="edit"
|
||||
itemType={itemType}
|
||||
initialData={initialData}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<ItemMaster | null> {
|
||||
// API 연동 전 mock 데이터 반환
|
||||
// const item = await fetchItemByCode(itemCode);
|
||||
const item = mockItems.find(
|
||||
(item) => item.itemCode === decodeURIComponent(itemCode)
|
||||
);
|
||||
return item || null;
|
||||
function mapApiResponseToItemMaster(data: Record<string, unknown>): 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<string, unknown>) => ({
|
||||
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<ItemMaster | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="flex flex-col items-center justify-center py-12 gap-4">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-muted-foreground">품목 정보 로딩 중...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 에러 상태
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 gap-4">
|
||||
<p className="text-destructive">{error}</p>
|
||||
<button
|
||||
onClick={() => router.push('/items')}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
품목 목록으로 돌아가기
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 품목 없음
|
||||
if (!item) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-6">
|
||||
<Suspense fallback={<div className="text-center py-8">로딩 중...</div>}>
|
||||
<ItemDetailClient item={item} />
|
||||
</Suspense>
|
||||
<ItemDetailClient item={item} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 메타데이터 설정
|
||||
*/
|
||||
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} 품목 정보`,
|
||||
};
|
||||
}
|
||||
@@ -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<ClientFormData | null>(
|
||||
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 (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!editingClient) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ClientRegistration
|
||||
onBack={handleBack}
|
||||
onSave={handleSave}
|
||||
editingClient={editingClient}
|
||||
isLoading={hookLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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<Client | null>(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 (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!client) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ClientDetail
|
||||
client={client}
|
||||
onBack={handleBack}
|
||||
onEdit={handleEdit}
|
||||
onDelete={() => setShowDeleteDialog(true)}
|
||||
/>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>거래처 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
'{client.name}' 거래처를 삭제하시겠습니까?
|
||||
<br />
|
||||
이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{isDeleting ? "삭제 중..." : "삭제"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<ClientRegistration
|
||||
onBack={handleBack}
|
||||
onSave={handleSave}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 20;
|
||||
|
||||
// 모달 상태
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingCustomer, setEditingCustomer] = useState<CustomerAccount | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
businessNo: "",
|
||||
representative: "",
|
||||
phone: "",
|
||||
address: "",
|
||||
email: "",
|
||||
businessType: "",
|
||||
businessItem: "",
|
||||
});
|
||||
|
||||
// 삭제 확인 다이얼로그 state
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||||
@@ -178,46 +82,52 @@ export default function CustomerAccountManagementPage() {
|
||||
const [mobileDisplayCount, setMobileDisplayCount] = useState(20);
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 로컬 데이터 state
|
||||
const [customers, setCustomers] = useState<CustomerAccount[]>(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<NodeJS.Timeout | null>(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() {
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button
|
||||
variant="default"
|
||||
size="default"
|
||||
className="flex-1 min-w-[100px] h-11"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEdit(customer);
|
||||
}}
|
||||
>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="flex-1 min-w-[100px] h-11 border-red-200 text-red-600 hover:border-red-300 bg-[rgba(255,255,255,0)]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(customer.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
isSelected ? (
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button
|
||||
variant="default"
|
||||
size="default"
|
||||
className="flex-1 min-w-[100px] h-11"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEdit(customer);
|
||||
}}
|
||||
>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="flex-1 min-w-[100px] h-11 border-red-200 text-red-600 hover:border-red-300 bg-[rgba(255,255,255,0)]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(customer.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
) : 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,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 등록/수정 모달 */}
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingCustomer ? "거래처 수정" : "거래처 등록"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>거래처 정보를 입력하세요</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid grid-cols-2 gap-4 py-4 px-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">거래처명 *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, name: e.target.value })
|
||||
}
|
||||
placeholder="ABC건설"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="businessNo">사업자번호 *</Label>
|
||||
<Input
|
||||
id="businessNo"
|
||||
value={formData.businessNo}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, businessNo: e.target.value })
|
||||
}
|
||||
placeholder="123-45-67890"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="representative">대표자 *</Label>
|
||||
<Input
|
||||
id="representative"
|
||||
value={formData.representative}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, representative: e.target.value })
|
||||
}
|
||||
placeholder="홍길동"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">전화번호 *</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
value={formData.phone}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, phone: e.target.value })
|
||||
}
|
||||
placeholder="02-1234-5678"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 col-span-2">
|
||||
<Label htmlFor="address">주소 *</Label>
|
||||
<Input
|
||||
id="address"
|
||||
value={formData.address}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, address: e.target.value })
|
||||
}
|
||||
placeholder="서울시 강남구 테헤란로 123"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">이메일</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, email: e.target.value })
|
||||
}
|
||||
placeholder="abc@company.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="businessType">업태</Label>
|
||||
<Input
|
||||
id="businessType"
|
||||
value={formData.businessType}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, businessType: e.target.value })
|
||||
}
|
||||
placeholder="건설업"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 col-span-2">
|
||||
<Label htmlFor="businessItem">업종</Label>
|
||||
<Input
|
||||
id="businessItem"
|
||||
value={formData.businessItem}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, businessItem: e.target.value })
|
||||
}
|
||||
placeholder="건축공사"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsModalOpen(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave}>저장</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
@@ -736,7 +519,7 @@ export default function CustomerAccountManagementPage() {
|
||||
<AlertDialogTitle>거래처 삭제 확인</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{deleteTargetId
|
||||
? `거래처: ${customers.find((c) => c.id === deleteTargetId)?.name || deleteTargetId}`
|
||||
? `거래처: ${clients.find((c) => c.id === deleteTargetId)?.name || deleteTargetId}`
|
||||
: ""}
|
||||
<br />
|
||||
이 거래처를 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다.
|
||||
|
||||
@@ -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<QuoteFormData | null>(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 (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto" />
|
||||
<p className="mt-2 text-sm text-gray-500">견적 정보를 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<QuoteRegistration
|
||||
onBack={handleBack}
|
||||
onSave={handleSave}
|
||||
editingQuote={quote}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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<QuoteFormData | null>(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 (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto" />
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
견적 정보를 불러오는 중...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!quote) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<p className="text-gray-500">견적 정보를 찾을 수 없습니다.</p>
|
||||
<Button onClick={handleBack} className="mt-4">
|
||||
목록으로 돌아가기
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<FileText className="w-6 h-6" />
|
||||
견적 상세
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-1">견적번호: {quote.id}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{/* 문서 버튼들 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsQuoteDocumentOpen(true)}
|
||||
>
|
||||
<Printer className="w-4 h-4 mr-2" />
|
||||
견적서
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsCalculationReportOpen(true)}
|
||||
>
|
||||
<FileText className="w-4 h-4 mr-2" />
|
||||
산출내역서
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsPurchaseOrderOpen(true)}
|
||||
>
|
||||
<FileOutput className="w-4 h-4 mr-2" />
|
||||
발주서
|
||||
</Button>
|
||||
|
||||
{/* 액션 버튼들 */}
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
<List className="w-4 h-4 mr-2" />
|
||||
목록
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleEdit}>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleFinalize}
|
||||
className="bg-green-600 hover:bg-green-700 text-white"
|
||||
>
|
||||
<FileCheck className="w-4 h-4 mr-2" />
|
||||
최종확정
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 기본 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<Label>견적번호</Label>
|
||||
<Input
|
||||
value={quote.id || "-"}
|
||||
disabled
|
||||
className="bg-gray-50 text-black font-medium"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>작성자</Label>
|
||||
<Input
|
||||
value={quote.writer || "-"}
|
||||
disabled
|
||||
className="bg-gray-50 text-black font-medium"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>발주처</Label>
|
||||
<Input
|
||||
value={quote.clientName || "-"}
|
||||
disabled
|
||||
className="bg-gray-50 text-black font-medium"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<Label>담당자</Label>
|
||||
<Input
|
||||
value={quote.manager || "-"}
|
||||
disabled
|
||||
className="bg-gray-50 text-black font-medium"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>연락처</Label>
|
||||
<Input
|
||||
value={quote.contact || "-"}
|
||||
disabled
|
||||
className="bg-gray-50 text-black font-medium"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>현장명</Label>
|
||||
<Input
|
||||
value={quote.siteName || "-"}
|
||||
disabled
|
||||
className="bg-gray-50 text-black font-medium"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<Label>등록일</Label>
|
||||
<Input
|
||||
value={formatDate(quote.registrationDate || "")}
|
||||
disabled
|
||||
className="bg-gray-50 text-black font-medium"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>납기일</Label>
|
||||
<Input
|
||||
value={formatDate(quote.dueDate || "")}
|
||||
disabled
|
||||
className="bg-gray-50 text-black font-medium"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{quote.remarks && (
|
||||
<div>
|
||||
<Label>비고</Label>
|
||||
<Textarea
|
||||
value={quote.remarks}
|
||||
disabled
|
||||
className="bg-gray-50 text-black font-medium min-h-[80px]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 자동 견적 산출 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>자동 견적 산출 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{quote.items && quote.items.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{quote.items.map((item, index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="border rounded-lg p-4 bg-gray-50"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<Badge variant="outline">항목 {index + 1}</Badge>
|
||||
<Badge variant="secondary">{item.floor}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">제품명</span>
|
||||
<p className="font-medium">{item.productName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">오픈사이즈</span>
|
||||
<p className="font-medium">
|
||||
{item.openWidth} × {item.openHeight} mm
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">수량</span>
|
||||
<p className="font-medium">{item.quantity} SET</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">금액</span>
|
||||
<p className="font-medium text-blue-600">
|
||||
₩{formatAmount((item.inspectionFee || 0) * (item.quantity || 1))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 합계 */}
|
||||
<div className="border-t pt-4 mt-4">
|
||||
<div className="flex justify-between items-center text-lg font-bold">
|
||||
<span>총 견적금액</span>
|
||||
<span className="text-blue-600">
|
||||
₩{formatAmount(totalAmount)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-center py-8">
|
||||
산출 항목이 없습니다.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 견적서 다이얼로그 */}
|
||||
<Dialog open={isQuoteDocumentOpen} onOpenChange={setIsQuoteDocumentOpen}>
|
||||
<DialogContent className="max-w-[95vw] md:max-w-[800px] lg:max-w-[900px] h-[95vh] flex flex-col p-0">
|
||||
<DialogHeader className="flex-shrink-0 px-6 py-4 border-b">
|
||||
<DialogTitle>견적서</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 버튼 영역 */}
|
||||
<div className="flex-shrink-0 px-6 py-3 border-b bg-muted/30 flex flex-wrap gap-2 justify-between items-center">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
onClick={() => {
|
||||
toast.info('인쇄 대화상자에서 "PDF로 저장" 옵션을 선택하세요.');
|
||||
window.print();
|
||||
}}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
PDF
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
onClick={() => toast.info("이메일 전송 기능은 API 연동이 필요합니다.")}
|
||||
>
|
||||
<Mail className="w-4 h-4 mr-2" />
|
||||
이메일
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="bg-purple-600 hover:bg-purple-700"
|
||||
onClick={() => toast.info("팩스 전송 기능은 API 연동이 필요합니다.")}
|
||||
>
|
||||
<FileOutput className="w-4 h-4 mr-2" />
|
||||
팩스
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="bg-yellow-500 hover:bg-yellow-600"
|
||||
onClick={() => toast.info("카카오톡 전송 기능은 API 연동이 필요합니다.")}
|
||||
>
|
||||
<MessageCircle className="w-4 h-4 mr-2" />
|
||||
카카오톡
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => window.print()}>
|
||||
<Printer className="w-4 h-4 mr-2" />
|
||||
인쇄
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setIsQuoteDocumentOpen(false)}
|
||||
>
|
||||
<X className="w-4 h-4 mr-2" />
|
||||
닫기
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 문서 영역 */}
|
||||
<div className="flex-1 overflow-y-auto bg-gray-100 p-4">
|
||||
<div className="max-w-[210mm] mx-auto bg-white shadow-lg rounded-lg p-8">
|
||||
<QuoteDocument quote={quote} />
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 산출내역서 다이얼로그 */}
|
||||
<Dialog
|
||||
open={isCalculationReportOpen}
|
||||
onOpenChange={setIsCalculationReportOpen}
|
||||
>
|
||||
<DialogContent className="max-w-[95vw] md:max-w-[800px] lg:max-w-[900px] h-[95vh] flex flex-col p-0 overflow-hidden">
|
||||
<DialogHeader className="flex-shrink-0 px-6 py-4 border-b">
|
||||
<DialogTitle>산출내역서</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 버튼 영역 */}
|
||||
<div className="flex-shrink-0 px-6 py-3 border-b bg-muted/30 flex flex-wrap gap-2 justify-between items-center">
|
||||
<div className="flex gap-4 flex-wrap items-center">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
onClick={() => {
|
||||
toast.info('인쇄 대화상자에서 "PDF로 저장" 옵션을 선택하세요.');
|
||||
window.print();
|
||||
}}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
PDF
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
onClick={() => toast.info("이메일 전송 기능은 API 연동이 필요합니다.")}
|
||||
>
|
||||
<Mail className="w-4 h-4 mr-2" />
|
||||
이메일
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="bg-purple-600 hover:bg-purple-700"
|
||||
onClick={() => toast.info("팩스 전송 기능은 API 연동이 필요합니다.")}
|
||||
>
|
||||
<FileOutput className="w-4 h-4 mr-2" />
|
||||
팩스
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="bg-yellow-500 hover:bg-yellow-600"
|
||||
onClick={() => toast.info("카카오톡 전송 기능은 API 연동이 필요합니다.")}
|
||||
>
|
||||
<MessageCircle className="w-4 h-4 mr-2" />
|
||||
카카오톡
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => window.print()}
|
||||
>
|
||||
<Printer className="w-4 h-4 mr-2" />
|
||||
인쇄
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 표시 옵션 체크박스 */}
|
||||
<div className="flex gap-4 pl-4 border-l">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showDetailedBreakdown}
|
||||
onChange={(e) => setShowDetailedBreakdown(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-sm">산출내역서</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showMaterialList}
|
||||
onChange={(e) => setShowMaterialList(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-sm">소요자재 내역</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setIsCalculationReportOpen(false)}
|
||||
>
|
||||
<X className="w-4 h-4 mr-2" />
|
||||
닫기
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 문서 영역 */}
|
||||
<div className="flex-1 overflow-y-auto bg-gray-100 p-4">
|
||||
<div className="max-w-[210mm] mx-auto bg-white shadow-lg rounded-lg p-8">
|
||||
<QuoteCalculationReport
|
||||
quote={quote}
|
||||
documentType="견적산출내역서"
|
||||
showDetailedBreakdown={showDetailedBreakdown}
|
||||
showMaterialList={showMaterialList}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 발주서 다이얼로그 */}
|
||||
<Dialog open={isPurchaseOrderOpen} onOpenChange={setIsPurchaseOrderOpen}>
|
||||
<DialogContent className="max-w-[95vw] md:max-w-[800px] lg:max-w-[900px] h-[95vh] flex flex-col p-0 overflow-hidden">
|
||||
<DialogHeader className="flex-shrink-0 px-6 py-4 border-b">
|
||||
<DialogTitle>발주서</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 버튼 영역 */}
|
||||
<div className="flex-shrink-0 px-6 py-3 border-b bg-muted/30 flex flex-wrap gap-2 justify-between items-center">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
onClick={() => {
|
||||
toast.info('인쇄 대화상자에서 "PDF로 저장" 옵션을 선택하세요.');
|
||||
window.print();
|
||||
}}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
PDF
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
onClick={() => toast.info("이메일 전송 기능은 API 연동이 필요합니다.")}
|
||||
>
|
||||
<Mail className="w-4 h-4 mr-2" />
|
||||
이메일
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="bg-purple-600 hover:bg-purple-700"
|
||||
onClick={() => toast.info("팩스 전송 기능은 API 연동이 필요합니다.")}
|
||||
>
|
||||
<FileOutput className="w-4 h-4 mr-2" />
|
||||
팩스
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="bg-yellow-500 hover:bg-yellow-600"
|
||||
onClick={() => toast.info("카카오톡 전송 기능은 API 연동이 필요합니다.")}
|
||||
>
|
||||
<MessageCircle className="w-4 h-4 mr-2" />
|
||||
카카오톡
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => window.print()}>
|
||||
<Printer className="w-4 h-4 mr-2" />
|
||||
인쇄
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setIsPurchaseOrderOpen(false)}
|
||||
>
|
||||
<X className="w-4 h-4 mr-2" />
|
||||
닫기
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 문서 영역 */}
|
||||
<div className="flex-1 overflow-y-auto bg-gray-100 p-4">
|
||||
<div className="max-w-[210mm] mx-auto bg-white shadow-lg rounded-lg p-8">
|
||||
<PurchaseOrderDocument quote={quote} />
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<QuoteRegistration
|
||||
onBack={handleBack}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{quote.currentRevision > 0 && (
|
||||
isSelected ? (
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{quote.currentRevision > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="flex-1 min-w-[100px] h-11"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleViewHistory(quote);
|
||||
}}
|
||||
>
|
||||
<History className="h-4 w-4 mr-2" />
|
||||
이력
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="default"
|
||||
size="default"
|
||||
className="flex-1 min-w-[100px] h-11"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleViewHistory(quote);
|
||||
handleEdit(quote);
|
||||
}}
|
||||
>
|
||||
<History className="h-4 w-4 mr-2" />
|
||||
이력
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="default"
|
||||
size="default"
|
||||
className="flex-1 min-w-[100px] h-11"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEdit(quote);
|
||||
}}
|
||||
>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
{!quote.isFinal && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="flex-1 min-w-[100px] h-11 border-red-200 text-red-600 hover:border-red-300 bg-[rgba(255,255,255,0)]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(quote.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
삭제
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{!quote.isFinal && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="flex-1 min-w-[100px] h-11 border-red-200 text-red-600 hover:border-red-300 bg-[rgba(255,255,255,0)]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(quote.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
삭제
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
@@ -655,7 +659,7 @@ export default function QuoteManagementPage() {
|
||||
description="견적서 작성 및 관리"
|
||||
icon={FileText}
|
||||
headerActions={
|
||||
<Button onClick={() => toast.info("견적 등록 기능 준비중")}>
|
||||
<Button onClick={() => router.push("/sales/quote-management/new")}>
|
||||
<FileText className="w-4 h-4 mr-2" />
|
||||
견적 등록
|
||||
</Button>
|
||||
|
||||
@@ -161,11 +161,11 @@ async function proxyRequest(
|
||||
url.searchParams.append(key, value);
|
||||
});
|
||||
|
||||
// 3. 요청 바디 읽기 (POST, PUT, DELETE)
|
||||
// 3. 요청 바디 읽기 (POST, PUT, DELETE, PATCH)
|
||||
let body: string | undefined;
|
||||
const contentType = request.headers.get('content-type') || 'application/json';
|
||||
|
||||
if (['POST', 'PUT', 'DELETE'].includes(method)) {
|
||||
if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(method)) {
|
||||
if (contentType.includes('application/json')) {
|
||||
body = await request.text();
|
||||
console.log('🔵 [PROXY] Request:', method, url.toString());
|
||||
@@ -293,3 +293,16 @@ export async function DELETE(
|
||||
const resolvedParams = await params;
|
||||
return proxyRequest(request, resolvedParams, 'DELETE');
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH 요청 프록시
|
||||
* Next.js 15: params는 Promise이므로 await 필요
|
||||
* 용도: toggle 엔드포인트 (/clients/{id}/toggle, /client-groups/{id}/toggle)
|
||||
*/
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path: string[] }> }
|
||||
) {
|
||||
const resolvedParams = await params;
|
||||
return proxyRequest(request, resolvedParams, 'PATCH');
|
||||
}
|
||||
|
||||
@@ -182,12 +182,10 @@ export function LoginPage() {
|
||||
<p className="text-xs text-muted-foreground">{t('login')}</p>
|
||||
</div>
|
||||
</button>
|
||||
{/* 2025-12-04: MVP에서 회원가입 버튼 제거 (운영 페이지로 이동 예정) */}
|
||||
<div className="flex items-center gap-3">
|
||||
<ThemeSelect native={false} />
|
||||
<LanguageSelect native={false} />
|
||||
<Button variant="ghost" onClick={() => router.push("/signup")} className="rounded-xl">
|
||||
{t('signUp')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -291,37 +289,7 @@ export function LoginPage() {
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-border"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-4 bg-card text-muted-foreground">{tCommon('or')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push("/signup")}
|
||||
className="w-full rounded-xl"
|
||||
>
|
||||
{t('createAccount')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Signup Link */}
|
||||
<div className="text-center mt-6">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('noAccount')}{" "}
|
||||
<button
|
||||
onClick={() => router.push("/signup")}
|
||||
className="text-primary font-medium hover:underline"
|
||||
>
|
||||
{t('signUp')}
|
||||
</button>
|
||||
</p>
|
||||
{/* 2025-12-04: MVP에서 회원가입 섹션 제거 (운영 페이지로 이동 예정) */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
247
src/components/clients/ClientDetail.tsx
Normal file
247
src/components/clients/ClientDetail.tsx
Normal file
@@ -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 (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-1">{label}</p>
|
||||
<div className={`flex items-center gap-2 ${valueClassName || ""}`}>
|
||||
{icon}
|
||||
<span className="font-medium">{value || "-"}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Building2 className="h-6 w-6 text-primary" />
|
||||
<h1 className="text-2xl font-bold">{client.name}</h1>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={onBack}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
목록
|
||||
</Button>
|
||||
<Button variant="outline" onClick={onEdit}>
|
||||
<Pencil className="h-4 w-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={onDelete}>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 1. 기본 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 className="h-5 w-5 text-primary" />
|
||||
<CardTitle>기본 정보</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<DetailItem label="CODE" value={client.code} />
|
||||
<DetailItem label="사업자번호" value={client.businessNo} />
|
||||
<DetailItem
|
||||
label="거래처 유형"
|
||||
value={
|
||||
<Badge
|
||||
variant={
|
||||
client.clientType === "매출"
|
||||
? "default"
|
||||
: client.clientType === "매입"
|
||||
? "secondary"
|
||||
: "outline"
|
||||
}
|
||||
>
|
||||
{client.clientType}
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<DetailItem label="거래처명" value={client.name} />
|
||||
<DetailItem label="대표자" value={client.representative} />
|
||||
<DetailItem
|
||||
label="상태"
|
||||
value={
|
||||
<Badge
|
||||
variant={client.status === "활성" ? "default" : "secondary"}
|
||||
className={
|
||||
client.status === "활성"
|
||||
? "bg-green-100 text-green-800"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{client.status}
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<DetailItem
|
||||
label="주소"
|
||||
value={client.address}
|
||||
icon={<MapPin className="h-4 w-4 text-muted-foreground" />}
|
||||
/>
|
||||
<DetailItem label="업태" value={client.businessType} />
|
||||
<DetailItem label="종목" value={client.businessItem} />
|
||||
</div>
|
||||
|
||||
{client.memo && (
|
||||
<DetailItem label="비고" value={client.memo} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 2. 연락처 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Phone className="h-5 w-5 text-primary" />
|
||||
<CardTitle>연락처 정보</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<DetailItem
|
||||
label="전화"
|
||||
value={client.phone}
|
||||
icon={<Phone className="h-4 w-4 text-muted-foreground" />}
|
||||
/>
|
||||
<DetailItem
|
||||
label="휴대전화"
|
||||
value={client.mobile}
|
||||
icon={<Phone className="h-4 w-4 text-muted-foreground" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
||||
<DetailItem label="팩스" value={client.fax} />
|
||||
<DetailItem
|
||||
label="이메일"
|
||||
value={client.email}
|
||||
icon={<Mail className="h-4 w-4 text-muted-foreground" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
||||
<DetailItem label="담당자명" value={client.managerName} />
|
||||
<DetailItem label="담당자 연락처" value={client.managerTel} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 3. 결제 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<CreditCard className="h-5 w-5 text-primary" />
|
||||
<CardTitle>결제 정보</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<DetailItem label="매입 결제일" value={client.purchasePaymentDay} />
|
||||
<DetailItem label="매출 결제일" value={client.salesPaymentDay} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 4. 악성채권 정보 (있는 경우에만 표시) */}
|
||||
{client.badDebt && (
|
||||
<Card className="border-red-300 bg-red-50/30">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-red-500" />
|
||||
<CardTitle className="text-red-700">악성채권 정보</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<DetailItem
|
||||
label="악성채권 금액"
|
||||
value={formatCurrency(client.badDebtAmount)}
|
||||
valueClassName="text-red-600 font-bold"
|
||||
/>
|
||||
<DetailItem label="수령일" value={client.badDebtReceiveDate} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
||||
<DetailItem label="종료일" value={client.badDebtEndDate} />
|
||||
<DetailItem label="진행 상태" value={client.badDebtProgress} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
607
src/components/clients/ClientRegistration.tsx
Normal file
607
src/components/clients/ClientRegistration.tsx
Normal file
@@ -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<void>;
|
||||
editingClient?: ClientFormData | null;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function ClientRegistration({
|
||||
onBack,
|
||||
onSave,
|
||||
editingClient,
|
||||
isLoading = false,
|
||||
}: ClientRegistrationProps) {
|
||||
const [formData, setFormData] = useState<ClientFormData>(
|
||||
editingClient || INITIAL_CLIENT_FORM
|
||||
);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// editingClient가 변경되면 formData 업데이트
|
||||
useEffect(() => {
|
||||
if (editingClient) {
|
||||
setFormData(editingClient);
|
||||
}
|
||||
}, [editingClient]);
|
||||
|
||||
// 유효성 검사
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
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 (
|
||||
<ResponsiveFormTemplate
|
||||
title={editingClient ? "거래처 수정" : "거래처 등록"}
|
||||
description="거래처 정보를 입력하세요"
|
||||
icon={Building2}
|
||||
onSave={handleSubmit}
|
||||
onCancel={onBack}
|
||||
saveLabel={editingClient ? "수정" : "등록"}
|
||||
isEditMode={!!editingClient}
|
||||
saveLoading={isSaving || isLoading}
|
||||
saveDisabled={isSaving || isLoading}
|
||||
maxWidth="2xl"
|
||||
>
|
||||
{/* 1. 기본 정보 */}
|
||||
<FormSection
|
||||
title="기본 정보"
|
||||
description="거래처의 기본 정보를 입력하세요"
|
||||
icon={Building2}
|
||||
>
|
||||
<FormFieldGrid columns={2}>
|
||||
<FormField
|
||||
label="사업자등록번호"
|
||||
required
|
||||
error={errors.businessNo}
|
||||
htmlFor="businessNo"
|
||||
>
|
||||
<Input
|
||||
id="businessNo"
|
||||
placeholder="10자리 숫자"
|
||||
value={formData.businessNo}
|
||||
onChange={(e) => handleFieldChange("businessNo", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="거래처 코드"
|
||||
htmlFor="clientCode"
|
||||
helpText="자동 생성됩니다"
|
||||
>
|
||||
<Input
|
||||
id="clientCode"
|
||||
placeholder="자동생성"
|
||||
value={formData.clientCode || ""}
|
||||
disabled
|
||||
/>
|
||||
</FormField>
|
||||
</FormFieldGrid>
|
||||
|
||||
<FormFieldGrid columns={2}>
|
||||
<FormField
|
||||
label="거래처명"
|
||||
required
|
||||
error={errors.name}
|
||||
htmlFor="name"
|
||||
>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="거래처명 입력"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleFieldChange("name", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="대표자명"
|
||||
required
|
||||
error={errors.representative}
|
||||
htmlFor="representative"
|
||||
>
|
||||
<Input
|
||||
id="representative"
|
||||
placeholder="대표자명 입력"
|
||||
value={formData.representative}
|
||||
onChange={(e) =>
|
||||
handleFieldChange("representative", e.target.value)
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
</FormFieldGrid>
|
||||
|
||||
<FormField label="거래처 유형" required type="custom">
|
||||
<RadioGroup
|
||||
value={formData.clientType}
|
||||
onValueChange={(value) =>
|
||||
handleFieldChange("clientType", value as ClientType)
|
||||
}
|
||||
className="flex gap-4"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="매입" id="type-purchase" />
|
||||
<Label htmlFor="type-purchase">매입</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="매출" id="type-sales" />
|
||||
<Label htmlFor="type-sales">매출</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="매입매출" id="type-both" />
|
||||
<Label htmlFor="type-both">매입매출</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</FormField>
|
||||
|
||||
<FormFieldGrid columns={2}>
|
||||
<FormField label="업태" htmlFor="businessType">
|
||||
<Input
|
||||
id="businessType"
|
||||
placeholder="제조업, 도소매업 등"
|
||||
value={formData.businessType}
|
||||
onChange={(e) =>
|
||||
handleFieldChange("businessType", e.target.value)
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="종목" htmlFor="businessItem">
|
||||
<Input
|
||||
id="businessItem"
|
||||
placeholder="철강, 건설 등"
|
||||
value={formData.businessItem}
|
||||
onChange={(e) =>
|
||||
handleFieldChange("businessItem", e.target.value)
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
</FormFieldGrid>
|
||||
</FormSection>
|
||||
|
||||
{/* 2. 연락처 정보 */}
|
||||
<FormSection
|
||||
title="연락처 정보"
|
||||
description="거래처의 연락처 정보를 입력하세요"
|
||||
icon={Phone}
|
||||
>
|
||||
<FormField label="주소" htmlFor="address">
|
||||
<Input
|
||||
id="address"
|
||||
placeholder="주소 입력"
|
||||
value={formData.address}
|
||||
onChange={(e) => handleFieldChange("address", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormFieldGrid columns={3}>
|
||||
<FormField label="전화번호" error={errors.phone} htmlFor="phone">
|
||||
<Input
|
||||
id="phone"
|
||||
placeholder="02-1234-5678"
|
||||
value={formData.phone}
|
||||
onChange={(e) => handleFieldChange("phone", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="모바일" htmlFor="mobile">
|
||||
<Input
|
||||
id="mobile"
|
||||
placeholder="010-1234-5678"
|
||||
value={formData.mobile}
|
||||
onChange={(e) => handleFieldChange("mobile", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="팩스" htmlFor="fax">
|
||||
<Input
|
||||
id="fax"
|
||||
placeholder="02-1234-5678"
|
||||
value={formData.fax}
|
||||
onChange={(e) => handleFieldChange("fax", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
</FormFieldGrid>
|
||||
|
||||
<FormField label="이메일" error={errors.email} htmlFor="email">
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="example@company.com"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleFieldChange("email", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
</FormSection>
|
||||
|
||||
{/* 3. 담당자 정보 */}
|
||||
<FormSection
|
||||
title="담당자 정보"
|
||||
description="거래처 담당자 정보를 입력하세요"
|
||||
icon={UserCircle}
|
||||
>
|
||||
<FormFieldGrid columns={2}>
|
||||
<FormField label="담당자명" htmlFor="managerName">
|
||||
<Input
|
||||
id="managerName"
|
||||
placeholder="담당자명 입력"
|
||||
value={formData.managerName}
|
||||
onChange={(e) => handleFieldChange("managerName", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="담당자 전화" htmlFor="managerTel">
|
||||
<Input
|
||||
id="managerTel"
|
||||
placeholder="010-1234-5678"
|
||||
value={formData.managerTel}
|
||||
onChange={(e) => handleFieldChange("managerTel", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
</FormFieldGrid>
|
||||
|
||||
<FormField label="시스템 관리자" htmlFor="systemManager">
|
||||
<Input
|
||||
id="systemManager"
|
||||
placeholder="시스템 관리자명"
|
||||
value={formData.systemManager}
|
||||
onChange={(e) =>
|
||||
handleFieldChange("systemManager", e.target.value)
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
</FormSection>
|
||||
|
||||
{/* 4. 발주처 설정 */}
|
||||
<FormSection
|
||||
title="발주처 설정"
|
||||
description="발주처로 사용할 경우 계정 정보를 입력하세요"
|
||||
icon={CreditCard}
|
||||
>
|
||||
<FormFieldGrid columns={2}>
|
||||
<FormField label="계정 ID" htmlFor="accountId">
|
||||
<Input
|
||||
id="accountId"
|
||||
placeholder="계정 ID"
|
||||
value={formData.accountId}
|
||||
onChange={(e) => handleFieldChange("accountId", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="비밀번호" htmlFor="accountPassword">
|
||||
<Input
|
||||
id="accountPassword"
|
||||
type="password"
|
||||
placeholder="비밀번호"
|
||||
value={formData.accountPassword}
|
||||
onChange={(e) =>
|
||||
handleFieldChange("accountPassword", e.target.value)
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
</FormFieldGrid>
|
||||
|
||||
<FormFieldGrid columns={2}>
|
||||
<FormField label="매입 결제일" htmlFor="purchasePaymentDay">
|
||||
<Select
|
||||
value={formData.purchasePaymentDay}
|
||||
onValueChange={(value) =>
|
||||
handleFieldChange("purchasePaymentDay", value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="purchasePaymentDay">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="말일">말일</SelectItem>
|
||||
<SelectItem value="익월 10일">익월 10일</SelectItem>
|
||||
<SelectItem value="익월 15일">익월 15일</SelectItem>
|
||||
<SelectItem value="익월 20일">익월 20일</SelectItem>
|
||||
<SelectItem value="익월 25일">익월 25일</SelectItem>
|
||||
<SelectItem value="익월 말일">익월 말일</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
|
||||
<FormField label="매출 결제일" htmlFor="salesPaymentDay">
|
||||
<Select
|
||||
value={formData.salesPaymentDay}
|
||||
onValueChange={(value) =>
|
||||
handleFieldChange("salesPaymentDay", value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="salesPaymentDay">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="말일">말일</SelectItem>
|
||||
<SelectItem value="익월 10일">익월 10일</SelectItem>
|
||||
<SelectItem value="익월 15일">익월 15일</SelectItem>
|
||||
<SelectItem value="익월 20일">익월 20일</SelectItem>
|
||||
<SelectItem value="익월 25일">익월 25일</SelectItem>
|
||||
<SelectItem value="익월 말일">익월 말일</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
</FormFieldGrid>
|
||||
</FormSection>
|
||||
|
||||
{/* 5. 약정 세금 */}
|
||||
<FormSection
|
||||
title="약정 세금"
|
||||
description="세금 약정이 있는 경우 입력하세요"
|
||||
icon={Calculator}
|
||||
>
|
||||
<FormField label="약정 여부" type="custom">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="taxAgreement"
|
||||
checked={formData.taxAgreement}
|
||||
onCheckedChange={(checked) =>
|
||||
handleFieldChange("taxAgreement", checked as boolean)
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="taxAgreement">세금 약정 있음</Label>
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
{formData.taxAgreement && (
|
||||
<>
|
||||
<FormField label="약정 금액" htmlFor="taxAmount">
|
||||
<Input
|
||||
id="taxAmount"
|
||||
type="number"
|
||||
placeholder="약정 금액"
|
||||
value={formData.taxAmount}
|
||||
onChange={(e) => handleFieldChange("taxAmount", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormFieldGrid columns={2}>
|
||||
<FormField label="약정 시작일" htmlFor="taxStartDate">
|
||||
<Input
|
||||
id="taxStartDate"
|
||||
type="date"
|
||||
value={formData.taxStartDate}
|
||||
onChange={(e) =>
|
||||
handleFieldChange("taxStartDate", e.target.value)
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="약정 종료일" htmlFor="taxEndDate">
|
||||
<Input
|
||||
id="taxEndDate"
|
||||
type="date"
|
||||
value={formData.taxEndDate}
|
||||
onChange={(e) =>
|
||||
handleFieldChange("taxEndDate", e.target.value)
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
</FormFieldGrid>
|
||||
</>
|
||||
)}
|
||||
</FormSection>
|
||||
|
||||
{/* 6. 악성채권 */}
|
||||
<FormSection
|
||||
title="악성채권 정보"
|
||||
description="악성채권이 있는 경우 입력하세요"
|
||||
icon={AlertTriangle}
|
||||
>
|
||||
<FormField label="악성채권 여부" type="custom">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="badDebt"
|
||||
checked={formData.badDebt}
|
||||
onCheckedChange={(checked) =>
|
||||
handleFieldChange("badDebt", checked as boolean)
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="badDebt">악성채권 있음</Label>
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
{formData.badDebt && (
|
||||
<>
|
||||
<FormField label="악성채권 금액" htmlFor="badDebtAmount">
|
||||
<Input
|
||||
id="badDebtAmount"
|
||||
type="number"
|
||||
placeholder="채권 금액"
|
||||
value={formData.badDebtAmount}
|
||||
onChange={(e) =>
|
||||
handleFieldChange("badDebtAmount", e.target.value)
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormFieldGrid columns={2}>
|
||||
<FormField label="채권 발생일" htmlFor="badDebtReceiveDate">
|
||||
<Input
|
||||
id="badDebtReceiveDate"
|
||||
type="date"
|
||||
value={formData.badDebtReceiveDate}
|
||||
onChange={(e) =>
|
||||
handleFieldChange("badDebtReceiveDate", e.target.value)
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="채권 만료일" htmlFor="badDebtEndDate">
|
||||
<Input
|
||||
id="badDebtEndDate"
|
||||
type="date"
|
||||
value={formData.badDebtEndDate}
|
||||
onChange={(e) =>
|
||||
handleFieldChange("badDebtEndDate", e.target.value)
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
</FormFieldGrid>
|
||||
|
||||
<FormField label="진행 상태" htmlFor="badDebtProgress">
|
||||
<Select
|
||||
value={formData.badDebtProgress}
|
||||
onValueChange={(value) =>
|
||||
handleFieldChange("badDebtProgress", value as BadDebtProgress)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="badDebtProgress">
|
||||
<SelectValue placeholder="진행 상태 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="협의중">협의중</SelectItem>
|
||||
<SelectItem value="소송중">소송중</SelectItem>
|
||||
<SelectItem value="회수완료">회수완료</SelectItem>
|
||||
<SelectItem value="대손처리">대손처리</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
</>
|
||||
)}
|
||||
</FormSection>
|
||||
|
||||
{/* 7. 기타 */}
|
||||
<FormSection
|
||||
title="기타 정보"
|
||||
description="추가 정보를 입력하세요"
|
||||
icon={FileText}
|
||||
>
|
||||
<FormField label="메모" htmlFor="memo">
|
||||
<Textarea
|
||||
id="memo"
|
||||
placeholder="메모 입력"
|
||||
value={formData.memo}
|
||||
onChange={(e) => handleFieldChange("memo", e.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="상태" type="custom">
|
||||
<RadioGroup
|
||||
value={formData.isActive ? "활성" : "비활성"}
|
||||
onValueChange={(value) =>
|
||||
handleFieldChange("isActive", value === "활성")
|
||||
}
|
||||
className="flex gap-4"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="활성" id="status-active" />
|
||||
<Label htmlFor="status-active">활성</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="비활성" id="status-inactive" />
|
||||
<Label htmlFor="status-inactive">비활성</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</FormField>
|
||||
</FormSection>
|
||||
</ResponsiveFormTemplate>
|
||||
);
|
||||
}
|
||||
@@ -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<DynamicFormErrors>({});
|
||||
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<void>) => {
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
await onSubmit(formData);
|
||||
// 성공 시: setIsSubmitting(false)를 호출하지 않음
|
||||
// 페이지 이동하므로 버튼 비활성화 상태 유지 → 중복 클릭 방지
|
||||
} catch (err) {
|
||||
console.error('폼 제출 실패:', err);
|
||||
throw err;
|
||||
} finally {
|
||||
// 실패 시에만 버튼 다시 활성화 → 재시도 가능
|
||||
setIsSubmitting(false);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[formData]
|
||||
|
||||
@@ -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<BendingDetail[]>([]);
|
||||
const [widthSum, setWidthSum] = useState<string>('');
|
||||
|
||||
// FG(제품) 전용 파일 업로드 상태 관리
|
||||
const [specificationFile, setSpecificationFile] = useState<File | null>(null);
|
||||
const [certificationFile, setCertificationFile] = useState<File | null>(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<string, string> = {}; // 간단한 키 → 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<ItemFieldResponse[]>(() => {
|
||||
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<string, string> = {
|
||||
'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<string, any> = {};
|
||||
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({
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{/* 비고 필드 다음에 구매부품(전동개폐기) 품목코드 자동생성 */}
|
||||
{isNoteField && isPurchasedPart && (
|
||||
<div className="mt-4">
|
||||
<Label htmlFor="purchased_item_code_auto">품목코드 (자동생성)</Label>
|
||||
<Input
|
||||
id="purchased_item_code_auto"
|
||||
value={autoPurchasedItemCode || ''}
|
||||
placeholder="품목명, 용량, 전원을 선택하면 자동으로 생성됩니다"
|
||||
disabled
|
||||
className="bg-muted text-muted-foreground"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
* 품목코드는 '품목명+용량+전원' 형식으로 자동 생성됩니다 (예: 전동개폐기150KG380V)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{/* FG(제품) 전용: 품목명 필드 다음에 품목코드 자동생성 */}
|
||||
{isItemNameField && selectedItemType === 'FG' && (
|
||||
<div className="mt-4">
|
||||
<Label htmlFor="fg_item_code_auto">품목코드 (자동생성)</Label>
|
||||
<Input
|
||||
id="fg_item_code_auto"
|
||||
value={(formData[itemNameKey] as string) || ''}
|
||||
placeholder="품목명이 입력되면 자동으로 동일하게 생성됩니다"
|
||||
disabled
|
||||
className="bg-muted text-muted-foreground"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
* 제품(FG)의 품목코드는 품목명과 동일하게 설정됩니다
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{/* FG(제품) 전용: 인정 유효기간 종료일 다음에 시방서/인정서 파일 업로드 */}
|
||||
{isCertEndDateField && selectedItemType === 'FG' && (
|
||||
<div className="mt-4 space-y-4">
|
||||
{/* 시방서 파일 업로드 */}
|
||||
<div>
|
||||
<Label htmlFor="specification_file">시방서 (PDF)</Label>
|
||||
<div className="mt-1.5">
|
||||
<Input
|
||||
id="specification_file"
|
||||
type="file"
|
||||
accept=".pdf"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0] || null;
|
||||
setSpecificationFile(file);
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
{specificationFile && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
선택된 파일: {specificationFile.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* 인정서 파일 업로드 */}
|
||||
<div>
|
||||
<Label htmlFor="certification_file">인정서 (PDF)</Label>
|
||||
<div className="mt-1.5">
|
||||
<Input
|
||||
id="certification_file"
|
||||
type="file"
|
||||
accept=".pdf"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0] || null;
|
||||
setCertificationFile(file);
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
{certificationFile && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
선택된 파일: {certificationFile.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 하드코딩 내역 목록 (문서화용)
|
||||
// ============================================
|
||||
|
||||
@@ -90,7 +90,7 @@ export default function ItemDetailClient({ item }: ItemDetailClientProps) {
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => router.push(`/items/${encodeURIComponent(item.itemCode)}/edit`)}
|
||||
onClick={() => router.push(`/items/${encodeURIComponent(item.itemCode)}/edit?type=${item.itemType}&id=${item.id}`)}
|
||||
>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
수정
|
||||
|
||||
@@ -109,20 +109,16 @@ export default function ProductForm({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<div>
|
||||
<Label>품목코드 (자동생성)</Label>
|
||||
<Input
|
||||
value={(() => {
|
||||
const pName = productName || '';
|
||||
const iName = getValues('itemName') || '';
|
||||
return pName && iName ? `${pName}-${iName}` : '';
|
||||
})()}
|
||||
value={getValues('itemName') || ''}
|
||||
disabled
|
||||
className="bg-muted text-muted-foreground"
|
||||
placeholder="상품명과 품목명을 입력하면 자동으로 생성됩니다"
|
||||
placeholder="품목명이 입력되면 자동으로 동일하게 생성됩니다"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
* 품목코드는 '상품명-품목명' 형식으로 자동 생성됩니다
|
||||
* 품목명과 품목코드가 동일하게 설정됩니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -159,6 +155,15 @@ export default function ProductForm({
|
||||
* 비활성 시 품목 사용이 제한됩니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>비고</Label>
|
||||
<Input
|
||||
placeholder="비고 사항을 입력하세요"
|
||||
value={remarks}
|
||||
onChange={(e) => setRemarks(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -199,7 +204,7 @@ export function ProductCertificationSection({
|
||||
</div>
|
||||
|
||||
{/* 인정번호, 유효기간, 파일 업로드, 비고 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="certificationNumber">인정번호</Label>
|
||||
<Input
|
||||
@@ -232,20 +237,28 @@ export function ProductCertificationSection({
|
||||
|
||||
{/* 시방서 파일 */}
|
||||
<div className="space-y-2">
|
||||
<Label>시방서 (PDF, DOCX, HWP, JPG, PNG / 최대 20MB)</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="file"
|
||||
accept=".pdf,.docx,.hwp,.jpg,.jpeg,.png"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setSpecificationFile(file);
|
||||
}
|
||||
}}
|
||||
className="flex-1"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<Label>시방서 (PDF)</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="cursor-pointer">
|
||||
<span className="inline-flex items-center justify-center px-4 py-2 text-sm font-medium border rounded-md bg-background hover:bg-accent hover:text-accent-foreground">
|
||||
파일 선택
|
||||
</span>
|
||||
<input
|
||||
type="file"
|
||||
accept=".pdf"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setSpecificationFile(file);
|
||||
}
|
||||
}}
|
||||
className="hidden"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</label>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{specificationFile ? specificationFile.name : '선택된 파일 없음'}
|
||||
</span>
|
||||
{specificationFile && (
|
||||
<Button
|
||||
type="button"
|
||||
@@ -253,34 +266,38 @@ export function ProductCertificationSection({
|
||||
size="sm"
|
||||
onClick={() => setSpecificationFile(null)}
|
||||
disabled={isSubmitting}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{specificationFile && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
첨부됨: {specificationFile.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 인정서 파일 */}
|
||||
<div className="space-y-2">
|
||||
<Label>인정서 (PDF, DOCX, HWP, JPG, PNG / 최대 20MB)</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="file"
|
||||
accept=".pdf,.docx,.hwp,.jpg,.jpeg,.png"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setCertificationFile(file);
|
||||
}
|
||||
}}
|
||||
className="flex-1"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<Label>인정서 (PDF)</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="cursor-pointer">
|
||||
<span className="inline-flex items-center justify-center px-4 py-2 text-sm font-medium border rounded-md bg-background hover:bg-accent hover:text-accent-foreground">
|
||||
파일 선택
|
||||
</span>
|
||||
<input
|
||||
type="file"
|
||||
accept=".pdf"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setCertificationFile(file);
|
||||
}
|
||||
}}
|
||||
className="hidden"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</label>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{certificationFile ? certificationFile.name : '선택된 파일 없음'}
|
||||
</span>
|
||||
{certificationFile && (
|
||||
<Button
|
||||
type="button"
|
||||
@@ -288,20 +305,16 @@ export function ProductCertificationSection({
|
||||
size="sm"
|
||||
onClick={() => setCertificationFile(null)}
|
||||
disabled={isSubmitting}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{certificationFile && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
첨부됨: {certificationFile.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 비고 */}
|
||||
<div className="md:col-span-2">
|
||||
<div>
|
||||
<Label>비고</Label>
|
||||
<Textarea
|
||||
value={remarks}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* - 전동개폐기, 모터, 체인 등
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
import type { UseFormRegister, UseFormSetValue, FieldErrors } from 'react-hook-form';
|
||||
import type { CreateItemFormData } from '@/lib/utils/validation';
|
||||
import { PART_TYPE_CATEGORIES } from '../../constants';
|
||||
import { generatePurchasedItemCode } from '@/components/items/DynamicItemForm/utils/itemCodeGenerator';
|
||||
|
||||
export interface PurchasedPartFormProps {
|
||||
selectedCategory1: string;
|
||||
@@ -60,6 +62,18 @@ export default function PurchasedPartForm({
|
||||
setValue,
|
||||
errors,
|
||||
}: PurchasedPartFormProps) {
|
||||
// 전동개폐기 품목코드 자동생성 (품목명 + 용량 + 전원)
|
||||
const generatedItemCode = useMemo(() => {
|
||||
if (selectedCategory1 === 'electric_opener') {
|
||||
const category = PART_TYPE_CATEGORIES.PURCHASED?.categories.find(
|
||||
c => c.value === selectedCategory1
|
||||
);
|
||||
const itemName = category?.label || '';
|
||||
return generatePurchasedItemCode(itemName, electricOpenerCapacity, electricOpenerPower);
|
||||
}
|
||||
return '';
|
||||
}, [selectedCategory1, electricOpenerCapacity, electricOpenerPower]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 품목명 선택 */}
|
||||
@@ -258,19 +272,21 @@ export default function PurchasedPartForm({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 품목코드 자동생성 */}
|
||||
<div className="md:col-span-2">
|
||||
<Label>품목코드 (자동생성)</Label>
|
||||
<Input
|
||||
value=""
|
||||
disabled
|
||||
className="bg-muted text-muted-foreground"
|
||||
placeholder="품목명과 규격이 입력되면 자동으로 생성됩니다"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
* 품목코드는 '품목명-규격' 형식으로 자동 생성됩니다
|
||||
</p>
|
||||
</div>
|
||||
{/* 품목코드 자동생성 - 전동개폐기만 표시 */}
|
||||
{selectedCategory1 === 'electric_opener' && (
|
||||
<div className="md:col-span-2">
|
||||
<Label>품목코드 (자동생성)</Label>
|
||||
<Input
|
||||
value={generatedItemCode}
|
||||
disabled
|
||||
className="bg-muted text-muted-foreground"
|
||||
placeholder="용량과 전원을 선택하면 자동으로 생성됩니다"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
* 품목코드는 '품목명+용량+전원' 형식으로 자동 생성됩니다 (예: 전동개폐기150KG380V)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 품목 상태 */}
|
||||
<div className="md:col-span-2">
|
||||
|
||||
@@ -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() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleView(item.itemCode)}
|
||||
onClick={(e) => { e.stopPropagation(); handleView(item.itemCode, item.itemType, item.id); }}
|
||||
title="상세 보기"
|
||||
>
|
||||
<Search className="w-4 h-4" />
|
||||
@@ -337,7 +360,7 @@ export default function ItemListClient() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(item.itemCode)}
|
||||
onClick={(e) => { e.stopPropagation(); handleEdit(item.itemCode, item.itemType, item.id); }}
|
||||
title="수정"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
@@ -345,7 +368,7 @@ export default function ItemListClient() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => openDeleteDialog(item.id, item.itemCode)}
|
||||
onClick={(e) => { e.stopPropagation(); openDeleteDialog(item.id, item.itemCode, item.itemType); }}
|
||||
title="삭제"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-red-500" />
|
||||
@@ -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={
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
{item.specification && (
|
||||
@@ -400,34 +423,37 @@ export default function ItemListClient() {
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
<div className="flex items-center justify-end gap-1 pt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => { e.stopPropagation(); handleView(item.itemCode); }}
|
||||
className="h-8 px-3"
|
||||
>
|
||||
<Search className="h-4 w-4 mr-1" />
|
||||
<span className="text-xs">상세</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => { e.stopPropagation(); handleEdit(item.itemCode); }}
|
||||
className="h-8 px-3"
|
||||
>
|
||||
<Edit className="h-4 w-4 mr-1" />
|
||||
<span className="text-xs">수정</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => { e.stopPropagation(); openDeleteDialog(item.id, item.itemCode); }}
|
||||
className="h-8 px-2"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-600" />
|
||||
</Button>
|
||||
</div>
|
||||
isSelected ? (
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button
|
||||
variant="default"
|
||||
size="default"
|
||||
className="flex-1 min-w-[100px] h-11"
|
||||
onClick={(e) => { e.stopPropagation(); handleView(item.itemCode, item.itemType, item.id); }}
|
||||
>
|
||||
<Search className="h-4 w-4 mr-2" />
|
||||
상세
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="default"
|
||||
className="flex-1 min-w-[100px] h-11"
|
||||
onClick={(e) => { e.stopPropagation(); handleEdit(item.itemCode, item.itemType, item.id); }}
|
||||
>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="flex-1 min-w-[100px] h-11 border-red-200 text-red-600 hover:border-red-300 bg-[rgba(255,255,255,0)]"
|
||||
onClick={(e) => { e.stopPropagation(); openDeleteDialog(item.id, item.itemCode, item.itemType); }}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -426,9 +426,25 @@ export function FieldDialog({
|
||||
<DialogFooter className="shrink-0 bg-white z-10 px-6 py-4 border-t">
|
||||
<Button variant="outline" onClick={handleClose}>취소</Button>
|
||||
<Button onClick={async () => {
|
||||
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);
|
||||
}}>저장</Button>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -60,11 +60,11 @@ export default function Sidebar({
|
||||
<div
|
||||
ref={menuContainerRef}
|
||||
className={`sidebar-scroll flex-1 overflow-y-auto transition-all duration-300 ${
|
||||
sidebarCollapsed ? 'px-3 py-4' : 'px-4 py-3 md:px-6 md:py-4'
|
||||
sidebarCollapsed ? 'px-2 py-3' : 'px-3 py-4 md:px-4 md:py-4'
|
||||
}`}
|
||||
>
|
||||
<div className={`transition-all duration-300 ${
|
||||
sidebarCollapsed ? 'space-y-2 mt-4' : 'space-y-3 mt-3'
|
||||
sidebarCollapsed ? 'space-y-1.5 mt-4' : 'space-y-1.5 mt-3'
|
||||
}`}>
|
||||
{menuItems.map((item) => {
|
||||
const IconComponent = item.icon;
|
||||
@@ -82,7 +82,7 @@ export default function Sidebar({
|
||||
<button
|
||||
onClick={() => 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}
|
||||
>
|
||||
<div className={`rounded-lg flex items-center justify-center transition-all duration-200 sidebar-menu-icon ${
|
||||
sidebarCollapsed ? 'w-8 h-8' : 'w-9 h-9'
|
||||
<div className={`rounded-lg flex items-center justify-center transition-all duration-200 sidebar-menu-icon aspect-square ${
|
||||
sidebarCollapsed ? 'w-7' : 'w-8'
|
||||
} ${
|
||||
isActive
|
||||
? "bg-white/20"
|
||||
@@ -123,7 +123,7 @@ export default function Sidebar({
|
||||
|
||||
{/* 서브메뉴 */}
|
||||
{hasChildren && isExpanded && !sidebarCollapsed && (
|
||||
<div className="mt-2 ml-4 space-y-1 border-l-2 border-primary/20 pl-4">
|
||||
<div className="mt-1.5 ml-3 space-y-1.5 border-l-2 border-primary/20 pl-3">
|
||||
{item.children?.map((subItem) => {
|
||||
const SubIcon = subItem.icon;
|
||||
const isSubActive = activeMenu === subItem.id;
|
||||
@@ -134,7 +134,7 @@ export default function Sidebar({
|
||||
>
|
||||
<button
|
||||
onClick={() => 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"
|
||||
|
||||
185
src/components/molecules/FormField.tsx
Normal file
185
src/components/molecules/FormField.tsx
Normal file
@@ -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 (
|
||||
<Select
|
||||
value={value as string}
|
||||
onValueChange={onChange}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className={`${error ? 'border-red-500' : ''} ${inputClassName}`}>
|
||||
<SelectValue placeholder={selectPlaceholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
disabled={option.disabled}
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
|
||||
case 'textarea':
|
||||
return (
|
||||
<Textarea
|
||||
id={htmlFor}
|
||||
value={value as string}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
rows={rows}
|
||||
className={`${error ? 'border-red-500' : ''} ${inputClassName}`}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'custom':
|
||||
return children;
|
||||
|
||||
case 'number':
|
||||
return (
|
||||
<Input
|
||||
id={htmlFor}
|
||||
type="number"
|
||||
value={value}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
className={`${error ? 'border-red-500' : ''} ${inputClassName}`}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'date':
|
||||
return (
|
||||
<Input
|
||||
id={htmlFor}
|
||||
type="date"
|
||||
value={value as string}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
disabled={disabled}
|
||||
className={`${error ? 'border-red-500' : ''} ${inputClassName}`}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'password':
|
||||
return (
|
||||
<Input
|
||||
id={htmlFor}
|
||||
type="password"
|
||||
value={value as string}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
className={`${error ? 'border-red-500' : ''} ${inputClassName}`}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'text':
|
||||
default:
|
||||
return (
|
||||
<Input
|
||||
id={htmlFor}
|
||||
type="text"
|
||||
value={value as string}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
className={`${error ? 'border-red-500' : ''} ${inputClassName}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<Label htmlFor={htmlFor}>
|
||||
{label} {required && <span className="text-red-500">*</span>}
|
||||
</Label>
|
||||
|
||||
<div className="mt-1">
|
||||
{renderInput()}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-1 mt-1 text-sm text-red-500">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{helpText && !error && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{helpText}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
74
src/components/organisms/FormActions.tsx
Normal file
74
src/components/organisms/FormActions.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* FormActions - 폼 하단 액션 버튼 그룹
|
||||
*/
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import { Button } from "../ui/button";
|
||||
import { Save, X } from "lucide-react";
|
||||
|
||||
export interface FormActionsProps {
|
||||
onSave?: () => void;
|
||||
onCancel?: () => void;
|
||||
saveLabel?: string;
|
||||
cancelLabel?: string;
|
||||
saveDisabled?: boolean;
|
||||
cancelDisabled?: boolean;
|
||||
saveLoading?: boolean;
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
align?: 'left' | 'center' | 'right';
|
||||
}
|
||||
|
||||
export function FormActions({
|
||||
onSave,
|
||||
onCancel,
|
||||
saveLabel = "저장",
|
||||
cancelLabel = "취소",
|
||||
saveDisabled = false,
|
||||
cancelDisabled = false,
|
||||
saveLoading = false,
|
||||
children,
|
||||
className = "",
|
||||
align = 'right',
|
||||
}: FormActionsProps) {
|
||||
|
||||
const alignClasses = {
|
||||
left: "justify-start",
|
||||
center: "justify-center",
|
||||
right: "justify-end",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col md:flex-row gap-3 ${alignClasses[align]} ${className}`}>
|
||||
{children ? (
|
||||
children
|
||||
) : (
|
||||
<>
|
||||
{onCancel && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={cancelDisabled}
|
||||
className="w-full md:w-auto"
|
||||
>
|
||||
<X className="h-4 w-4 mr-2" />
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
)}
|
||||
{onSave && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
disabled={saveDisabled || saveLoading}
|
||||
className="w-full md:w-auto"
|
||||
>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{saveLoading ? "저장 중..." : saveLabel}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
src/components/organisms/FormFieldGrid.tsx
Normal file
35
src/components/organisms/FormFieldGrid.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* FormFieldGrid - 반응형 폼 필드 그리드
|
||||
*
|
||||
* 모바일: 1컬럼
|
||||
* 태블릿: 2컬럼
|
||||
* 데스크톱: 3컬럼 (또는 사용자 지정)
|
||||
*/
|
||||
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export interface FormFieldGridProps {
|
||||
children: ReactNode;
|
||||
columns?: 1 | 2 | 3 | 4;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FormFieldGrid({
|
||||
children,
|
||||
columns = 3,
|
||||
className = "",
|
||||
}: FormFieldGridProps) {
|
||||
|
||||
const gridClasses = {
|
||||
1: "grid-cols-1",
|
||||
2: "grid-cols-1 md:grid-cols-2",
|
||||
3: "grid-cols-1 md:grid-cols-2 lg:grid-cols-3",
|
||||
4: "grid-cols-1 md:grid-cols-2 lg:grid-cols-4",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`grid ${gridClasses[columns]} gap-4 ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
62
src/components/organisms/FormSection.tsx
Normal file
62
src/components/organisms/FormSection.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* FormSection - 폼 섹션 카드 컴포넌트
|
||||
*
|
||||
* 등록 페이지의 각 섹션을 카드로 감싸는 컴포넌트
|
||||
* 제목, 설명, 아이콘을 포함할 수 있습니다.
|
||||
*/
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||
import { LucideIcon } from "lucide-react";
|
||||
|
||||
export interface FormSectionProps {
|
||||
title?: string;
|
||||
description?: string;
|
||||
icon?: LucideIcon;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
headerAction?: ReactNode;
|
||||
variant?: 'default' | 'highlighted';
|
||||
}
|
||||
|
||||
export function FormSection({
|
||||
title,
|
||||
description,
|
||||
icon: Icon,
|
||||
children,
|
||||
className = "",
|
||||
headerAction,
|
||||
variant = 'default',
|
||||
}: FormSectionProps) {
|
||||
|
||||
const variantClasses = {
|
||||
default: "",
|
||||
highlighted: "border-blue-200 bg-blue-50/30",
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={`${variantClasses[variant]} ${className}`}>
|
||||
{(title || description) && (
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{Icon && <Icon className="h-5 w-5 text-primary" />}
|
||||
<div>
|
||||
{title && <CardTitle>{title}</CardTitle>}
|
||||
{description && (
|
||||
<CardDescription className="mt-1">{description}</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{headerAction && (
|
||||
<div>{headerAction}</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
)}
|
||||
<CardContent className={title || description ? "" : "pt-6"}>
|
||||
{children}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
425
src/components/quotes/PurchaseOrderDocument.tsx
Normal file
425
src/components/quotes/PurchaseOrderDocument.tsx
Normal file
@@ -0,0 +1,425 @@
|
||||
/**
|
||||
* 발주서 (Purchase Order Document)
|
||||
*
|
||||
* - 로트번호 및 결재란
|
||||
* - 신청업체 정보
|
||||
* - 신청내용
|
||||
* - 부자재 목록
|
||||
*/
|
||||
|
||||
import { QuoteFormData } from "./QuoteRegistration";
|
||||
|
||||
interface PurchaseOrderDocumentProps {
|
||||
quote: QuoteFormData;
|
||||
}
|
||||
|
||||
export function PurchaseOrderDocument({ quote }: PurchaseOrderDocumentProps) {
|
||||
const formatDate = (dateStr: string) => {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
return `${date.getFullYear()}년 ${String(date.getMonth() + 1).padStart(2, '0')}월 ${String(date.getDate()).padStart(2, '0')}일`;
|
||||
};
|
||||
|
||||
// 발주번호 생성 (견적번호 기반)
|
||||
const purchaseOrderNumber = quote.id?.replace('Q', 'KQ#-SC-') + '-01' || 'KQ#-SC-XXXXXX-01';
|
||||
|
||||
// BOM에서 부자재 목록 추출 (샘플 데이터)
|
||||
const materialItems = quote.items?.map((item, index) => ({
|
||||
no: index + 1,
|
||||
name: item.productName || '아연도각파이프',
|
||||
spec: `${item.openWidth}×${item.openHeight}`,
|
||||
length: Number(item.openHeight) || 3000,
|
||||
quantity: item.quantity || 1,
|
||||
note: ''
|
||||
})) || [
|
||||
{ no: 1, name: '아연도각파이프', spec: '100-50-2T', length: 3000, quantity: 6, note: '' },
|
||||
{ no: 2, name: '아연도각파이프', spec: '100-100-2T', length: 3000, quantity: 6, note: '' },
|
||||
{ no: 3, name: '아연도앵글', spec: '50-50-4T', length: 2500, quantity: 10, note: '' },
|
||||
{ no: 4, name: '외주 발주 코팅 비비그레스', spec: '', length: 0, quantity: 1, note: '' },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
@media print {
|
||||
@page {
|
||||
size: A4 portrait;
|
||||
margin: 10mm;
|
||||
}
|
||||
|
||||
body {
|
||||
background: white !important;
|
||||
}
|
||||
|
||||
#purchase-order-content {
|
||||
background: white !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 발주서 공문서 스타일 */
|
||||
.purchase-order {
|
||||
font-family: 'Malgun Gothic', 'Apple SD Gothic Neo', sans-serif;
|
||||
background: white;
|
||||
color: #000;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.po-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 2px solid #000;
|
||||
}
|
||||
|
||||
.po-title {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.po-title h1 {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 8px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.po-approval-section {
|
||||
border: 2px solid #000;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.po-lot-number-row {
|
||||
display: grid;
|
||||
grid-template-columns: 100px 1fr;
|
||||
border-bottom: 2px solid #000;
|
||||
}
|
||||
|
||||
.po-lot-label {
|
||||
background: #e8e8e8;
|
||||
border-right: 2px solid #000;
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.po-lot-value {
|
||||
background: white;
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
color: #000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.po-approval-box {
|
||||
width: 100%;
|
||||
border: none;
|
||||
display: grid;
|
||||
grid-template-columns: 60px 1fr;
|
||||
grid-template-rows: auto auto auto;
|
||||
}
|
||||
|
||||
.po-approval-merged-vertical-cell {
|
||||
border-right: 1px solid #000;
|
||||
padding: 4px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
background: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
grid-row: 1 / 4;
|
||||
}
|
||||
|
||||
.po-approval-header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
border-bottom: 1px solid #000;
|
||||
}
|
||||
|
||||
.po-approval-header-cell {
|
||||
border-right: 1px solid #000;
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.po-approval-header-cell:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.po-approval-content-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
border-bottom: 1px solid #000;
|
||||
}
|
||||
|
||||
.po-approval-name-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
}
|
||||
|
||||
.po-approval-signature-cell {
|
||||
border-right: 1px solid #000;
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
height: 50px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.po-approval-signature-cell:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.po-approval-name-cell {
|
||||
border-right: 1px solid #000;
|
||||
padding: 6px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.po-approval-name-cell:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.po-section-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border: 2px solid #000;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.po-section-header {
|
||||
background: #e8e8e8;
|
||||
border: 1px solid #666;
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.po-section-content {
|
||||
border: 1px solid #999;
|
||||
padding: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.po-materials-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border: 2px solid #000;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.po-materials-table th {
|
||||
background: #e8e8e8;
|
||||
border: 1px solid #666;
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.po-materials-table td {
|
||||
border: 1px solid #999;
|
||||
padding: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.po-notes {
|
||||
margin-top: 20px;
|
||||
padding: 15px;
|
||||
background: #f9f9f9;
|
||||
border: 1px solid #ddd;
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* 발주서 내용 */}
|
||||
<div id="purchase-order-content" className="purchase-order p-12 print:p-8">
|
||||
|
||||
{/* 헤더: 제목 + 결재란 */}
|
||||
<div className="po-header">
|
||||
{/* 제목 */}
|
||||
<div className="po-title">
|
||||
<h1>발 주 서</h1>
|
||||
</div>
|
||||
|
||||
{/* 로트번호 + 결재란 */}
|
||||
<div className="po-approval-section">
|
||||
{/* 로트번호 */}
|
||||
<div className="po-lot-number-row">
|
||||
<div className="po-lot-label">
|
||||
로트번호
|
||||
</div>
|
||||
<div className="po-lot-value">
|
||||
{purchaseOrderNumber}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 결재란 */}
|
||||
<div className="po-approval-box">
|
||||
<div className="po-approval-merged-vertical-cell">결<br/>재</div>
|
||||
{/* 결재란 헤더 */}
|
||||
<div className="po-approval-header">
|
||||
<div className="po-approval-header-cell">작성</div>
|
||||
<div className="po-approval-header-cell">검토</div>
|
||||
<div className="po-approval-header-cell">승인</div>
|
||||
</div>
|
||||
{/* 결재+서명란 */}
|
||||
<div className="po-approval-content-row">
|
||||
<div className="po-approval-signature-cell">전진</div>
|
||||
<div className="po-approval-signature-cell"></div>
|
||||
<div className="po-approval-signature-cell"></div>
|
||||
</div>
|
||||
{/* 이름란 */}
|
||||
<div className="po-approval-name-row">
|
||||
<div className="po-approval-name-cell">판매/전진</div>
|
||||
<div className="po-approval-name-cell">회계</div>
|
||||
<div className="po-approval-name-cell">생산</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 신청업체 */}
|
||||
<table className="po-section-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th className="po-section-header" rowSpan={3}>신 청 업 체</th>
|
||||
<th className="po-section-header" style={{ width: '100px' }}>발주처</th>
|
||||
<td className="po-section-content">{quote.clientName || '-'}</td>
|
||||
<th className="po-section-header" style={{ width: '100px' }}>발주일</th>
|
||||
<td className="po-section-content">{formatDate(quote.registrationDate || '')}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="po-section-header">담당자</th>
|
||||
<td className="po-section-content">{quote.manager || '-'}</td>
|
||||
<th className="po-section-header">연락처</th>
|
||||
<td className="po-section-content">{quote.contact || '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="po-section-header">F A X</th>
|
||||
<td className="po-section-content">-</td>
|
||||
<th className="po-section-header">설치개소(총)</th>
|
||||
<td className="po-section-content">{quote.items?.reduce((sum, item) => sum + (item.quantity || 0), 0) || 0}개소</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* 신청내용 */}
|
||||
<table className="po-section-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th className="po-section-header" rowSpan={5}>신 청 내 용</th>
|
||||
<th className="po-section-header" style={{ width: '100px' }}>현장명</th>
|
||||
<td className="po-section-content" colSpan={3}>{quote.siteName || '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="po-section-header">납기요청일</th>
|
||||
<td className="po-section-content" colSpan={3}>{formatDate(quote.dueDate || '')}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="po-section-header">출고일</th>
|
||||
<td className="po-section-content">{formatDate(quote.registrationDate || '')}</td>
|
||||
<th className="po-section-header" style={{ width: '100px' }}>배송방법</th>
|
||||
<td className="po-section-content">상차</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="po-section-header">납품주소</th>
|
||||
<td className="po-section-content" colSpan={3}>경기도 안성시 서운면 서운신기 16-180</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="po-section-header">수신자</th>
|
||||
<td className="po-section-content">{quote.manager || '-'}</td>
|
||||
<th className="po-section-header" style={{ width: '100px' }}>연락처</th>
|
||||
<td className="po-section-content">{quote.contact || '-'}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* 발주개소 정보 */}
|
||||
<table className="po-section-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th className="po-section-header" style={{ width: '120px' }}>누적발주개소</th>
|
||||
<td className="po-section-content" style={{ width: '200px' }}>-</td>
|
||||
<th className="po-section-header" style={{ width: '120px' }}>금번발주개소</th>
|
||||
<td className="po-section-content">{quote.items?.reduce((sum, item) => sum + (item.quantity || 0), 0) || 0}개소</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* 유의사항 */}
|
||||
<div style={{ marginBottom: '10px', fontSize: '11px', lineHeight: '1.6' }}>
|
||||
<p>1. 귀사의 일익번영을 기원합니다.</p>
|
||||
<p>2. 아래와 같이 주문하오니 품질 및 납기일을 준수하여 주시기 바랍니다.</p>
|
||||
</div>
|
||||
|
||||
{/* 부자재 테이블 */}
|
||||
<div style={{ fontWeight: '700', marginBottom: '10px', fontSize: '14px' }}>■ 부자재</div>
|
||||
<table className="po-materials-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '50px' }}>구분</th>
|
||||
<th>품명</th>
|
||||
<th style={{ width: '200px' }}>규격</th>
|
||||
<th style={{ width: '100px' }}>길이(mm)</th>
|
||||
<th style={{ width: '80px' }}>수량</th>
|
||||
<th style={{ width: '150px' }}>비고</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{materialItems.map((item) => (
|
||||
<tr key={item.no}>
|
||||
<td style={{ textAlign: 'center' }}>{item.no}</td>
|
||||
<td>{item.name}</td>
|
||||
<td>{item.spec}</td>
|
||||
<td style={{ textAlign: 'right' }}>{item.length > 0 ? item.length.toLocaleString() : ''}</td>
|
||||
<td style={{ textAlign: 'center', fontWeight: '600' }}>{item.quantity}</td>
|
||||
<td>{item.note}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* 특이사항 */}
|
||||
<div style={{ marginBottom: '20px', padding: '10px', border: '1px solid #ddd', background: '#fafafa' }}>
|
||||
<strong style={{ fontSize: '12px' }}>【특이사항】</strong>
|
||||
<div style={{ fontSize: '11px', marginTop: '5px', lineHeight: '1.6' }}>
|
||||
{quote.remarks || '스크린 셔터 부품구성표 기반 자동 견적'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 하단 안내사항 */}
|
||||
<div className="po-notes">
|
||||
<p style={{ fontWeight: '600', marginBottom: '5px' }}>【 유의사항 】</p>
|
||||
<p>• 발주서 승인 완료 후 작업을 진행해주시기 바랍니다.</p>
|
||||
<p>• 납기 엄수 부탁드리며, 품질 기준에 맞춰 납품해주시기 바랍니다.</p>
|
||||
<p>• 기타 문의사항은 담당자에게 연락 부탁드립니다.</p>
|
||||
<p style={{ marginTop: '10px', textAlign: 'center', fontWeight: '600' }}>
|
||||
문의: {quote.writer || '담당자'} | {quote.contact || '031-983-5130'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
508
src/components/quotes/QuoteCalculationReport.tsx
Normal file
508
src/components/quotes/QuoteCalculationReport.tsx
Normal file
@@ -0,0 +1,508 @@
|
||||
/**
|
||||
* 견적 산출내역서 / 견적서 컴포넌트
|
||||
* - documentType="견적서": 간단한 견적서
|
||||
* - documentType="견적산출내역서": 상세 산출내역서 + 소요자재 내역
|
||||
*/
|
||||
|
||||
import { QuoteFormData } from "./QuoteRegistration";
|
||||
|
||||
interface QuoteCalculationReportProps {
|
||||
quote: QuoteFormData;
|
||||
documentType?: "견적산출내역서" | "견적서";
|
||||
showDetailedBreakdown?: boolean;
|
||||
showMaterialList?: boolean;
|
||||
}
|
||||
|
||||
export function QuoteCalculationReport({
|
||||
quote,
|
||||
documentType = "견적산출내역서",
|
||||
showDetailedBreakdown = true,
|
||||
showMaterialList = true
|
||||
}: QuoteCalculationReportProps) {
|
||||
const formatAmount = (amount: number | null | undefined) => {
|
||||
if (amount == null) return '0';
|
||||
return Number(amount).toLocaleString('ko-KR');
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
return `${date.getFullYear()}년 ${String(date.getMonth() + 1).padStart(2, '0')}월 ${String(date.getDate()).padStart(2, '0')}일`;
|
||||
};
|
||||
|
||||
// 총 금액 계산
|
||||
const totalAmount = quote.items?.reduce((sum, item) => {
|
||||
return sum + (item.inspectionFee || 0) * (item.quantity || 1);
|
||||
}, 0) || 0;
|
||||
|
||||
// 소요자재 내역 생성 (샘플 데이터)
|
||||
const materialItems = quote.items?.map((item, index) => ({
|
||||
no: index + 1,
|
||||
name: item.productName || '가이드레일',
|
||||
spec: `${item.openWidth || 0}×${item.openHeight || 0}mm`,
|
||||
quantity: item.quantity || 1,
|
||||
unit: 'SET'
|
||||
})) || [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
@media print {
|
||||
@page {
|
||||
size: A4 portrait;
|
||||
margin: 15mm;
|
||||
}
|
||||
|
||||
body {
|
||||
background: white !important;
|
||||
}
|
||||
|
||||
.print\\:hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
#quote-report-content {
|
||||
background: white !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
table {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
.page-break-after {
|
||||
page-break-after: always;
|
||||
}
|
||||
}
|
||||
|
||||
/* 공문서 스타일 */
|
||||
.official-doc {
|
||||
font-family: 'Malgun Gothic', 'Apple SD Gothic Neo', sans-serif;
|
||||
background: white;
|
||||
color: #000;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.doc-header {
|
||||
text-align: center;
|
||||
border-bottom: 3px double #000;
|
||||
padding-bottom: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.doc-title {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 2px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.doc-number {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
border: 2px solid #000;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.info-box-header {
|
||||
background: #f0f0f0;
|
||||
border-bottom: 2px solid #000;
|
||||
padding: 8px 12px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.info-box-content {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.info-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.info-table th {
|
||||
background: #f8f8f8;
|
||||
border: 1px solid #999;
|
||||
padding: 8px 10px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.info-table td {
|
||||
border: 1px solid #999;
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.amount-box {
|
||||
border: 3px double #000;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
margin: 30px 0;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.amount-label {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.amount-value {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #000;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.amount-note {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
background: #000;
|
||||
color: white;
|
||||
padding: 10px 15px;
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
margin: 30px 0 15px 0;
|
||||
text-align: center;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.detail-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border: 2px solid #000;
|
||||
}
|
||||
|
||||
.detail-table thead th {
|
||||
background: #e8e8e8;
|
||||
border: 1px solid #666;
|
||||
padding: 10px 6px;
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.detail-table tbody td {
|
||||
border: 1px solid #999;
|
||||
padding: 8px 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.detail-table tbody tr:hover {
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
.detail-table tfoot td {
|
||||
background: #f0f0f0;
|
||||
border: 1px solid #666;
|
||||
padding: 10px;
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.material-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border: 2px solid #000;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.material-table th {
|
||||
background: #e8e8e8;
|
||||
border: 1px solid #666;
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.material-table td {
|
||||
border: 1px solid #999;
|
||||
padding: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.stamp-area {
|
||||
border: 2px solid #000;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.stamp-text {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 10px;
|
||||
color: #999;
|
||||
text-align: center;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.footer-note {
|
||||
margin-top: 40px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #ccc;
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.signature-section {
|
||||
margin-top: 30px;
|
||||
text-align: right;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* 문서 컴포넌트 */}
|
||||
<div className="official-doc">
|
||||
{/* 문서 헤더 */}
|
||||
<div className="doc-header">
|
||||
<div className="doc-title">
|
||||
{documentType === "견적서" ? "견 적 서" : "견 적 산 출 내 역 서"}
|
||||
</div>
|
||||
<div className="doc-number">
|
||||
문서번호: {quote.id || '-'} | 작성일자: {formatDate(quote.registrationDate || '')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 수요자 정보 */}
|
||||
<div className="info-box">
|
||||
<div className="info-box-header">수 요 자</div>
|
||||
<div className="info-box-content">
|
||||
<table className="info-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>업체명</th>
|
||||
<td colSpan={3}>{quote.clientName || '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>현장명</th>
|
||||
<td>{quote.siteName || '-'}</td>
|
||||
<th>담당자</th>
|
||||
<td>{quote.manager || '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>제품명</th>
|
||||
<td>{quote.items?.[0]?.productName || '-'}</td>
|
||||
<th>연락처</th>
|
||||
<td>{quote.contact || '-'}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 공급자 정보 */}
|
||||
<div className="info-box">
|
||||
<div className="info-box-header">공 급 자</div>
|
||||
<div className="info-box-content">
|
||||
<table className="info-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>상호</th>
|
||||
<td>(주)염진건설</td>
|
||||
<th>사업자등록번호</th>
|
||||
<td>139-87-00353</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>대표자</th>
|
||||
<td>김 용 진</td>
|
||||
<th>업태</th>
|
||||
<td>제조</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>종목</th>
|
||||
<td colSpan={3}>방창, 셔터, 금속창호</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>사업장주소</th>
|
||||
<td colSpan={3}>경기도 안성시 공업용지 오성길 45-22</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>전화</th>
|
||||
<td>031-983-5130</td>
|
||||
<th>팩스</th>
|
||||
<td>02-6911-6315</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 총 견적금액 */}
|
||||
<div className="amount-box">
|
||||
<div className="amount-label">총 견적금액</div>
|
||||
<div className="amount-value">₩ {formatAmount(totalAmount)}</div>
|
||||
<div className="amount-note">※ 부가가치세 별도</div>
|
||||
</div>
|
||||
|
||||
{/* 세부 산출내역서 */}
|
||||
{showDetailedBreakdown && quote.items && quote.items.length > 0 && (
|
||||
<div className="page-break-after">
|
||||
<div className="section-title">세 부 산 출 내 역</div>
|
||||
|
||||
<table className="detail-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '40px' }}>No.</th>
|
||||
<th style={{ width: '200px' }}>품목명</th>
|
||||
<th style={{ width: '150px' }}>규격</th>
|
||||
<th style={{ width: '70px' }}>수량</th>
|
||||
<th style={{ width: '50px' }}>단위</th>
|
||||
<th style={{ width: '110px' }}>단가</th>
|
||||
<th style={{ width: '130px' }}>금액</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{quote.items.map((item, index) => (
|
||||
<tr key={item.id || `item-${index}`}>
|
||||
<td style={{ textAlign: 'center' }}>{index + 1}</td>
|
||||
<td>{item.productName}</td>
|
||||
<td style={{ fontSize: '11px' }}>{`${item.openWidth}×${item.openHeight}mm`}</td>
|
||||
<td style={{ textAlign: 'right' }}>{item.quantity}</td>
|
||||
<td style={{ textAlign: 'center' }}>SET</td>
|
||||
<td style={{ textAlign: 'right' }}>{formatAmount(item.inspectionFee)}</td>
|
||||
<td style={{ textAlign: 'right', fontWeight: '600' }}>{formatAmount((item.inspectionFee || 0) * (item.quantity || 1))}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colSpan={6} style={{ textAlign: 'right', padding: '12px', background: '#e0e0e0', fontWeight: '700' }}>공급가액 합계</td>
|
||||
<td style={{ textAlign: 'right', padding: '12px', background: '#e0e0e0', fontSize: '15px', fontWeight: '700' }}>{formatAmount(totalAmount)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 소요자재 내역 */}
|
||||
{showMaterialList && documentType !== "견적서" && (
|
||||
<div>
|
||||
<div className="section-title">소 요 자 재 내 역</div>
|
||||
|
||||
{/* 제품 정보 */}
|
||||
<div className="info-box" style={{ marginTop: '15px' }}>
|
||||
<div className="info-box-content">
|
||||
<table className="info-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>제품구분</th>
|
||||
<td>{quote.items?.[0]?.productCategory === 'steel' ? '철재' : '스크린'}</td>
|
||||
<th>부호</th>
|
||||
<td>{quote.items?.[0]?.code || '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>오픈사이즈</th>
|
||||
<td>W {quote.items?.[0]?.openWidth || '-'} × H {quote.items?.[0]?.openHeight || '-'} (mm)</td>
|
||||
<th>제작사이즈</th>
|
||||
<td>W {Number(quote.items?.[0]?.openWidth || 0) + 100} × H {Number(quote.items?.[0]?.openHeight || 0) + 100} (mm)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>수량</th>
|
||||
<td>{quote.items?.[0]?.quantity || 1} SET</td>
|
||||
<th>케이스</th>
|
||||
<td>2438 × 550 (mm)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 자재 목록 테이블 */}
|
||||
<table className="material-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '40px' }}>No.</th>
|
||||
<th>자재명</th>
|
||||
<th style={{ width: '250px' }}>규격</th>
|
||||
<th style={{ width: '80px' }}>수량</th>
|
||||
<th style={{ width: '60px' }}>단위</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{materialItems.map((item, index) => (
|
||||
<tr key={index}>
|
||||
<td style={{ textAlign: 'center' }}>{index + 1}</td>
|
||||
<td>{item.name}</td>
|
||||
<td>{item.spec}</td>
|
||||
<td style={{ textAlign: 'center', fontWeight: '600' }}>{item.quantity}</td>
|
||||
<td style={{ textAlign: 'center' }}>{item.unit}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 비고사항 */}
|
||||
{quote.remarks && (
|
||||
<div style={{ marginTop: '30px' }}>
|
||||
<div className="section-title">비 고 사 항</div>
|
||||
<div style={{
|
||||
border: '2px solid #000',
|
||||
padding: '15px',
|
||||
minHeight: '100px',
|
||||
whiteSpace: 'pre-wrap',
|
||||
fontSize: '13px',
|
||||
lineHeight: '1.8',
|
||||
marginTop: '15px'
|
||||
}}>
|
||||
{quote.remarks}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 서명란 */}
|
||||
<div className="signature-section">
|
||||
<div style={{ display: 'inline-block', textAlign: 'left' }}>
|
||||
<div style={{ marginBottom: '15px', fontSize: '14px' }}>
|
||||
상기와 같이 견적합니다.
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '20px' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '13px', marginBottom: '5px' }}>{formatDate(quote.registrationDate || '')}</div>
|
||||
<div style={{ fontSize: '15px', fontWeight: '600' }}>
|
||||
공급자: (주)염진건설 (인)
|
||||
</div>
|
||||
</div>
|
||||
<div className="stamp-area">
|
||||
<div className="stamp-text">
|
||||
(인감<br/>날인)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 하단 안내사항 */}
|
||||
<div className="footer-note">
|
||||
<p style={{ fontWeight: '600', marginBottom: '8px' }}>【 유의사항 】</p>
|
||||
<p>1. 본 견적서는 {formatDate(quote.registrationDate || '')} 기준으로 작성되었으며, 자재 가격 변동 시 조정될 수 있습니다.</p>
|
||||
<p>2. 견적 유효기간은 발행일로부터 30일이며, 기간 경과 시 재견적이 필요합니다.</p>
|
||||
<p>3. 제작 사양 및 수량 변경 시 견적 금액이 변동될 수 있습니다.</p>
|
||||
<p>4. 현장 여건에 따라 추가 비용이 발생할 수 있습니다.</p>
|
||||
<p style={{ marginTop: '12px', textAlign: 'center', fontWeight: '600' }}>
|
||||
문의: {quote.manager || '담당자'} | {quote.contact || '031-983-5130'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
461
src/components/quotes/QuoteDocument.tsx
Normal file
461
src/components/quotes/QuoteDocument.tsx
Normal file
@@ -0,0 +1,461 @@
|
||||
/**
|
||||
* 견적서 (Quote Document)
|
||||
*
|
||||
* - 수요자/공급자 정보
|
||||
* - 총 견적금액
|
||||
* - 제품구성 정보
|
||||
* - 품목 내역 테이블
|
||||
* - 비용 산출
|
||||
* - 비고사항
|
||||
* - 서명란
|
||||
*/
|
||||
|
||||
import { QuoteFormData } from "./QuoteRegistration";
|
||||
|
||||
interface QuoteDocumentProps {
|
||||
quote: QuoteFormData;
|
||||
}
|
||||
|
||||
export function QuoteDocument({ quote }: QuoteDocumentProps) {
|
||||
const formatAmount = (amount: number | undefined) => {
|
||||
if (amount === undefined || amount === null) return '0';
|
||||
return amount.toLocaleString('ko-KR');
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
return `${date.getFullYear()}년 ${String(date.getMonth() + 1).padStart(2, '0')}월 ${String(date.getDate()).padStart(2, '0')}일`;
|
||||
};
|
||||
|
||||
// 품목 내역 생성
|
||||
const quoteItems = quote.items?.map((item, index) => ({
|
||||
no: index + 1,
|
||||
itemName: item.productName || '스크린셔터',
|
||||
spec: `${item.openWidth}×${item.openHeight}`,
|
||||
quantity: item.quantity || 1,
|
||||
unit: '개소',
|
||||
unitPrice: item.unitPrice || 0,
|
||||
totalPrice: item.totalAmount || 0,
|
||||
})) || [];
|
||||
|
||||
// 합계 계산
|
||||
const subtotal = quoteItems.reduce((sum, item) => sum + item.totalPrice, 0);
|
||||
const vat = Math.round(subtotal * 0.1);
|
||||
const totalAmount = subtotal + vat;
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
@media print {
|
||||
@page {
|
||||
size: A4 portrait;
|
||||
margin: 15mm;
|
||||
}
|
||||
|
||||
body {
|
||||
background: white !important;
|
||||
}
|
||||
|
||||
.print\\:hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
#quote-document-content {
|
||||
background: white !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
table {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
}
|
||||
|
||||
/* 공문서 스타일 */
|
||||
.official-doc {
|
||||
font-family: 'Malgun Gothic', 'Apple SD Gothic Neo', sans-serif;
|
||||
background: white;
|
||||
color: #000;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.doc-header {
|
||||
text-align: center;
|
||||
border-bottom: 3px double #000;
|
||||
padding-bottom: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.doc-title {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 2px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.doc-number {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
border: 2px solid #000;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.info-box-header {
|
||||
background: #f0f0f0;
|
||||
border-bottom: 2px solid #000;
|
||||
padding: 8px 12px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.info-box-content {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.info-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.info-table th {
|
||||
background: #f8f8f8;
|
||||
border: 1px solid #999;
|
||||
padding: 8px 10px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.info-table td {
|
||||
border: 1px solid #999;
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.amount-box {
|
||||
border: 3px double #000;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
margin: 30px 0;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.amount-label {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.amount-value {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #000;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.amount-note {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
background: #000;
|
||||
color: white;
|
||||
padding: 10px 15px;
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
margin: 30px 0 15px 0;
|
||||
text-align: center;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.detail-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border: 2px solid #000;
|
||||
}
|
||||
|
||||
.detail-table thead th {
|
||||
background: #e8e8e8;
|
||||
border: 1px solid #666;
|
||||
padding: 10px 6px;
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.detail-table tbody td {
|
||||
border: 1px solid #999;
|
||||
padding: 8px 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.detail-table tbody tr:hover {
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
.detail-table tfoot td {
|
||||
background: #f0f0f0;
|
||||
border: 1px solid #666;
|
||||
padding: 10px;
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.stamp-area {
|
||||
border: 2px solid #000;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.stamp-text {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 10px;
|
||||
color: #999;
|
||||
text-align: center;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.footer-note {
|
||||
margin-top: 40px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #ccc;
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.signature-section {
|
||||
margin-top: 30px;
|
||||
text-align: right;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* 견적서 내용 */}
|
||||
<div id="quote-document-content" className="official-doc p-12 print:p-8">
|
||||
{/* 문서 헤더 */}
|
||||
<div className="doc-header">
|
||||
<div className="doc-title">견 적 서</div>
|
||||
<div className="doc-number">
|
||||
문서번호: {quote.id || 'Q-XXXXXX'} | 작성일자: {formatDate(quote.registrationDate || '')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 수요자 정보 */}
|
||||
<div className="info-box">
|
||||
<div className="info-box-header">수 요 자</div>
|
||||
<div className="info-box-content">
|
||||
<table className="info-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>업체명</th>
|
||||
<td colSpan={3}>{quote.clientName || '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>프로젝트명</th>
|
||||
<td>{quote.siteName || '-'}</td>
|
||||
<th>담당자</th>
|
||||
<td>{quote.manager || '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>견적일자</th>
|
||||
<td>{formatDate(quote.registrationDate || '')}</td>
|
||||
<th>연락처</th>
|
||||
<td>{quote.contact || '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>유효기간</th>
|
||||
<td colSpan={3}>{formatDate(quote.dueDate || '')}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 공급자 정보 */}
|
||||
<div className="info-box">
|
||||
<div className="info-box-header">공 급 자</div>
|
||||
<div className="info-box-content">
|
||||
<table className="info-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>상호</th>
|
||||
<td>동호기업</td>
|
||||
<th>사업자등록번호</th>
|
||||
<td>139-87-00333</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>대표자</th>
|
||||
<td>이 광 호</td>
|
||||
<th>업태</th>
|
||||
<td>제조</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>종목</th>
|
||||
<td colSpan={3}>방창, 셔터, 금속성호</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>사업장주소</th>
|
||||
<td colSpan={3}>경기도 안성시 공업용지 오성길 45-22</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>전화</th>
|
||||
<td>031-983-5130</td>
|
||||
<th>팩스</th>
|
||||
<td>02-6911-6315</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 총 견적금액 */}
|
||||
<div className="amount-box">
|
||||
<div className="amount-label">총 견적금액</div>
|
||||
<div className="amount-value">₩ {formatAmount(totalAmount)}</div>
|
||||
<div className="amount-note">※ 부가가치세 포함</div>
|
||||
</div>
|
||||
|
||||
{/* 제품구성 정보 */}
|
||||
{quote.items && quote.items.length > 0 && (
|
||||
<>
|
||||
<div className="section-title">제 품 구 성 정 보</div>
|
||||
<div className="info-box" style={{ marginTop: '15px' }}>
|
||||
<div className="info-box-content">
|
||||
<table className="info-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>모델</th>
|
||||
<td>{quote.items[0]?.productName || '스크린셔터'}</td>
|
||||
<th>총 수량</th>
|
||||
<td>{quote.items.reduce((sum, item) => sum + (item.quantity || 0), 0)}개소</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>오픈사이즈</th>
|
||||
<td>{quote.items[0]?.openWidth}×{quote.items[0]?.openHeight}</td>
|
||||
<th>설치유형</th>
|
||||
<td>{quote.items[0]?.installType || '-'}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 품목 내역 */}
|
||||
{quoteItems.length > 0 && (
|
||||
<>
|
||||
<div className="section-title">품 목 내 역</div>
|
||||
<table className="detail-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '40px' }}>No.</th>
|
||||
<th style={{ width: '200px' }}>품목명</th>
|
||||
<th style={{ width: '150px' }}>규격</th>
|
||||
<th style={{ width: '70px' }}>수량</th>
|
||||
<th style={{ width: '50px' }}>단위</th>
|
||||
<th style={{ width: '110px' }}>단가</th>
|
||||
<th style={{ width: '130px' }}>금액</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{quoteItems.map((item) => (
|
||||
<tr key={item.no}>
|
||||
<td style={{ textAlign: 'center' }}>{item.no}</td>
|
||||
<td>{item.itemName}</td>
|
||||
<td style={{ textAlign: 'center' }}>{item.spec || '-'}</td>
|
||||
<td style={{ textAlign: 'right' }}>{item.quantity}</td>
|
||||
<td style={{ textAlign: 'center' }}>{item.unit}</td>
|
||||
<td style={{ textAlign: 'right' }}>{formatAmount(item.unitPrice)}</td>
|
||||
<td style={{ textAlign: 'right', fontWeight: '600' }}>{formatAmount(item.totalPrice)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colSpan={6} style={{ textAlign: 'right', padding: '12px' }}>공급가액 합계</td>
|
||||
<td style={{ textAlign: 'right', padding: '12px' }}>{formatAmount(subtotal)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colSpan={6} style={{ textAlign: 'right', padding: '12px' }}>부가가치세 (10%)</td>
|
||||
<td style={{ textAlign: 'right', padding: '12px' }}>{formatAmount(vat)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colSpan={6} style={{ textAlign: 'right', padding: '12px', background: '#e0e0e0' }}>총 견적금액</td>
|
||||
<td style={{ textAlign: 'right', padding: '12px', background: '#e0e0e0', fontSize: '15px', fontWeight: '700' }}>
|
||||
{formatAmount(totalAmount)}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 비고사항 */}
|
||||
{quote.remarks && (
|
||||
<>
|
||||
<div className="section-title">비 고 사 항</div>
|
||||
<div style={{
|
||||
border: '2px solid #000',
|
||||
padding: '15px',
|
||||
minHeight: '100px',
|
||||
whiteSpace: 'pre-wrap',
|
||||
fontSize: '13px',
|
||||
lineHeight: '1.8',
|
||||
marginTop: '15px'
|
||||
}}>
|
||||
{quote.remarks}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 서명란 */}
|
||||
<div className="signature-section">
|
||||
<div style={{ display: 'inline-block', textAlign: 'left' }}>
|
||||
<div style={{ marginBottom: '15px', fontSize: '14px' }}>
|
||||
상기와 같이 견적합니다.
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '20px' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '13px', marginBottom: '5px' }}>{formatDate(quote.registrationDate || '')}</div>
|
||||
<div style={{ fontSize: '15px', fontWeight: '600' }}>
|
||||
공급자: 동호기업 (인)
|
||||
</div>
|
||||
</div>
|
||||
<div className="stamp-area">
|
||||
<div className="stamp-text">
|
||||
(인감<br/>날인)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 하단 안내사항 */}
|
||||
<div className="footer-note">
|
||||
<p style={{ fontWeight: '600', marginBottom: '8px' }}>【 유의사항 】</p>
|
||||
<p>1. 본 견적서는 {formatDate(quote.registrationDate || '')} 기준으로 작성되었으며, 자재 가격 변동 시 조정될 수 있습니다.</p>
|
||||
<p>2. 견적 유효기간은 {formatDate(quote.dueDate || '')}까지이며, 기간 경과 시 재견적이 필요합니다.</p>
|
||||
<p>3. 제작 사양 및 수량 변경 시 견적 금액이 변동될 수 있습니다.</p>
|
||||
<p>4. 현장 여건에 따라 추가 비용이 발생할 수 있습니다.</p>
|
||||
<p style={{ marginTop: '12px', textAlign: 'center', fontWeight: '600' }}>
|
||||
문의: {quote.writer || '담당자'} | {quote.contact || '031-983-5130'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
801
src/components/quotes/QuoteRegistration.tsx
Normal file
801
src/components/quotes/QuoteRegistration.tsx
Normal file
@@ -0,0 +1,801 @@
|
||||
/**
|
||||
* 견적 등록/수정 컴포넌트
|
||||
*
|
||||
* 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 { Button } from "../ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
|
||||
import { Badge } from "../ui/badge";
|
||||
import {
|
||||
FileText,
|
||||
Calculator,
|
||||
Plus,
|
||||
Copy,
|
||||
Trash2,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
ResponsiveFormTemplate,
|
||||
FormSection,
|
||||
FormFieldGrid,
|
||||
} from "../templates/ResponsiveFormTemplate";
|
||||
import { FormField } from "../molecules/FormField";
|
||||
|
||||
// 견적 항목 타입
|
||||
export interface QuoteItem {
|
||||
id: string;
|
||||
floor: string; // 층수
|
||||
code: string; // 부호
|
||||
productCategory: string; // 제품 카테고리 (PC)
|
||||
productName: string; // 제품명
|
||||
openWidth: string; // 오픈사이즈 W0
|
||||
openHeight: string; // 오픈사이즈 H0
|
||||
guideRailType: string; // 가이드레일 설치 유형 (GT)
|
||||
motorPower: string; // 모터 전원 (MP)
|
||||
controller: string; // 연동제어기 (CT)
|
||||
quantity: number; // 수량 (QTY)
|
||||
wingSize: string; // 마구리 날개치수 (WS)
|
||||
inspectionFee: number; // 검사비 (INSP)
|
||||
}
|
||||
|
||||
// 견적 폼 데이터 타입
|
||||
export interface QuoteFormData {
|
||||
id?: string;
|
||||
registrationDate: string;
|
||||
writer: string;
|
||||
clientId: string;
|
||||
clientName: string;
|
||||
siteName: string; // 현장명 (직접 입력)
|
||||
manager: string;
|
||||
contact: string;
|
||||
dueDate: string;
|
||||
remarks: string;
|
||||
items: QuoteItem[];
|
||||
}
|
||||
|
||||
// 초기 견적 항목
|
||||
const createNewItem = (): QuoteItem => ({
|
||||
id: `item-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
floor: "",
|
||||
code: "",
|
||||
productCategory: "",
|
||||
productName: "",
|
||||
openWidth: "",
|
||||
openHeight: "",
|
||||
guideRailType: "",
|
||||
motorPower: "",
|
||||
controller: "",
|
||||
quantity: 1,
|
||||
wingSize: "50",
|
||||
inspectionFee: 50000,
|
||||
});
|
||||
|
||||
// 초기 폼 데이터
|
||||
export const INITIAL_QUOTE_FORM: QuoteFormData = {
|
||||
registrationDate: new Date().toISOString().split("T")[0],
|
||||
writer: "드미트리", // TODO: 로그인 사용자 정보로 대체
|
||||
clientId: "",
|
||||
clientName: "",
|
||||
siteName: "", // 현장명 (직접 입력)
|
||||
manager: "",
|
||||
contact: "",
|
||||
dueDate: "",
|
||||
remarks: "",
|
||||
items: [createNewItem()],
|
||||
};
|
||||
|
||||
// 샘플 발주처 데이터 (TODO: API에서 가져오기)
|
||||
const SAMPLE_CLIENTS = [
|
||||
{ id: "client-1", name: "인천건설 - 최담당" },
|
||||
{ id: "client-2", name: "ABC건설" },
|
||||
{ id: "client-3", name: "XYZ산업" },
|
||||
];
|
||||
|
||||
// 제품 카테고리 옵션
|
||||
const PRODUCT_CATEGORIES = [
|
||||
{ value: "screen", label: "스크린" },
|
||||
{ value: "steel", label: "철재" },
|
||||
{ value: "aluminum", label: "알루미늄" },
|
||||
{ value: "etc", label: "기타" },
|
||||
];
|
||||
|
||||
// 제품명 옵션 (카테고리별)
|
||||
const PRODUCTS: Record<string, { value: string; label: string }[]> = {
|
||||
screen: [
|
||||
{ value: "SCR-001", label: "스크린 A형" },
|
||||
{ value: "SCR-002", label: "스크린 B형" },
|
||||
{ value: "SCR-003", label: "스크린 C형" },
|
||||
],
|
||||
steel: [
|
||||
{ value: "STL-001", label: "철재 도어 A" },
|
||||
{ value: "STL-002", label: "철재 도어 B" },
|
||||
],
|
||||
aluminum: [
|
||||
{ value: "ALU-001", label: "알루미늄 프레임" },
|
||||
],
|
||||
etc: [
|
||||
{ value: "ETC-001", label: "기타 제품" },
|
||||
],
|
||||
};
|
||||
|
||||
// 가이드레일 설치 유형
|
||||
const GUIDE_RAIL_TYPES = [
|
||||
{ value: "wall", label: "벽부착형" },
|
||||
{ value: "ceiling", label: "천장매립형" },
|
||||
{ value: "floor", label: "바닥매립형" },
|
||||
];
|
||||
|
||||
// 모터 전원
|
||||
const MOTOR_POWERS = [
|
||||
{ value: "single", label: "단상 220V" },
|
||||
{ value: "three", label: "삼상 380V" },
|
||||
];
|
||||
|
||||
// 연동제어기
|
||||
const CONTROLLERS = [
|
||||
{ value: "basic", label: "기본 제어기" },
|
||||
{ value: "smart", label: "스마트 제어기" },
|
||||
{ value: "premium", label: "프리미엄 제어기" },
|
||||
];
|
||||
|
||||
interface QuoteRegistrationProps {
|
||||
onBack: () => void;
|
||||
onSave: (quote: QuoteFormData) => Promise<void>;
|
||||
editingQuote?: QuoteFormData | null;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function QuoteRegistration({
|
||||
onBack,
|
||||
onSave,
|
||||
editingQuote,
|
||||
isLoading = false,
|
||||
}: QuoteRegistrationProps) {
|
||||
const [formData, setFormData] = useState<QuoteFormData>(
|
||||
editingQuote || INITIAL_QUOTE_FORM
|
||||
);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [activeItemIndex, setActiveItemIndex] = useState(0);
|
||||
|
||||
// editingQuote가 변경되면 formData 업데이트
|
||||
useEffect(() => {
|
||||
if (editingQuote) {
|
||||
setFormData(editingQuote);
|
||||
}
|
||||
}, [editingQuote]);
|
||||
|
||||
// 유효성 검사
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!formData.clientId) {
|
||||
newErrors.clientId = "발주처를 선택해주세요";
|
||||
}
|
||||
|
||||
// 견적 항목 검사
|
||||
formData.items.forEach((item, index) => {
|
||||
if (!item.productCategory) {
|
||||
newErrors[`item-${index}-productCategory`] = "제품 카테고리를 선택해주세요";
|
||||
}
|
||||
if (!item.productName) {
|
||||
newErrors[`item-${index}-productName`] = "제품명을 선택해주세요";
|
||||
}
|
||||
if (!item.openWidth) {
|
||||
newErrors[`item-${index}-openWidth`] = "오픈사이즈(W)를 입력해주세요";
|
||||
}
|
||||
if (!item.openHeight) {
|
||||
newErrors[`item-${index}-openHeight`] = "오픈사이즈(H)를 입력해주세요";
|
||||
}
|
||||
if (!item.guideRailType) {
|
||||
newErrors[`item-${index}-guideRailType`] = "설치 유형을 선택해주세요";
|
||||
}
|
||||
if (!item.motorPower) {
|
||||
newErrors[`item-${index}-motorPower`] = "모터 전원을 선택해주세요";
|
||||
}
|
||||
if (!item.controller) {
|
||||
newErrors[`item-${index}-controller`] = "제어기를 선택해주세요";
|
||||
}
|
||||
if (item.quantity < 1) {
|
||||
newErrors[`item-${index}-quantity`] = "수량은 1 이상이어야 합니다";
|
||||
}
|
||||
});
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!validateForm()) {
|
||||
toast.error("입력 내용을 확인해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onSave(formData);
|
||||
toast.success(
|
||||
editingQuote ? "견적이 수정되었습니다." : "견적이 등록되었습니다."
|
||||
);
|
||||
onBack();
|
||||
} catch (error) {
|
||||
toast.error("저장 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFieldChange = (
|
||||
field: keyof QuoteFormData,
|
||||
value: string | QuoteItem[]
|
||||
) => {
|
||||
setFormData({ ...formData, [field]: value });
|
||||
if (errors[field]) {
|
||||
setErrors((prev) => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors[field];
|
||||
return newErrors;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 발주처 선택
|
||||
const handleClientChange = (clientId: string) => {
|
||||
const client = SAMPLE_CLIENTS.find((c) => c.id === clientId);
|
||||
setFormData({
|
||||
...formData,
|
||||
clientId,
|
||||
clientName: client?.name || "",
|
||||
});
|
||||
};
|
||||
|
||||
// 견적 항목 변경
|
||||
const handleItemChange = (
|
||||
index: number,
|
||||
field: keyof QuoteItem,
|
||||
value: string | number
|
||||
) => {
|
||||
const newItems = [...formData.items];
|
||||
newItems[index] = { ...newItems[index], [field]: value };
|
||||
|
||||
// 제품 카테고리 변경 시 제품명 초기화
|
||||
if (field === "productCategory") {
|
||||
newItems[index].productName = "";
|
||||
}
|
||||
|
||||
setFormData({ ...formData, items: newItems });
|
||||
|
||||
// 에러 클리어
|
||||
const errorKey = `item-${index}-${field}`;
|
||||
if (errors[errorKey]) {
|
||||
setErrors((prev) => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors[errorKey];
|
||||
return newErrors;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 견적 항목 추가
|
||||
const handleAddItem = () => {
|
||||
const newItems = [...formData.items, createNewItem()];
|
||||
setFormData({ ...formData, items: newItems });
|
||||
setActiveItemIndex(newItems.length - 1);
|
||||
};
|
||||
|
||||
// 견적 항목 복사
|
||||
const handleCopyItem = (index: number) => {
|
||||
const itemToCopy = formData.items[index];
|
||||
const newItem: QuoteItem = {
|
||||
...itemToCopy,
|
||||
id: `item-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
};
|
||||
const newItems = [...formData.items, newItem];
|
||||
setFormData({ ...formData, items: newItems });
|
||||
setActiveItemIndex(newItems.length - 1);
|
||||
toast.success("견적 항목이 복사되었습니다.");
|
||||
};
|
||||
|
||||
// 견적 항목 삭제
|
||||
const handleDeleteItem = (index: number) => {
|
||||
if (formData.items.length === 1) {
|
||||
toast.error("최소 1개의 견적 항목이 필요합니다.");
|
||||
return;
|
||||
}
|
||||
const newItems = formData.items.filter((_, i) => i !== index);
|
||||
setFormData({ ...formData, items: newItems });
|
||||
if (activeItemIndex >= newItems.length) {
|
||||
setActiveItemIndex(newItems.length - 1);
|
||||
}
|
||||
toast.success("견적 항목이 삭제되었습니다.");
|
||||
};
|
||||
|
||||
// 자동 견적 산출
|
||||
const handleAutoCalculate = () => {
|
||||
toast.info(`자동 견적 산출 (${formData.items.length}개 항목) - API 연동 필요`);
|
||||
};
|
||||
|
||||
// 샘플 데이터 생성
|
||||
const handleGenerateSample = () => {
|
||||
toast.info("완벽한 샘플 생성 - API 연동 필요");
|
||||
};
|
||||
|
||||
return (
|
||||
<ResponsiveFormTemplate
|
||||
title={editingQuote ? "견적 수정" : "견적 등록"}
|
||||
description=""
|
||||
icon={FileText}
|
||||
onSave={handleSubmit}
|
||||
onCancel={onBack}
|
||||
saveLabel="저장"
|
||||
cancelLabel="취소"
|
||||
isEditMode={!!editingQuote}
|
||||
saveLoading={isSaving || isLoading}
|
||||
saveDisabled={isSaving || isLoading}
|
||||
maxWidth="2xl"
|
||||
>
|
||||
{/* 1. 기본 정보 */}
|
||||
<FormSection
|
||||
title="기본 정보"
|
||||
description=""
|
||||
icon={FileText}
|
||||
>
|
||||
<FormFieldGrid columns={3}>
|
||||
<FormField label="등록일" htmlFor="registrationDate">
|
||||
<Input
|
||||
id="registrationDate"
|
||||
type="date"
|
||||
value={formData.registrationDate}
|
||||
disabled
|
||||
className="bg-gray-50"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="작성자" htmlFor="writer">
|
||||
<Input
|
||||
id="writer"
|
||||
value={formData.writer}
|
||||
disabled
|
||||
className="bg-gray-50"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="발주처 선택"
|
||||
required
|
||||
error={errors.clientId}
|
||||
htmlFor="clientId"
|
||||
>
|
||||
<Select
|
||||
value={formData.clientId}
|
||||
onValueChange={handleClientChange}
|
||||
>
|
||||
<SelectTrigger id="clientId">
|
||||
<SelectValue placeholder="발주처를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SAMPLE_CLIENTS.map((client) => (
|
||||
<SelectItem key={client.id} value={client.id}>
|
||||
{client.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
</FormFieldGrid>
|
||||
|
||||
<FormFieldGrid columns={3}>
|
||||
<FormField label="현장명" htmlFor="siteName">
|
||||
<Input
|
||||
id="siteName"
|
||||
placeholder="현장명을 입력하세요"
|
||||
value={formData.siteName}
|
||||
onChange={(e) => handleFieldChange("siteName", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="발주 담당자" htmlFor="manager">
|
||||
<Input
|
||||
id="manager"
|
||||
placeholder="담당자명을 입력하세요"
|
||||
value={formData.manager}
|
||||
onChange={(e) => handleFieldChange("manager", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="연락처" htmlFor="contact">
|
||||
<Input
|
||||
id="contact"
|
||||
placeholder="010-1234-5678"
|
||||
value={formData.contact}
|
||||
onChange={(e) => handleFieldChange("contact", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
</FormFieldGrid>
|
||||
|
||||
<FormFieldGrid columns={3}>
|
||||
<FormField label="납기일" htmlFor="dueDate">
|
||||
<Input
|
||||
id="dueDate"
|
||||
type="date"
|
||||
value={formData.dueDate}
|
||||
onChange={(e) => handleFieldChange("dueDate", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="col-span-2" />
|
||||
</FormFieldGrid>
|
||||
|
||||
<FormField label="비고" htmlFor="remarks">
|
||||
<Textarea
|
||||
id="remarks"
|
||||
placeholder="특이사항을 입력하세요"
|
||||
value={formData.remarks}
|
||||
onChange={(e) => handleFieldChange("remarks", e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</FormField>
|
||||
</FormSection>
|
||||
|
||||
{/* 2. 자동 견적 산출 */}
|
||||
<FormSection
|
||||
title="자동 견적 산출"
|
||||
description="입력값을 기반으로 견적을 자동으로 산출합니다"
|
||||
icon={Calculator}
|
||||
>
|
||||
{/* 견적 탭 */}
|
||||
<Card className="border-gray-200">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{formData.items.map((item, index) => (
|
||||
<Button
|
||||
key={item.id}
|
||||
variant={activeItemIndex === index ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setActiveItemIndex(index)}
|
||||
className="min-w-[70px]"
|
||||
>
|
||||
견적 {index + 1}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleCopyItem(activeItemIndex)}
|
||||
title="복사"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDeleteItem(activeItemIndex)}
|
||||
title="삭제"
|
||||
className="text-red-500 hover:text-red-600"
|
||||
disabled={formData.items.length === 1}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{formData.items[activeItemIndex] && (
|
||||
<>
|
||||
<FormFieldGrid columns={3}>
|
||||
<FormField label="층수" htmlFor={`floor-${activeItemIndex}`}>
|
||||
<Input
|
||||
id={`floor-${activeItemIndex}`}
|
||||
placeholder="예: 1층, B1, 지하1층"
|
||||
value={formData.items[activeItemIndex].floor}
|
||||
onChange={(e) =>
|
||||
handleItemChange(activeItemIndex, "floor", e.target.value)
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="부호" htmlFor={`code-${activeItemIndex}`}>
|
||||
<Input
|
||||
id={`code-${activeItemIndex}`}
|
||||
placeholder="예: A, B, C"
|
||||
value={formData.items[activeItemIndex].code}
|
||||
onChange={(e) =>
|
||||
handleItemChange(activeItemIndex, "code", e.target.value)
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="제품 카테고리 (PC)"
|
||||
required
|
||||
error={errors[`item-${activeItemIndex}-productCategory`]}
|
||||
htmlFor={`productCategory-${activeItemIndex}`}
|
||||
>
|
||||
<Select
|
||||
value={formData.items[activeItemIndex].productCategory}
|
||||
onValueChange={(value) =>
|
||||
handleItemChange(activeItemIndex, "productCategory", value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id={`productCategory-${activeItemIndex}`}>
|
||||
<SelectValue placeholder="카테고리 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PRODUCT_CATEGORIES.map((cat) => (
|
||||
<SelectItem key={cat.value} value={cat.value}>
|
||||
{cat.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
</FormFieldGrid>
|
||||
|
||||
<FormFieldGrid columns={3}>
|
||||
<FormField
|
||||
label="제품명"
|
||||
required
|
||||
error={errors[`item-${activeItemIndex}-productName`]}
|
||||
htmlFor={`productName-${activeItemIndex}`}
|
||||
>
|
||||
<Select
|
||||
value={formData.items[activeItemIndex].productName}
|
||||
onValueChange={(value) =>
|
||||
handleItemChange(activeItemIndex, "productName", value)
|
||||
}
|
||||
disabled={!formData.items[activeItemIndex].productCategory}
|
||||
>
|
||||
<SelectTrigger id={`productName-${activeItemIndex}`}>
|
||||
<SelectValue placeholder="제품을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(PRODUCTS[formData.items[activeItemIndex].productCategory] || []).map((product) => (
|
||||
<SelectItem key={product.value} value={product.value}>
|
||||
{product.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="오픈사이즈 (W0)"
|
||||
required
|
||||
error={errors[`item-${activeItemIndex}-openWidth`]}
|
||||
htmlFor={`openWidth-${activeItemIndex}`}
|
||||
>
|
||||
<Input
|
||||
id={`openWidth-${activeItemIndex}`}
|
||||
placeholder="예: 2000"
|
||||
value={formData.items[activeItemIndex].openWidth}
|
||||
onChange={(e) =>
|
||||
handleItemChange(activeItemIndex, "openWidth", e.target.value)
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="오픈사이즈 (H0)"
|
||||
required
|
||||
error={errors[`item-${activeItemIndex}-openHeight`]}
|
||||
htmlFor={`openHeight-${activeItemIndex}`}
|
||||
>
|
||||
<Input
|
||||
id={`openHeight-${activeItemIndex}`}
|
||||
placeholder="예: 2500"
|
||||
value={formData.items[activeItemIndex].openHeight}
|
||||
onChange={(e) =>
|
||||
handleItemChange(activeItemIndex, "openHeight", e.target.value)
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
</FormFieldGrid>
|
||||
|
||||
<FormFieldGrid columns={3}>
|
||||
<FormField
|
||||
label="가이드레일 설치 유형 (GT)"
|
||||
required
|
||||
error={errors[`item-${activeItemIndex}-guideRailType`]}
|
||||
htmlFor={`guideRailType-${activeItemIndex}`}
|
||||
>
|
||||
<Select
|
||||
value={formData.items[activeItemIndex].guideRailType}
|
||||
onValueChange={(value) =>
|
||||
handleItemChange(activeItemIndex, "guideRailType", value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id={`guideRailType-${activeItemIndex}`}>
|
||||
<SelectValue placeholder="설치 유형 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{GUIDE_RAIL_TYPES.map((type) => (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="모터 전원 (MP)"
|
||||
required
|
||||
error={errors[`item-${activeItemIndex}-motorPower`]}
|
||||
htmlFor={`motorPower-${activeItemIndex}`}
|
||||
>
|
||||
<Select
|
||||
value={formData.items[activeItemIndex].motorPower}
|
||||
onValueChange={(value) =>
|
||||
handleItemChange(activeItemIndex, "motorPower", value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id={`motorPower-${activeItemIndex}`}>
|
||||
<SelectValue placeholder="전원 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MOTOR_POWERS.map((power) => (
|
||||
<SelectItem key={power.value} value={power.value}>
|
||||
{power.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="연동제어기 (CT)"
|
||||
required
|
||||
error={errors[`item-${activeItemIndex}-controller`]}
|
||||
htmlFor={`controller-${activeItemIndex}`}
|
||||
>
|
||||
<Select
|
||||
value={formData.items[activeItemIndex].controller}
|
||||
onValueChange={(value) =>
|
||||
handleItemChange(activeItemIndex, "controller", value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id={`controller-${activeItemIndex}`}>
|
||||
<SelectValue placeholder="제어기 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CONTROLLERS.map((ctrl) => (
|
||||
<SelectItem key={ctrl.value} value={ctrl.value}>
|
||||
{ctrl.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
</FormFieldGrid>
|
||||
|
||||
<FormFieldGrid columns={3}>
|
||||
<FormField
|
||||
label="수량 (QTY)"
|
||||
required
|
||||
error={errors[`item-${activeItemIndex}-quantity`]}
|
||||
htmlFor={`quantity-${activeItemIndex}`}
|
||||
>
|
||||
<Input
|
||||
id={`quantity-${activeItemIndex}`}
|
||||
type="number"
|
||||
min="1"
|
||||
value={formData.items[activeItemIndex].quantity}
|
||||
onChange={(e) =>
|
||||
handleItemChange(activeItemIndex, "quantity", parseInt(e.target.value) || 1)
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="마구리 날개치수 (WS)"
|
||||
htmlFor={`wingSize-${activeItemIndex}`}
|
||||
>
|
||||
<Input
|
||||
id={`wingSize-${activeItemIndex}`}
|
||||
placeholder="예: 50"
|
||||
value={formData.items[activeItemIndex].wingSize}
|
||||
onChange={(e) =>
|
||||
handleItemChange(activeItemIndex, "wingSize", e.target.value)
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="검사비 (INSP)"
|
||||
htmlFor={`inspectionFee-${activeItemIndex}`}
|
||||
>
|
||||
<Input
|
||||
id={`inspectionFee-${activeItemIndex}`}
|
||||
type="number"
|
||||
placeholder="예: 50000"
|
||||
value={formData.items[activeItemIndex].inspectionFee}
|
||||
onChange={(e) =>
|
||||
handleItemChange(activeItemIndex, "inspectionFee", parseInt(e.target.value) || 0)
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
</FormFieldGrid>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 견적 추가 버튼 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={handleAddItem}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
견적 추가
|
||||
</Button>
|
||||
|
||||
{/* 자동 견적 산출 버튼 */}
|
||||
<Button
|
||||
variant="default"
|
||||
className="w-full bg-blue-600 hover:bg-blue-700"
|
||||
onClick={handleAutoCalculate}
|
||||
>
|
||||
자동 견적 산출 ({formData.items.length}개 항목)
|
||||
</Button>
|
||||
</FormSection>
|
||||
|
||||
{/* 3. 샘플 데이터 (개발용) */}
|
||||
<Card className="border-blue-200 bg-blue-50/50">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-sm font-medium">
|
||||
견적 산출 샘플 데이터 (완전판)
|
||||
</CardTitle>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
자동 견적 산출 기능을 테스트하기 위한 완벽한 샘플 데이터를 생성합니다.
|
||||
완제품 14종(스크린 5종, 철재 5종, 절곡 4종), 반제품 40종, 부자재 25종, 원자재 20종이
|
||||
생성되며, 모든 제품에 실제 BOM 구조(2~3단계 계층)와 단가 정보가 포함되어
|
||||
즉시 견적 산출이 가능합니다.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleGenerateSample}
|
||||
className="bg-white"
|
||||
>
|
||||
<Sparkles className="h-4 w-4 mr-2" />
|
||||
완벽한 샘플 생성
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="secondary">완제품 14종</Badge>
|
||||
<Badge variant="secondary">반제품 40종</Badge>
|
||||
<Badge variant="secondary">부자재 25종</Badge>
|
||||
<Badge variant="secondary">원자재 20종</Badge>
|
||||
<Badge variant="outline">BOM 2~3단계 계층 ▾</Badge>
|
||||
<Badge variant="outline">단가 정보 포함 ▾</Badge>
|
||||
<Badge variant="outline">절곡 제품 포함 ▾</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</ResponsiveFormTemplate>
|
||||
);
|
||||
}
|
||||
@@ -51,7 +51,7 @@ export interface PaginationConfig {
|
||||
|
||||
export interface StatCard {
|
||||
label: string;
|
||||
value: number;
|
||||
value: string | number;
|
||||
icon: LucideIcon;
|
||||
iconColor: string;
|
||||
}
|
||||
@@ -108,7 +108,7 @@ export interface IntegratedListTemplateV2Props<T = any> {
|
||||
allData?: T[]; // 모바일 인피니티 스크롤용 전체 필터된 데이터
|
||||
mobileDisplayCount?: number; // 모바일에서 표시할 개수
|
||||
onLoadMore?: () => void; // 더 불러오기 콜백
|
||||
infinityScrollSentinelRef?: RefObject<HTMLDivElement>; // 인피니티 스크롤용 sentinel ref
|
||||
infinityScrollSentinelRef?: RefObject<HTMLDivElement | null>; // 인피니티 스크롤용 sentinel ref
|
||||
|
||||
// 체크박스 선택
|
||||
selectedItems: Set<string>;
|
||||
|
||||
96
src/components/templates/ResponsiveFormTemplate.tsx
Normal file
96
src/components/templates/ResponsiveFormTemplate.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* ResponsiveFormTemplate - 통합 등록 페이지 템플릿
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import { LucideIcon } from "lucide-react";
|
||||
import { PageLayout } from "../organisms/PageLayout";
|
||||
import { PageHeader } from "../organisms/PageHeader";
|
||||
import { FormActions } from "../organisms/FormActions";
|
||||
|
||||
// Re-export form components for convenience
|
||||
export { FormSection } from "../organisms/FormSection";
|
||||
export type { FormSectionProps } from "../organisms/FormSection";
|
||||
export { FormField } from "../molecules/FormField";
|
||||
export type { FormFieldProps, FormFieldType, SelectOption } from "../molecules/FormField";
|
||||
export { FormFieldGrid } from "../organisms/FormFieldGrid";
|
||||
export type { FormFieldGridProps } from "../organisms/FormFieldGrid";
|
||||
export { FormActions } from "../organisms/FormActions";
|
||||
export type { FormActionsProps } from "../organisms/FormActions";
|
||||
|
||||
export interface ResponsiveFormTemplateProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
icon?: LucideIcon;
|
||||
headerActions?: ReactNode;
|
||||
isEditMode?: boolean;
|
||||
children: ReactNode;
|
||||
onSave?: () => void;
|
||||
onCancel?: () => void;
|
||||
saveLabel?: string;
|
||||
cancelLabel?: string;
|
||||
saveDisabled?: boolean;
|
||||
saveLoading?: boolean;
|
||||
showActions?: boolean;
|
||||
customActions?: ReactNode;
|
||||
className?: string;
|
||||
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full';
|
||||
versionInfo?: ReactNode;
|
||||
}
|
||||
|
||||
export function ResponsiveFormTemplate({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
headerActions,
|
||||
isEditMode,
|
||||
children,
|
||||
onSave,
|
||||
onCancel,
|
||||
saveLabel = "저장",
|
||||
cancelLabel = "취소",
|
||||
saveDisabled = false,
|
||||
saveLoading = false,
|
||||
showActions = true,
|
||||
customActions,
|
||||
className = "",
|
||||
maxWidth = 'full',
|
||||
versionInfo,
|
||||
}: ResponsiveFormTemplateProps) {
|
||||
|
||||
return (
|
||||
<PageLayout maxWidth={maxWidth} versionInfo={versionInfo}>
|
||||
{/* 헤더 */}
|
||||
<PageHeader
|
||||
title={title}
|
||||
description={description}
|
||||
icon={icon}
|
||||
rightActions={headerActions}
|
||||
isEditMode={isEditMode}
|
||||
/>
|
||||
|
||||
{/* 메인 컨텐츠 */}
|
||||
<div className={`space-y-6 ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* 하단 액션 버튼 */}
|
||||
{showActions && (
|
||||
<div className="sticky bottom-0 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-t pt-4 pb-4 -mx-3 md:-mx-6 px-3 md:px-6 mt-6">
|
||||
{customActions || (
|
||||
<FormActions
|
||||
onSave={onSave}
|
||||
onCancel={onCancel}
|
||||
saveLabel={saveLabel}
|
||||
cancelLabel={cancelLabel}
|
||||
saveDisabled={saveDisabled}
|
||||
saveLoading={saveLoading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
45
src/components/ui/radio-group.tsx
Normal file
45
src/components/ui/radio-group.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
|
||||
import { CircleIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "./utils";
|
||||
|
||||
function RadioGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
data-slot="radio-group"
|
||||
className={cn("grid gap-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function RadioGroupItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
data-slot="radio-group-item"
|
||||
className={cn(
|
||||
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator
|
||||
data-slot="radio-group-indicator"
|
||||
className="relative flex items-center justify-center"
|
||||
>
|
||||
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
export { RadioGroup, RadioGroupItem };
|
||||
366
src/hooks/useClientGroupList.ts
Normal file
366
src/hooks/useClientGroupList.ts
Normal file
@@ -0,0 +1,366 @@
|
||||
/**
|
||||
* 거래처 그룹(ClientGroup) API 훅
|
||||
*
|
||||
* 백엔드 API: /api/v1/client-groups
|
||||
* - GET /client-groups - 목록 조회
|
||||
* - GET /client-groups/{id} - 단건 조회
|
||||
* - POST /client-groups - 생성
|
||||
* - PUT /client-groups/{id} - 수정
|
||||
* - DELETE /client-groups/{id} - 삭제
|
||||
* - PATCH /client-groups/{id}/toggle - 활성/비활성 토글
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
// 백엔드 API 응답 타입
|
||||
export interface ClientGroupApiResponse {
|
||||
id: number;
|
||||
tenant_id: number;
|
||||
group_code: string;
|
||||
group_name: string;
|
||||
price_rate: string | number; // decimal(10,4)
|
||||
is_active: boolean | number; // 0 or 1
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
created_by: number | null;
|
||||
updated_by: number | null;
|
||||
}
|
||||
|
||||
// 프론트엔드 타입
|
||||
export interface ClientGroup {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
priceRate: number;
|
||||
status: '활성' | '비활성';
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// 폼 데이터 타입
|
||||
export interface ClientGroupFormData {
|
||||
groupCode: string;
|
||||
groupName: string;
|
||||
priceRate: number;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
// 페이지네이션 정보
|
||||
export interface PaginationInfo {
|
||||
currentPage: number;
|
||||
lastPage: number;
|
||||
perPage: number;
|
||||
total: number;
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
|
||||
// 훅 반환 타입
|
||||
export interface UseClientGroupListReturn {
|
||||
groups: ClientGroup[];
|
||||
pagination: PaginationInfo | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
fetchGroups: (params?: FetchGroupsParams) => Promise<void>;
|
||||
fetchGroup: (id: string) => Promise<ClientGroup | null>;
|
||||
createGroup: (data: ClientGroupFormData) => Promise<ClientGroup>;
|
||||
updateGroup: (id: string, data: ClientGroupFormData) => Promise<ClientGroup>;
|
||||
deleteGroup: (id: string) => Promise<void>;
|
||||
toggleGroupStatus: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
// API 요청 파라미터
|
||||
interface FetchGroupsParams {
|
||||
page?: number;
|
||||
size?: number;
|
||||
q?: string;
|
||||
onlyActive?: boolean;
|
||||
}
|
||||
|
||||
// API 응답 → 프론트엔드 타입 변환
|
||||
function transformGroupFromApi(apiGroup: ClientGroupApiResponse): ClientGroup {
|
||||
// is_active가 boolean 또는 number(0/1)일 수 있음
|
||||
const isActive = apiGroup.is_active === true || apiGroup.is_active === 1;
|
||||
|
||||
return {
|
||||
id: String(apiGroup.id),
|
||||
code: apiGroup.group_code || '',
|
||||
name: apiGroup.group_name || '',
|
||||
priceRate: Number(apiGroup.price_rate) || 0,
|
||||
status: isActive ? '활성' : '비활성',
|
||||
createdAt: apiGroup.created_at || '',
|
||||
updatedAt: apiGroup.updated_at || '',
|
||||
};
|
||||
}
|
||||
|
||||
// 프론트엔드 타입 → API 요청 변환 (생성용)
|
||||
function transformGroupToApiCreate(data: ClientGroupFormData): Record<string, unknown> {
|
||||
return {
|
||||
group_code: data.groupCode,
|
||||
group_name: data.groupName,
|
||||
price_rate: data.priceRate,
|
||||
is_active: data.isActive !== false, // 기본값 true
|
||||
};
|
||||
}
|
||||
|
||||
// 프론트엔드 타입 → API 요청 변환 (수정용)
|
||||
function transformGroupToApiUpdate(data: ClientGroupFormData): Record<string, unknown> {
|
||||
return {
|
||||
group_code: data.groupCode,
|
||||
group_name: data.groupName,
|
||||
price_rate: data.priceRate,
|
||||
is_active: data.isActive,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 거래처 그룹 관리 훅
|
||||
*/
|
||||
export function useClientGroupList(): UseClientGroupListReturn {
|
||||
const [groups, setGroups] = useState<ClientGroup[]>([]);
|
||||
const [pagination, setPagination] = useState<PaginationInfo | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
/**
|
||||
* 거래처 그룹 목록 조회
|
||||
*/
|
||||
const fetchGroups = useCallback(async (params?: FetchGroupsParams) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.page) searchParams.set('page', String(params.page));
|
||||
if (params?.size) searchParams.set('size', String(params.size));
|
||||
if (params?.q) searchParams.set('q', params.q);
|
||||
if (params?.onlyActive !== undefined) {
|
||||
searchParams.set('only_active', String(params.onlyActive));
|
||||
}
|
||||
|
||||
const queryString = searchParams.toString();
|
||||
const url = `/api/proxy/client-groups${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`거래처 그룹 목록 조회 실패: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// Laravel paginate 응답 구조: { success, data: { current_page, data: [...], last_page, ... } }
|
||||
if (result.success && result.data) {
|
||||
const paginatedData = result.data;
|
||||
const items: ClientGroupApiResponse[] = paginatedData.data || [];
|
||||
const transformedGroups = items.map(transformGroupFromApi);
|
||||
setGroups(transformedGroups);
|
||||
|
||||
setPagination({
|
||||
currentPage: paginatedData.current_page || 1,
|
||||
lastPage: paginatedData.last_page || 1,
|
||||
perPage: paginatedData.per_page || 20,
|
||||
total: paginatedData.total || 0,
|
||||
from: paginatedData.from || 0,
|
||||
to: paginatedData.to || 0,
|
||||
});
|
||||
} else if (Array.isArray(result)) {
|
||||
// 단순 배열 응답 (페이지네이션 없음)
|
||||
const transformedGroups = result.map(transformGroupFromApi);
|
||||
setGroups(transformedGroups);
|
||||
setPagination(null);
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : '목록 조회 중 오류가 발생했습니다';
|
||||
setError(errorMessage);
|
||||
console.error('fetchGroups error:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 거래처 그룹 단건 조회
|
||||
*/
|
||||
const fetchGroup = useCallback(async (id: string): Promise<ClientGroup | null> => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/proxy/client-groups/${id}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`거래처 그룹 조회 실패: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
const data = result.data || result;
|
||||
|
||||
return transformGroupFromApi(data);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : '조회 중 오류가 발생했습니다';
|
||||
setError(errorMessage);
|
||||
console.error('fetchGroup error:', err);
|
||||
return null;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 거래처 그룹 생성
|
||||
*/
|
||||
const createGroup = useCallback(async (data: ClientGroupFormData): Promise<ClientGroup> => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const apiData = transformGroupToApiCreate(data);
|
||||
|
||||
const response = await fetch('/api/proxy/client-groups', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(apiData),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || `거래처 그룹 생성 실패: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
const resultData = result.data || result;
|
||||
|
||||
return transformGroupFromApi(resultData);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : '생성 중 오류가 발생했습니다';
|
||||
setError(errorMessage);
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 거래처 그룹 수정
|
||||
*/
|
||||
const updateGroup = useCallback(async (id: string, data: ClientGroupFormData): Promise<ClientGroup> => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const apiData = transformGroupToApiUpdate(data);
|
||||
|
||||
const response = await fetch(`/api/proxy/client-groups/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(apiData),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || `거래처 그룹 수정 실패: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
const resultData = result.data || result;
|
||||
|
||||
return transformGroupFromApi(resultData);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : '수정 중 오류가 발생했습니다';
|
||||
setError(errorMessage);
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 거래처 그룹 삭제
|
||||
*/
|
||||
const deleteGroup = useCallback(async (id: string): Promise<void> => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/proxy/client-groups/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || `거래처 그룹 삭제 실패: ${response.status}`);
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : '삭제 중 오류가 발생했습니다';
|
||||
setError(errorMessage);
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 거래처 그룹 활성/비활성 토글
|
||||
*/
|
||||
const toggleGroupStatus = useCallback(async (id: string): Promise<void> => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/proxy/client-groups/${id}/toggle`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || `상태 변경 실패: ${response.status}`);
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : '상태 변경 중 오류가 발생했습니다';
|
||||
setError(errorMessage);
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
groups,
|
||||
pagination,
|
||||
isLoading,
|
||||
error,
|
||||
fetchGroups,
|
||||
fetchGroup,
|
||||
createGroup,
|
||||
updateGroup,
|
||||
deleteGroup,
|
||||
toggleGroupStatus,
|
||||
};
|
||||
}
|
||||
530
src/hooks/useClientList.ts
Normal file
530
src/hooks/useClientList.ts
Normal file
@@ -0,0 +1,530 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
|
||||
// ============================================
|
||||
// 타입 정의
|
||||
// ============================================
|
||||
|
||||
// 거래처 유형
|
||||
export type ClientType = "매입" | "매출" | "매입매출";
|
||||
|
||||
// 악성채권 진행상태
|
||||
export type BadDebtProgress = "협의중" | "소송중" | "회수완료" | "대손처리" | "";
|
||||
|
||||
// 백엔드 API 응답 타입 (확장)
|
||||
export interface ClientApiResponse {
|
||||
id: number;
|
||||
tenant_id: number;
|
||||
client_group_id: number | null;
|
||||
client_code: string;
|
||||
name: string;
|
||||
contact_person: string | null;
|
||||
phone: string | null;
|
||||
email: string | null;
|
||||
address: string | null;
|
||||
business_no: string | null;
|
||||
business_type: string | null;
|
||||
business_item: string | null;
|
||||
is_active: "Y" | "N";
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
// 2차 추가 필드 (백엔드 완료 시 활성화)
|
||||
client_type?: ClientType;
|
||||
mobile?: string | null;
|
||||
fax?: string | null;
|
||||
manager_name?: string | null;
|
||||
manager_tel?: string | null;
|
||||
system_manager?: string | null;
|
||||
account_id?: string | null;
|
||||
account_password?: string | null;
|
||||
purchase_payment_day?: string | null;
|
||||
sales_payment_day?: string | null;
|
||||
tax_agreement?: boolean;
|
||||
tax_amount?: number | null;
|
||||
tax_start_date?: string | null;
|
||||
tax_end_date?: string | null;
|
||||
bad_debt?: boolean;
|
||||
bad_debt_amount?: number | null;
|
||||
bad_debt_receive_date?: string | null;
|
||||
bad_debt_end_date?: string | null;
|
||||
bad_debt_progress?: BadDebtProgress;
|
||||
memo?: string | null;
|
||||
}
|
||||
|
||||
// 프론트엔드 타입 (확장)
|
||||
export interface Client {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
businessNo: string;
|
||||
representative: string; // contact_person
|
||||
phone: string;
|
||||
address: string;
|
||||
email: string;
|
||||
businessType: string;
|
||||
businessItem: string;
|
||||
registeredDate: string;
|
||||
status: "활성" | "비활성";
|
||||
groupId: string | null;
|
||||
groupName?: string;
|
||||
// 2차 추가 필드
|
||||
clientType: ClientType;
|
||||
mobile: string;
|
||||
fax: string;
|
||||
managerName: string;
|
||||
managerTel: string;
|
||||
systemManager: string;
|
||||
accountId: string;
|
||||
accountPassword: string;
|
||||
purchasePaymentDay: string;
|
||||
salesPaymentDay: string;
|
||||
taxAgreement: boolean;
|
||||
taxAmount: string;
|
||||
taxStartDate: string;
|
||||
taxEndDate: string;
|
||||
badDebt: boolean;
|
||||
badDebtAmount: string;
|
||||
badDebtReceiveDate: string;
|
||||
badDebtEndDate: string;
|
||||
badDebtProgress: BadDebtProgress;
|
||||
memo: string;
|
||||
}
|
||||
|
||||
// 페이지네이션 정보
|
||||
export interface PaginationInfo {
|
||||
currentPage: number;
|
||||
lastPage: number;
|
||||
total: number;
|
||||
perPage: number;
|
||||
}
|
||||
|
||||
// 검색 파라미터
|
||||
export interface ClientSearchParams {
|
||||
page?: number;
|
||||
size?: number;
|
||||
q?: string;
|
||||
onlyActive?: boolean;
|
||||
}
|
||||
|
||||
// 생성/수정 요청 타입 (확장)
|
||||
export interface ClientFormData {
|
||||
clientCode?: string;
|
||||
name: string;
|
||||
businessNo: string;
|
||||
representative: string;
|
||||
phone: string;
|
||||
address: string;
|
||||
email: string;
|
||||
businessType: string;
|
||||
businessItem: string;
|
||||
groupId?: string | null;
|
||||
isActive: boolean;
|
||||
// 2차 추가 필드
|
||||
clientType: ClientType;
|
||||
mobile: string;
|
||||
fax: string;
|
||||
managerName: string;
|
||||
managerTel: string;
|
||||
systemManager: string;
|
||||
accountId: string;
|
||||
accountPassword: string;
|
||||
purchasePaymentDay: string;
|
||||
salesPaymentDay: string;
|
||||
taxAgreement: boolean;
|
||||
taxAmount: string;
|
||||
taxStartDate: string;
|
||||
taxEndDate: string;
|
||||
badDebt: boolean;
|
||||
badDebtAmount: string;
|
||||
badDebtReceiveDate: string;
|
||||
badDebtEndDate: string;
|
||||
badDebtProgress: BadDebtProgress;
|
||||
memo: string;
|
||||
}
|
||||
|
||||
// 폼 초기값
|
||||
export const INITIAL_CLIENT_FORM: ClientFormData = {
|
||||
name: "",
|
||||
businessNo: "",
|
||||
representative: "",
|
||||
phone: "",
|
||||
address: "",
|
||||
email: "",
|
||||
businessType: "",
|
||||
businessItem: "",
|
||||
groupId: null,
|
||||
isActive: true,
|
||||
clientType: "매입",
|
||||
mobile: "",
|
||||
fax: "",
|
||||
managerName: "",
|
||||
managerTel: "",
|
||||
systemManager: "",
|
||||
accountId: "",
|
||||
accountPassword: "",
|
||||
purchasePaymentDay: "말일",
|
||||
salesPaymentDay: "말일",
|
||||
taxAgreement: false,
|
||||
taxAmount: "",
|
||||
taxStartDate: "",
|
||||
taxEndDate: "",
|
||||
badDebt: false,
|
||||
badDebtAmount: "",
|
||||
badDebtReceiveDate: "",
|
||||
badDebtEndDate: "",
|
||||
badDebtProgress: "",
|
||||
memo: "",
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// 데이터 변환 유틸리티
|
||||
// ============================================
|
||||
|
||||
// API 응답 → 프론트엔드 타입 변환
|
||||
export function transformClientFromApi(api: ClientApiResponse): Client {
|
||||
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: api.business_no || "",
|
||||
businessType: api.business_type || "",
|
||||
businessItem: api.business_item || "",
|
||||
registeredDate: api.created_at ? api.created_at.split(" ")[0] : "",
|
||||
status: api.is_active === "Y" ? "활성" : "비활성",
|
||||
groupId: api.client_group_id ? String(api.client_group_id) : null,
|
||||
// 2차 추가 필드
|
||||
clientType: api.client_type || "매입",
|
||||
mobile: api.mobile || "",
|
||||
fax: api.fax || "",
|
||||
managerName: api.manager_name || "",
|
||||
managerTel: api.manager_tel || "",
|
||||
systemManager: api.system_manager || "",
|
||||
accountId: api.account_id || "",
|
||||
accountPassword: "", // 비밀번호는 조회 시 비움
|
||||
purchasePaymentDay: api.purchase_payment_day || "말일",
|
||||
salesPaymentDay: api.sales_payment_day || "말일",
|
||||
taxAgreement: api.tax_agreement || false,
|
||||
taxAmount: api.tax_amount ? String(api.tax_amount) : "",
|
||||
taxStartDate: api.tax_start_date || "",
|
||||
taxEndDate: api.tax_end_date || "",
|
||||
badDebt: api.bad_debt || false,
|
||||
badDebtAmount: api.bad_debt_amount ? String(api.bad_debt_amount) : "",
|
||||
badDebtReceiveDate: api.bad_debt_receive_date || "",
|
||||
badDebtEndDate: api.bad_debt_end_date || "",
|
||||
badDebtProgress: api.bad_debt_progress || "",
|
||||
memo: api.memo || "",
|
||||
};
|
||||
}
|
||||
|
||||
// 프론트엔드 → API 요청 변환 (생성)
|
||||
export function transformClientToApiCreate(form: ClientFormData): Record<string, unknown> {
|
||||
return {
|
||||
client_code: form.clientCode,
|
||||
name: form.name,
|
||||
contact_person: form.representative || null,
|
||||
phone: form.phone || null,
|
||||
email: form.email || null,
|
||||
address: form.address || null,
|
||||
business_no: form.businessNo || null,
|
||||
business_type: form.businessType || null,
|
||||
business_item: form.businessItem || null,
|
||||
client_group_id: form.groupId ? Number(form.groupId) : null,
|
||||
is_active: form.isActive ? "Y" : "N",
|
||||
// 2차 추가 필드
|
||||
client_type: form.clientType,
|
||||
mobile: form.mobile || null,
|
||||
fax: form.fax || null,
|
||||
manager_name: form.managerName || null,
|
||||
manager_tel: form.managerTel || null,
|
||||
system_manager: form.systemManager || null,
|
||||
account_id: form.accountId || null,
|
||||
account_password: form.accountPassword || null,
|
||||
purchase_payment_day: form.purchasePaymentDay || null,
|
||||
sales_payment_day: form.salesPaymentDay || null,
|
||||
tax_agreement: form.taxAgreement,
|
||||
tax_amount: form.taxAmount ? Number(form.taxAmount) : null,
|
||||
tax_start_date: form.taxStartDate || null,
|
||||
tax_end_date: form.taxEndDate || null,
|
||||
bad_debt: form.badDebt,
|
||||
bad_debt_amount: form.badDebtAmount ? Number(form.badDebtAmount) : null,
|
||||
bad_debt_receive_date: form.badDebtReceiveDate || null,
|
||||
bad_debt_end_date: form.badDebtEndDate || null,
|
||||
bad_debt_progress: form.badDebtProgress || null,
|
||||
memo: form.memo || null,
|
||||
};
|
||||
}
|
||||
|
||||
// 프론트엔드 → API 요청 변환 (수정)
|
||||
export function transformClientToApiUpdate(form: ClientFormData): Record<string, unknown> {
|
||||
const data: Record<string, unknown> = {
|
||||
name: form.name,
|
||||
contact_person: form.representative || null,
|
||||
phone: form.phone || null,
|
||||
email: form.email || null,
|
||||
address: form.address || null,
|
||||
business_no: form.businessNo || null,
|
||||
business_type: form.businessType || null,
|
||||
business_item: form.businessItem || null,
|
||||
client_group_id: form.groupId ? Number(form.groupId) : null,
|
||||
is_active: form.isActive ? "Y" : "N",
|
||||
// 2차 추가 필드
|
||||
client_type: form.clientType,
|
||||
mobile: form.mobile || null,
|
||||
fax: form.fax || null,
|
||||
manager_name: form.managerName || null,
|
||||
manager_tel: form.managerTel || null,
|
||||
system_manager: form.systemManager || null,
|
||||
account_id: form.accountId || null,
|
||||
purchase_payment_day: form.purchasePaymentDay || null,
|
||||
sales_payment_day: form.salesPaymentDay || null,
|
||||
tax_agreement: form.taxAgreement,
|
||||
tax_amount: form.taxAmount ? Number(form.taxAmount) : null,
|
||||
tax_start_date: form.taxStartDate || null,
|
||||
tax_end_date: form.taxEndDate || null,
|
||||
bad_debt: form.badDebt,
|
||||
bad_debt_amount: form.badDebtAmount ? Number(form.badDebtAmount) : null,
|
||||
bad_debt_receive_date: form.badDebtReceiveDate || null,
|
||||
bad_debt_end_date: form.badDebtEndDate || null,
|
||||
bad_debt_progress: form.badDebtProgress || null,
|
||||
memo: form.memo || null,
|
||||
};
|
||||
|
||||
// 비밀번호는 입력한 경우에만 전송
|
||||
if (form.accountPassword) {
|
||||
data.account_password = form.accountPassword;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
// Client → ClientFormData 변환 (수정 시 폼 초기화용)
|
||||
export function clientToFormData(client: Client): ClientFormData {
|
||||
return {
|
||||
clientCode: client.code,
|
||||
name: client.name,
|
||||
businessNo: client.businessNo,
|
||||
representative: client.representative,
|
||||
phone: client.phone,
|
||||
address: client.address,
|
||||
email: client.email,
|
||||
businessType: client.businessType,
|
||||
businessItem: client.businessItem,
|
||||
groupId: client.groupId,
|
||||
isActive: client.status === "활성",
|
||||
clientType: client.clientType,
|
||||
mobile: client.mobile,
|
||||
fax: client.fax,
|
||||
managerName: client.managerName,
|
||||
managerTel: client.managerTel,
|
||||
systemManager: client.systemManager,
|
||||
accountId: client.accountId,
|
||||
accountPassword: "", // 비밀번호는 비움
|
||||
purchasePaymentDay: client.purchasePaymentDay,
|
||||
salesPaymentDay: client.salesPaymentDay,
|
||||
taxAgreement: client.taxAgreement,
|
||||
taxAmount: client.taxAmount,
|
||||
taxStartDate: client.taxStartDate,
|
||||
taxEndDate: client.taxEndDate,
|
||||
badDebt: client.badDebt,
|
||||
badDebtAmount: client.badDebtAmount,
|
||||
badDebtReceiveDate: client.badDebtReceiveDate,
|
||||
badDebtEndDate: client.badDebtEndDate,
|
||||
badDebtProgress: client.badDebtProgress,
|
||||
memo: client.memo,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// useClientList 훅
|
||||
// ============================================
|
||||
|
||||
export function useClientList() {
|
||||
const [clients, setClients] = useState<Client[]>([]);
|
||||
const [pagination, setPagination] = useState<PaginationInfo | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 목록 조회
|
||||
const fetchClients = useCallback(async (params: ClientSearchParams = {}) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params.page) searchParams.set("page", String(params.page));
|
||||
if (params.size) searchParams.set("size", String(params.size));
|
||||
if (params.q) searchParams.set("q", params.q);
|
||||
if (params.onlyActive !== undefined) {
|
||||
searchParams.set("only_active", params.onlyActive ? "1" : "0");
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/proxy/clients?${searchParams.toString()}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API 오류: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && result.data) {
|
||||
const apiClients: ClientApiResponse[] = result.data.data || [];
|
||||
const transformedClients = apiClients.map(transformClientFromApi);
|
||||
|
||||
setClients(transformedClients);
|
||||
setPagination({
|
||||
currentPage: result.data.current_page || 1,
|
||||
lastPage: result.data.last_page || 1,
|
||||
total: result.data.total || 0,
|
||||
perPage: result.data.per_page || 20,
|
||||
});
|
||||
} else {
|
||||
throw new Error(result.message || "데이터 조회 실패");
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "알 수 없는 오류";
|
||||
setError(errorMessage);
|
||||
setClients([]);
|
||||
setPagination(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 단건 조회
|
||||
const fetchClient = useCallback(async (id: string): Promise<Client | null> => {
|
||||
try {
|
||||
const response = await fetch(`/api/proxy/clients/${id}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API 오류: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && result.data) {
|
||||
return transformClientFromApi(result.data);
|
||||
}
|
||||
return null;
|
||||
} catch (err) {
|
||||
console.error("거래처 조회 실패:", err);
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 생성
|
||||
const createClient = useCallback(async (formData: ClientFormData): Promise<Client | null> => {
|
||||
try {
|
||||
const response = await fetch("/api/proxy/clients", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(transformClientToApiCreate(formData)),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || `API 오류: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && result.data) {
|
||||
return transformClientFromApi(result.data);
|
||||
}
|
||||
return null;
|
||||
} catch (err) {
|
||||
console.error("거래처 생성 실패:", err);
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 수정
|
||||
const updateClient = useCallback(async (id: string, formData: ClientFormData): Promise<Client | null> => {
|
||||
try {
|
||||
const response = await fetch(`/api/proxy/clients/${id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(transformClientToApiUpdate(formData)),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || `API 오류: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && result.data) {
|
||||
return transformClientFromApi(result.data);
|
||||
}
|
||||
return null;
|
||||
} catch (err) {
|
||||
console.error("거래처 수정 실패:", err);
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 삭제
|
||||
const deleteClient = useCallback(async (id: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(`/api/proxy/clients/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || `API 오류: ${response.status}`);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error("거래처 삭제 실패:", err);
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 활성/비활성 토글
|
||||
const toggleClientStatus = useCallback(async (id: string): Promise<Client | null> => {
|
||||
try {
|
||||
const response = await fetch(`/api/proxy/clients/${id}/toggle`, {
|
||||
method: "PATCH",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || `API 오류: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && result.data) {
|
||||
return transformClientFromApi(result.data);
|
||||
}
|
||||
return null;
|
||||
} catch (err) {
|
||||
console.error("거래처 상태 변경 실패:", err);
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// 상태
|
||||
clients,
|
||||
pagination,
|
||||
isLoading,
|
||||
error,
|
||||
// 액션
|
||||
fetchClients,
|
||||
fetchClient,
|
||||
createClient,
|
||||
updateClient,
|
||||
deleteClient,
|
||||
toggleClientStatus,
|
||||
// 유틸리티
|
||||
setClients,
|
||||
};
|
||||
}
|
||||
@@ -311,7 +311,7 @@ export default function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
{/* 데스크톱 사이드바 (모바일에서 숨김) */}
|
||||
<div
|
||||
className={`sticky top-[106px] self-start h-[calc(100vh-118px)] mt-3 border-none bg-transparent hidden md:block transition-all duration-300 flex-shrink-0 ${
|
||||
sidebarCollapsed ? 'w-24' : 'w-80'
|
||||
sidebarCollapsed ? 'w-24' : 'w-64'
|
||||
}`}
|
||||
>
|
||||
<Sidebar
|
||||
|
||||
@@ -41,9 +41,9 @@ export const AUTH_CONFIG = {
|
||||
],
|
||||
|
||||
// 게스트 전용 라우트 (로그인 후 접근 불가)
|
||||
// 2025-12-04: MVP에서 /signup 제거 (운영 페이지로 이동 예정)
|
||||
guestOnlyRoutes: [
|
||||
'/login',
|
||||
'/signup',
|
||||
'/forgot-password',
|
||||
],
|
||||
|
||||
|
||||
@@ -239,6 +239,13 @@ export function middleware(request: NextRequest) {
|
||||
// 4️⃣ 인증 체크
|
||||
const { isAuthenticated, authMode } = checkAuthentication(request);
|
||||
|
||||
// 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));
|
||||
}
|
||||
|
||||
// 5️⃣ 게스트 전용 라우트 (로그인/회원가입)
|
||||
if (isGuestOnlyRoute(pathnameWithoutLocale)) {
|
||||
// 이미 로그인한 경우 대시보드로
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user