diff --git a/claudedocs/_index.md b/claudedocs/_index.md index eb130a3b..6ea36828 100644 --- a/claudedocs/_index.md +++ b/claudedocs/_index.md @@ -1,6 +1,6 @@ # claudedocs 문서 맵 -> 프로젝트 기술 문서 인덱스 (Last Updated: 2025-12-01) +> 프로젝트 기술 문서 인덱스 (Last Updated: 2025-12-02) ## 폴더 구조 @@ -9,6 +9,7 @@ claudedocs/ ├── _index.md # 이 파일 - 문서 맵 ├── auth/ # 🔐 인증 & 토큰 관리 ├── item-master/ # 📦 품목기준관리 +├── sales/ # 💰 판매관리 (견적/거래처) ├── dashboard/ # 📊 대시보드 & 사이드바 ├── api/ # 🔌 API 통합 ├── guides/ # 📚 범용 가이드 @@ -39,8 +40,10 @@ claudedocs/ | 파일 | 설명 | |------|------| -| `[PLAN-2025-12-01] service-layer-refactoring.md` | 📋 **검토 대기** - 서비스 레이어 리팩토링 계획 (도메인 로직 중앙화) | -| `[PLAN-2025-11-28] dynamic-item-form-implementation.md` | ✅ **Phase 1-6 완료** - 품목관리 동적 렌더링 구현 (타입, 훅, 필드, 렌더러, 메인폼, Feature Flag) | +| `[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` | ⚠️ **롤백됨** - 이전 구현 계획 (참조용) | +| `[IMPL-2025-12-02] dynamic-item-form-rebuild.md` | 🔄 **진행중** - 품목관리 동적 페이지 재구현 (디자인 100% 동일 유지) | | `[API-REQUEST-2025-11-28] dynamic-page-rendering-api.md` | ⭐ **v3.1** - 동적 페이지 렌더링 API 요청서 (ID 기반 통일) | | `[PLAN-2025-11-27] item-form-component-separation.md` | ✅ **완료** - ItemForm 컴포넌트 분리 (1607→415줄, 74% 감소) | | `[IMPL-2025-11-27] realtime-sync-fixes.md` | 실시간 동기화 수정 (BOM, 섹션 복제, 항목 수정, **페이지 삭제 시 섹션 동기화** 2025-11-28) | @@ -56,6 +59,14 @@ claudedocs/ --- +## 💰 sales/ - 판매관리 (견적/거래처) + +| 파일 | 설명 | +|------|------| +| `[PLAN-2025-12-02] sales-pages-migration.md` | 📋 **신규** - 견적관리/거래처관리 마이그레이션 계획 | + +--- + ## 📊 dashboard/ - 대시보드 & 사이드바 | 파일 | 설명 | diff --git a/claudedocs/item-master/[IMPL-2025-12-02] dynamic-item-form-fixes.md b/claudedocs/item-master/[IMPL-2025-12-02] dynamic-item-form-fixes.md new file mode 100644 index 00000000..1cd14680 --- /dev/null +++ b/claudedocs/item-master/[IMPL-2025-12-02] dynamic-item-form-fixes.md @@ -0,0 +1,110 @@ +# DynamicItemForm 수정 내역 (2025-12-02) + +## 완료된 작업 + +### 1. 디자인 수정 (2카드 → 1카드) +- **파일**: `src/components/items/DynamicItemForm/index.tsx` +- **내용**: 첫 번째 섹션을 "기본 정보" 카드에 통합 +- **결과**: 목업과 동일한 단일 카드 레이아웃 + +### 2. 필드 순서 정렬 +- **순서**: 품목유형 → 품목명 → 규격(사양) → 품목코드(자동생성) → 단위 → 비고 +- **방법**: `item_name`, `specification` 필드 우선 렌더링 후 품목코드, 나머지 필드 순서 + +### 3. 단위 드롭다운 field_key 매칭 개선 +- **파일**: `src/components/items/DynamicItemForm/fields/DropdownField.tsx` +- **수정**: 한글 '단위' 포함 매칭 추가 +```typescript +const isUnitField = + fieldKey.toLowerCase().includes('unit') || + fieldKey.includes('단위') || + field.field_name.includes('단위') || + field.field_name.toLowerCase().includes('unit'); +``` + +### 4. aria-hidden 충돌 해결 +- **파일**: `src/components/ui/sheet.tsx`, `src/components/ui/select.tsx` +- **수정**: `modal={false}` 추가 +- **이유**: Sheet(사이드바)와 Select 드롭다운 간 aria-hidden 충돌 방지 + +### 5. 필드명 매핑 (프론트 → 백엔드) +- **파일**: `src/components/items/DynamicItemForm/index.tsx` +- **매핑 테이블**: +```typescript +const fieldKeyToBackendKey: Record = { + 'item_name': 'name', + 'specification': 'spec', + 'unit': 'unit', + 'note': 'note', + 'description': 'description', +}; +``` +- **추가 매핑**: + - `item_type` → `product_type` + - `item_code` → `code` + +--- + +## 발견된 백엔드 이슈 (미해결) + +### 1. unitOptions 빈 배열 +- **현상**: init API 응답에 `unitOptions: []` 빈 배열 +- **영향**: 단위 드롭다운에 옵션이 없음 +- **해결 필요**: + - 품목기준관리 속성 탭의 단위 데이터를 DB에 저장 + - init API에서 `item_unit_options` 테이블 조회 후 반환 + +### 2. category_id 필수 필드 +- **현상**: `Field 'category_id' doesn't have a default value` 에러 +- **해결 필요**: + - `products` 테이블의 `category_id` nullable 또는 기본값 설정 + - 또는 API에서 기본 category_id 처리 + +### 3. GET /items API 품목 조회 안됨 +- **현상**: 품목 저장 후 목록에서 안 보임 +- **응답**: `"data": [], "total": 0` +- **확인 필요**: + - POST `/items` → `products` 테이블 저장 + - GET `/items` → 어떤 테이블 조회하는지? + - tenant_id 조건 확인 + +--- + +## 수정된 파일 목록 + +``` +src/components/items/DynamicItemForm/index.tsx +src/components/items/DynamicItemForm/fields/DropdownField.tsx +src/components/items/DynamicItemForm/hooks/useFormStructure.ts +src/components/ui/sheet.tsx +src/components/ui/select.tsx +``` + +--- + +## 다음 세션에서 할 일 + +1. [ ] 백엔드 이슈 해결 확인 (unitOptions, category_id, GET /items) +2. [ ] 단위 드롭다운 정상 동작 테스트 +3. [ ] 품목 저장 후 목록에서 조회 테스트 +4. [ ] 다른 품목 유형(FG, PT, SM, RM) 테스트 +5. [ ] 디버그 console.log 제거 + +--- + +## 백엔드 요청 사항 정리 + +```markdown +### 1. unitOptions 반환 +📍 API: GET /api/v1/item-master/init +📍 요청: unitOptions에 item_unit_options 테이블 데이터 포함 + +### 2. category_id 기본값 +📍 테이블: products +📍 요청: category_id NULL 허용 또는 기본값 설정 + +### 3. GET /items 품목 조회 +📍 API: GET /api/v1/items +📍 문제: 저장한 품목이 조회되지 않음 +📍 확인: products 테이블 조회하는지, tenant_id 조건 확인 +``` \ No newline at end of file diff --git a/claudedocs/item-master/[IMPL-2025-12-02] dynamic-item-form-rebuild.md b/claudedocs/item-master/[IMPL-2025-12-02] dynamic-item-form-rebuild.md new file mode 100644 index 00000000..585975ba --- /dev/null +++ b/claudedocs/item-master/[IMPL-2025-12-02] dynamic-item-form-rebuild.md @@ -0,0 +1,223 @@ +# 품목 등록 동적 페이지 재구현 + +## 작업 일자: 2025-12-02 + +## 문서 버전 +| 버전 | 날짜 | 작성자 | 내용 | +|------|------|--------|------| +| 1.0 | 2025-12-02 | Claude | 롤백 후 재구현 시작 | +| 1.1 | 2025-12-02 | Claude | Phase 1-8 완료 | + +--- + +## 배경 + +- 이전 작업에서 DynamicItemForm 구현이 디자인 누락으로 롤백됨 +- 기존 ItemForm (목업 데이터 기반)의 디자인을 100% 유지하면서 동적 렌더링 구현 필요 + +--- + +## 핵심 원칙 + +1. **디자인 100% 동일**: 기존 ItemForm과 완벽히 동일한 UI/UX +2. **밸리데이션 동일**: 에러 메시지 위치, 스타일 동일 +3. **버튼/아이콘 동일**: 모든 버튼, 아이콘 위치 및 스타일 동일 +4. **화면 등장 순서 동일**: 섹션 표시 순서, 조건부 렌더링 동일 +5. **동적 구성**: 품목기준관리 API 기반으로 필드/섹션 동적 생성 + +--- + +## 체크리스트 + +### Phase 1: 현재 상태 분석 +- [x] 기존 ItemForm 디자인 완전 분석 + - [x] 품목 유형 선택 UI + - [x] 기본 정보 섹션 레이아웃 + - [x] BOM 섹션 레이아웃 + - [x] 전개도 섹션 레이아웃 + - [x] 버튼 위치 및 스타일 + - [x] 밸리데이션 에러 표시 방식 +- [x] 품목관리 API 응답 구조 확인 (init, pages.getStructure) + +### Phase 2: DynamicItemForm 폴더 생성 +- [x] 기본 폴더 구조 생성 + ``` + src/components/items/DynamicItemForm/ + ├── index.tsx + ├── types.ts + ├── hooks/ + │ ├── index.ts + │ ├── useFormStructure.ts + │ └── useDynamicFormState.ts + └── fields/ + ├── index.ts + ├── TextField.tsx + ├── DropdownField.tsx + ├── NumberField.tsx + ├── DateField.tsx + ├── TextareaField.tsx + ├── CheckboxField.tsx + └── DynamicFieldRenderer.tsx + ``` + +### Phase 3: 타입 정의 +- [x] `types.ts` - 동적 폼 관련 타입 정의 +- [x] API 응답 타입과 프론트엔드 타입 매핑 +- [x] SimpleUnitOption 타입 추가 + +### Phase 4: 훅 구현 +- [x] `useFormStructure.ts` - API에서 폼 구조 로드 +- [x] `useDynamicFormState.ts` - 동적 상태 관리 + +### Phase 5: 필드 컴포넌트 구현 (디자인 100% 동일) +- [x] `TextField.tsx` - 기존 Input 스타일 동일 +- [x] `DropdownField.tsx` - 기존 Select 스타일 동일 +- [x] `NumberField.tsx` - 기존 숫자 입력 스타일 동일 +- [x] `DateField.tsx` - 기존 날짜 선택 스타일 동일 +- [x] `TextareaField.tsx` - 기존 텍스트영역 스타일 동일 +- [x] `CheckboxField.tsx` - 기존 체크박스 스타일 동일 +- [x] `DynamicFieldRenderer.tsx` - 필드 타입별 렌더러 + +### Phase 6: 메인 폼 컴포넌트 +- [x] `index.tsx` - 메인 동적 폼 컴포넌트 + - [x] 기존 ItemForm과 동일한 레이아웃 + - [x] 동일한 헤더 스타일 (FormHeader 인라인) + - [x] 동일한 ValidationAlert 스타일 + - [x] 동일한 섹션 Card 스타일 + - [x] 동일한 버튼 배치 + +### Phase 7: 기존 ItemForm 주석처리 및 전환 +- [x] `/items/create/page.tsx` - DynamicItemForm으로 전환 +- [x] 기존 ItemForm import 주석처리 +- [x] 전환 테스트 준비 + +### Phase 8: API 연동 +- [x] POST /api/proxy/items 연동 구현 +- [x] 품목 등록 후 목록 페이지 이동 +- [x] 에러 처리 + +### Phase 9: 테스트 +- [x] TypeScript 빌드 검증 완료 (DynamicItemForm 에러 없음) +- [ ] 품목 유형별 등록 테스트 (FG, PT, RM, SM, CS) +- [ ] 밸리데이션 테스트 +- [ ] API 연동 테스트 +- [ ] 품목 목록에 등록된 데이터 표시 확인 + +--- + +## 테스트 가이드 + +### 사전 조건 +1. 백엔드 서버 실행 (sam-api) +2. 프론트엔드 개발 서버 실행 (`npm run dev`) +3. 품목기준관리에서 페이지/섹션/필드 설정 완료 + +### 테스트 항목 + +#### 1. 품목 등록 페이지 접근 +``` +URL: http://localhost:3000/items/create +``` +- [ ] 페이지 로딩 시 "폼 구조를 불러오는 중..." 표시 +- [ ] 품목 유형 선택 전 안내 메시지 표시 + +#### 2. 품목 유형 선택 +- [ ] FG (완제품) 선택 시 해당 폼 구조 로드 +- [ ] PT (부품) 선택 시 해당 폼 구조 로드 +- [ ] RM (원자재) 선택 시 해당 폼 구조 로드 +- [ ] SM (부자재) 선택 시 해당 폼 구조 로드 +- [ ] CS (소모품) 선택 시 해당 폼 구조 로드 + +#### 3. 필드 렌더링 +- [ ] 텍스트 필드 정상 렌더링 +- [ ] 숫자 필드 정상 렌더링 +- [ ] 드롭다운 필드 정상 렌더링 (단위 옵션 포함) +- [ ] 체크박스 필드 정상 렌더링 +- [ ] 날짜 필드 정상 렌더링 +- [ ] 텍스트영역 필드 정상 렌더링 + +#### 4. 밸리데이션 +- [ ] 필수 필드 빈값 시 에러 메시지 표시 +- [ ] ValidationAlert에 에러 목록 표시 +- [ ] 에러 필드에 빨간 테두리 표시 + +#### 5. API 연동 +- [ ] 저장 버튼 클릭 시 POST /api/proxy/items 호출 +- [ ] 저장 성공 시 /items 페이지로 이동 +- [ ] 저장 실패 시 에러 메시지 표시 + +#### 6. 디자인 검증 +- [ ] 기존 ItemForm과 레이아웃 동일 +- [ ] 헤더 스타일 동일 (아이콘, 버튼 위치) +- [ ] 섹션 Card 스타일 동일 +- [ ] 필드 라벨/에러 스타일 동일 + +--- + +## 생성된 파일 + +### DynamicItemForm 컴포넌트 +- `src/components/items/DynamicItemForm/index.tsx` - 메인 폼 (388줄) +- `src/components/items/DynamicItemForm/types.ts` - 타입 정의 +- `src/components/items/DynamicItemForm/hooks/index.ts` - 훅 export +- `src/components/items/DynamicItemForm/hooks/useFormStructure.ts` - 폼 구조 로드 +- `src/components/items/DynamicItemForm/hooks/useDynamicFormState.ts` - 상태 관리 +- `src/components/items/DynamicItemForm/fields/index.ts` - 필드 export +- `src/components/items/DynamicItemForm/fields/TextField.tsx` +- `src/components/items/DynamicItemForm/fields/NumberField.tsx` +- `src/components/items/DynamicItemForm/fields/DropdownField.tsx` +- `src/components/items/DynamicItemForm/fields/CheckboxField.tsx` +- `src/components/items/DynamicItemForm/fields/DateField.tsx` +- `src/components/items/DynamicItemForm/fields/TextareaField.tsx` +- `src/components/items/DynamicItemForm/fields/DynamicFieldRenderer.tsx` + +### 수정된 파일 +- `src/app/[locale]/(protected)/items/create/page.tsx` - DynamicItemForm 사용으로 변경 + +--- + +## API 흐름 + +``` +1. useFormStructure(itemType) + └─> itemMasterApi.init() + └─> /api/proxy/item-master/init + └─> pages, sections, fields, unitOptions 반환 + +2. itemMasterApi.pages.getStructure(pageId) + └─> /api/proxy/item-master/pages/{id}/structure + └─> page, sections, directFields 반환 + +3. 품목 등록 + └─> POST /api/proxy/items + └─> body: DynamicFormData +``` + +--- + +## 디자인 매칭 요소 + +### 레이아웃 +- `form` > `space-y-6` +- `Card` > `CardHeader` > `CardTitle` > `CardContent` +- `grid grid-cols-1 md:grid-cols-2 gap-4` + +### 필드 스타일 +- Label with required marker: ` *` +- Error state: `className={error ? 'border-red-500' : ''}` +- Error message: `

{error}

` +- Helper text: `

* ...

` + +### 버튼 스타일 +- Cancel: `variant="outline" size="sm"` +- Submit: `size="sm" disabled={!selectedItemType || isSubmitting}` +- Icons: `X`, `Save` from lucide-react + +--- + +## 변경 이력 + +| 날짜 | 작업 내용 | +|------|----------| +| 2025-12-02 | 문서 생성, Phase 1 시작 | +| 2025-12-02 | Phase 1-8 완료: DynamicItemForm 전체 구현 | \ No newline at end of file diff --git a/claudedocs/item-master/[IMPL-2025-12-02] item-code-auto-generation.md b/claudedocs/item-master/[IMPL-2025-12-02] item-code-auto-generation.md new file mode 100644 index 00000000..60660915 --- /dev/null +++ b/claudedocs/item-master/[IMPL-2025-12-02] item-code-auto-generation.md @@ -0,0 +1,81 @@ +# 품목코드 자동생성 로직 + +## 개요 + +품목코드는 품목기준관리에서 별도 필드로 등록하지 않고, **프론트엔드(DynamicItemForm)에서 자동 생성**한다. + +## 배경 + +- 품목기준관리 API에 "자동생성 필드" 설정 기능이 아직 없음 (TODO 상태) +- 당장 테스트를 위해 프론트에서 처리하기로 결정 (2025-12-02) + +## 자동생성 규칙 (2025-12-03 업데이트) + +### PT (부품) - 영문약어-순번 형식 +``` +품목명 선택 → 영문약어 매핑 조회 → 기존 품목코드에서 순번 계산 → 코드 생성 + +예시: +- 가이드레일 → GR → GR-001, GR-002, ... +- 모터 → MOTOR → MOTOR-001, MOTOR-002, ... +- 제어기 → CTL → CTL-001, CTL-002, ... +``` + +### 기타 품목 (FG, SM, RM, CS) - 품목명-규격 형식 +``` +IF 해당 페이지에 item_name 필드 존재 +AND specification 필드 존재 +THEN + → "품목코드 (자동생성)" 읽기전용 필드 자동 표시 + → 값: {품목명}-{규격} + → 저장 시 품목관리 API의 code로 전달 +``` + +## 영향 범위 + +### 품목기준관리에 등록할 필드 (CS 소모품 예시) + +| 필드명 | field_key | field_type | 비고 | +|--------|-----------|------------|------| +| 품목명 | `item_name` | textbox | 필수 | +| 규격(사양) | `specification` | textbox | 필수 | +| 단위 | `unit` | dropdown | 필수 | +| 비고 | `note` | textarea | 선택 | + +### 품목기준관리에 등록하지 않는 필드 + +| 필드명 | 이유 | +|--------|------| +| 품목코드 | 프론트에서 자동생성 | + +## 구현 위치 + +- **파일**: `src/components/items/DynamicItemForm/index.tsx` +- **로직**: + 1. `structure`에서 `item_name`, `specification` 필드 존재 여부 체크 + 2. 둘 다 있으면 품목코드 읽기전용 필드 렌더링 + 3. 값은 `formData.item_name` + `-` + `formData.specification` + 4. 저장 시 `submitData.item_code`에 포함 + +## 향후 계획 + +- 품목기준관리 API에 `properties.autoGenerate` 설정 기능 추가 시 마이그레이션 +- 예시 설정: +```json +{ + "field_name": "품목코드", + "field_type": "textbox", + "properties": { + "autoGenerate": { + "formula": "{item_name}-{specification}", + "readonly": true + } + } +} +``` + +## 관련 문서 + +- `[PLAN-2025-11-28] dynamic-item-form-implementation.md` +- `[IMPL-2025-12-02] dynamic-item-form-rebuild.md` +- `[REF] item-code-hardcoding.md` - PT 품목코드 하드코딩 내역 상세 \ No newline at end of file diff --git a/claudedocs/item-master/[PLAN-2025-11-28] dynamic-item-form-implementation.md b/claudedocs/item-master/[PLAN-2025-11-28] dynamic-item-form-implementation.md index eadfcc7a..429f3283 100644 --- a/claudedocs/item-master/[PLAN-2025-11-28] dynamic-item-form-implementation.md +++ b/claudedocs/item-master/[PLAN-2025-11-28] dynamic-item-form-implementation.md @@ -195,9 +195,9 @@ src/components/items/ - [x] DrawingCanvasSimple (파일 업로드 기반) - [x] BOMTable (품목 구성 관리) - [x] BendingDetailTable (전개도 상세 입력 + 자동 계산) -- [ ] 페이지 라우트 업데이트 (API 구현 후 진행) - - [ ] `/items/create/page.tsx` - - [ ] `/items/[id]/edit/page.tsx` +- [x] 페이지 라우트 업데이트 ✅ 완료 (2025-12-01) + - [x] `/items/create/page.tsx` → DynamicItemForm 연동 + - [x] `/items/[id]/edit/page.tsx` → DynamicItemForm + 실제 API 연동 ### Phase 7: 테스트 및 검증 - [ ] 품목 유형별 테스트 @@ -347,4 +347,5 @@ src/components/items/ | 날짜 | 버전 | 변경 내용 | |------|------|----------| -| 2025-11-28 | 1.0 | 초안 작성 | \ No newline at end of file +| 2025-11-28 | 1.0 | 초안 작성 | +| 2025-12-01 | 1.1 | Phase 6 페이지 라우트 업데이트 완료 (API 연동) | \ No newline at end of file diff --git a/claudedocs/item-master/[REF-2025-12-01] state-sync-solutions.md b/claudedocs/item-master/[REF-2025-12-01] state-sync-solutions.md new file mode 100644 index 00000000..d400a6bb --- /dev/null +++ b/claudedocs/item-master/[REF-2025-12-01] state-sync-solutions.md @@ -0,0 +1,225 @@ +# 품목기준관리 상태 동기화 문제 및 해결 방안 + +**작성일**: 2025-12-01 +**상태**: 참조 문서 +**관련**: 서비스 레이어 리팩토링 후 발생한 동기화 버그 분석 + +--- + +## 1. 오늘 발생한 버그 분석 + +### 1.1 실시간 동기화 문제 (sectionsAsTemplates 순서) + +``` +문제: 섹션탭에서 항목 추가 → 계층구조만 업데이트, 항목탭/속성탭은 안됨 +원인: Map 중복 제거 시 unlinkedSections가 linkedSections를 덮어씀 +해결: [...unlinkedSections, ...linkedSections] 순서로 변경 +``` + +**리팩토링으로 해결 가능?** ❌ 아니다. **데이터 소스 우선순위** 문제. + +### 1.2 422 Validation Error + +``` +문제: 섹션탭에서 일반 섹션 항목 추가 시 422 에러 +원인: 섹션탭은 POST /fields, 계층구조탭은 POST /sections/{id}/fields 사용 +해결: 섹션탭도 addFieldToSection() 사용하도록 통일 +``` + +**리팩토링으로 해결 가능?** ⚠️ 부분적. 서비스 레이어에서 API 선택을 중앙화할 수 있었지만, 두 탭이 **다른 API를 호출하는 것 자체**가 문제. + +### 1.3 페이지 삭제 시 필드 소실 + +``` +문제: 페이지 삭제 → 섹션의 필드까지 사라짐 +원인: refreshIndependentSections()가 필드 없이 섹션만 반환 +해결: 페이지의 섹션들을 직접 independentSections에 추가 +``` + +**리팩토링으로 해결 가능?** ❌ 아니다. **백엔드 API 응답 불완전성** + **상태 동기화 전략** 문제. + +--- + +## 2. 근본 원인: 동기화가 애매한 구조 + +### 2.1 현재 아키텍처의 문제점 + +``` +┌─────────────────────────────────────────────────┐ +│ 같은 "섹션" 데이터가 2곳에서 관리됨 │ +├─────────────────────────────────────────────────┤ +│ 1. itemPages[].sections[] ← 페이지에 연결된 섹션 │ +│ 2. independentSections[] ← 독립 섹션 │ +├─────────────────────────────────────────────────┤ +│ 문제: 두 소스가 동기화되지 않으면 불일치 발생! │ +└─────────────────────────────────────────────────┘ +``` + +### 2.2 서비스 레이어 리팩토링의 한계 + +| 해결 가능 | 해결 불가 | +|-----------|-----------| +| ✅ validation/파싱 로직 중복 제거 | ❌ 상태 동기화 문제 | +| ✅ 코드 유지보수성 향상 | ❌ 데이터 소스 불일치 | +| ✅ 도메인 로직 중앙화 | ❌ API 응답 불완전성 | + +--- + +## 3. 해결 방안 + +### 방법 1: 정규화된 상태 (Normalized State) ⭐ 권장 + +```typescript +// 현재 구조 (중첩, 중복 가능) +{ + itemPages: [ + { id: 1, sections: [{ id: 10, fields: [...] }] } + ], + independentSections: [ + { id: 10, fields: [...] } // 같은 섹션이 다른 데이터로 존재! + ] +} + +// 정규화된 구조 (Single Source of Truth) +{ + entities: { + pages: { 1: { id: 1, sectionIds: [10] } }, + sections: { 10: { id: 10, fieldIds: [100, 101] } }, + fields: { 100: {...}, 101: {...} } + }, + // 관계는 ID로만 참조 + ui: { + selectedPageId: 1, + independentSectionIds: [20, 30] // ID만 저장 + } +} +``` + +**장점:** +- 데이터 중복 없음 → 불일치 원천 차단 +- 한 곳만 수정하면 모든 곳에 반영 +- 메모리 효율적 + +**구현 도구:** Zustand + immer 또는 Redux Toolkit + +--- + +### 방법 2: Derived State 패턴 + +```typescript +// Context에서 원본 데이터는 하나만 유지 +const [allSections, setAllSections] = useState([]); +const [pageRelations, setPageRelations] = useState<{pageId: number, sectionId: number}[]>([]); + +// 필요한 데이터는 계산으로 도출 (useMemo) +const sectionsForPage = useMemo(() => + allSections.filter(s => + pageRelations.some(r => r.pageId === selectedPageId && r.sectionId === s.id) + ), [allSections, pageRelations, selectedPageId] +); + +const independentSections = useMemo(() => + allSections.filter(s => + !pageRelations.some(r => r.sectionId === s.id) + ), [allSections, pageRelations] +); +``` + +**장점:** +- 기존 Context 구조 유지 가능 +- 점진적 마이그레이션 가능 + +--- + +### 방법 3: React Query / TanStack Query + +```typescript +// 서버 상태를 캐시로 관리 +const { data: sections } = useQuery({ + queryKey: ['sections'], + queryFn: () => api.getSections({ includeFields: true }) +}); + +// 뮤테이션 후 자동 갱신 +const deletePage = useMutation({ + mutationFn: api.deletePage, + onSuccess: () => { + // 관련 쿼리 자동 갱신 + queryClient.invalidateQueries(['sections']); + queryClient.invalidateQueries(['pages']); + } +}); +``` + +**장점:** +- 서버-클라이언트 동기화 자동화 +- 캐싱, 재시도, 낙관적 업데이트 내장 +- 수동 상태 관리 대폭 감소 + +--- + +### 방법 4: 백엔드 API 개선 + +``` +현재: GET /sections → 섹션만 반환 (필드 없음) +개선: GET /sections?include=fields → 섹션 + 필드 반환 +``` + +**또는 GraphQL:** +```graphql +query { + sections { + id + title + fields { # 필요한 관계 데이터 명시적 요청 + id + field_name + } + } +} +``` + +--- + +## 4. 현실적인 적용 순서 + +| 단계 | 방법 | 난이도 | 효과 | 설명 | +|------|------|--------|------|------| +| 1단계 | Derived State 패턴 | 🟢 낮음 | 즉시 적용 가능 | 기존 구조 유지하며 파생 데이터 정리 | +| 2단계 | 백엔드 API 개선 요청 | 🟡 중간 | 근본 해결 | `include=fields` 파라미터 추가 | +| 3단계 | React Query 도입 | 🟡 중간 | 동기화 자동화 | 서버 상태 관리 단순화 | +| 4단계 | 정규화된 상태 | 🔴 높음 | 완전한 해결 | 대규모 리팩토링 필요 | + +--- + +## 5. 프로젝트 추천 방향 + +### 단기 (즉시 적용 가능) +- Derived State 패턴으로 `sectionsAsTemplates` 같은 파생 데이터 정리 +- 중복 데이터 소스 최소화 + +### 중기 (백엔드 협업 필요) +- 백엔드에 `GET /sections?include=fields` API 추가 요청 +- 관계 데이터 포함 응답으로 프론트엔드 동기화 부담 감소 + +### 장기 (대규모 개선) +- React Query 도입으로 서버 상태 관리 단순화 +- 또는 정규화된 상태 구조로 전환 + +--- + +## 6. 결론 + +| 구분 | 내용 | +|------|------| +| **서비스 레이어** | 도메인 로직 중복 해결 ✅ | +| **상태 동기화** | 아키텍처 레벨 개선 필요 ⚠️ | +| **근본 원인** | 같은 데이터가 여러 곳에서 관리됨 | +| **해결 방향** | Single Source of Truth 패턴 적용 | + +--- + +## 7. 관련 문서 + +- `[PLAN-2025-12-01] service-layer-refactoring.md` - 서비스 레이어 리팩토링 계획 +- `[REF-2025-11-26] item-master-hooks-refactoring.md` - 훅 분리 작업 기록 \ No newline at end of file diff --git a/claudedocs/item-master/[REF] item-code-hardcoding.md b/claudedocs/item-master/[REF] item-code-hardcoding.md new file mode 100644 index 00000000..310a0ff4 --- /dev/null +++ b/claudedocs/item-master/[REF] item-code-hardcoding.md @@ -0,0 +1,241 @@ +# 품목코드/품목명 자동생성 하드코딩 내역 + +> MVP용 프론트엔드 구현 - 추후 백엔드 API 또는 품목기준관리 설정으로 이관 필요 + +## 개요 + +PT(부품) 품목 등록 시 품목코드와 품목명을 자동 생성하는 로직이 프론트엔드에 하드코딩되어 있습니다. +이 문서는 해당 하드코딩 내역을 정리하여 추후 백엔드 이관 시 참고할 수 있도록 합니다. + +## 품목코드/품목명 생성 규칙 + +### 적용 범위 + +| 품목유형 | 품목코드 형식 | 품목명 형식 | 예시 | +|---------|-------------|------------|------| +| **PT (부품)** | `영문약어-순번` | 한글 조합 | `GR-001` / `가이드레일 130×80` | +| FG (제품) | `품목명-규격` | 직접 입력 | `스크린-2400` | +| SM (부자재) | `품목명-규격` | 직접 입력 | `볼트-M8` | +| RM (원자재) | `품목명-규격` | 직접 입력 | `알루미늄-T1.5` | +| CS (소모품) | `품목명-규격` | 직접 입력 | `테이프-50mm` | + +--- + +## 하드코딩 항목 1: 영문약어 매핑 테이블 + +### 파일 위치 +`src/components/items/DynamicItemForm/utils/itemCodeGenerator.ts` + +### 상수명 +`ITEM_CODE_PREFIX_MAP` + +### 내용 +```typescript +export const ITEM_CODE_PREFIX_MAP: Record = { + // 부품 - 조립품 + '가이드레일': 'GR', + '케이스': 'CASE', + '브라켓': 'BRK', + + // 부품 - 구매품 + '모터': 'MOTOR', + '제어기': 'CTL', + '전동개폐기': 'OPENER', + '스위치': 'SW', + '센서': 'SENSOR', + '리모컨': 'REMOTE', + + // 부품 - 절곡품 + '레일': 'RAIL', + '커버': 'COVER', + '플레이트': 'PLATE', + + // 제품 + '스크린': 'SCREEN', + '셔터': 'SHUTTER', + '방화스크린': 'FIRE-SCR', + '롤스크린': 'ROLL-SCR', + + // 원자재 + '알루미늄': 'ALU', + '스틸': 'STEEL', + '철판': 'STEEL', + + // 부자재/소모품 + '볼트': 'BOLT', + '너트': 'NUT', + '와셔': 'WASHER', + '나사': 'SCREW', +}; +``` + +### 마이그레이션 방안 +- 품목기준관리 API에 `영문약어 설정` 기능 추가 +- 또는 별도 `item_code_prefix` 테이블 생성 + +--- + +## 하드코딩 항목 2: 절곡품 코드 체계 + +### 파일 위치 +`src/components/items/DynamicItemForm/utils/itemCodeGenerator.ts` + +### 상수명 +`BENDING_CODE_SYSTEM` + +### 내용 +```typescript +export const BENDING_CODE_SYSTEM = { + // 품목명코드 (category2) + 품목명코드: { + 'R': '가이드레일', + 'S': '스크린', + 'C': '케이스', + 'B': '박스', + 'T': '트림', + 'L': '라스틱', + 'G': '기타', + }, + + // 종류코드 (category3) + 종류코드: { + 'M': '마감', + 'T': '티', + 'C': '채널', + 'D': '단면', + 'S': '상부', + 'U': '하부', + 'F': '플랫', + 'P': '피스', + 'L': '리드', + 'B': '브라켓', + 'E': '엔드', + 'I': '이음', + 'A': '각재', + }, + + // 길이코드 매핑 (mm → 코드) + 길이코드: { + 1219: '12', + 2438: '24', + 3000: '30', + 3500: '35', + 4000: '40', + 4150: '41', + 4200: '42', + 4300: '43', + }, +}; +``` + +### 사용 예시 +- 품목명코드 `R` + 종류코드 `C` + 길이코드 `24` = `RC24` +- 가이드레일 채널 2438mm + +### 마이그레이션 방안 +- 품목기준관리 API에 `코드 체계 설정` 기능 추가 +- 또는 별도 `bending_code_system` 테이블 생성 + +--- + +## 하드코딩 항목 3: 조립품 설치유형 매핑 + +### 파일 위치 +`src/components/items/DynamicItemForm/utils/itemCodeGenerator.ts` + +### 상수명 +`INSTALLATION_TYPE_MAP` + +### 내용 +```typescript +export const INSTALLATION_TYPE_MAP: Record = { + 'standard': '표준형', + 'top': '상부형', + 'bottom': '하부형', + 'side': '측면형', + 'custom': '맞춤형', +}; +``` + +### 사용 예시 +- 품목명 `가이드레일` + 설치유형 `standard` → `가이드레일표준형` + +### 마이그레이션 방안 +- 품목기준관리 필드 옵션으로 설정 가능하도록 변경 + +--- + +## 자동생성 함수 목록 + +### 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 1: 품목기준관리 설정 확장 +1. `영문약어` 필드 추가 (품목명 필드 옵션에 매핑) +2. `코드생성규칙` 설정 UI 추가 +3. API에서 코드 생성 규칙 반환 + +### Phase 2: 백엔드 이관 +1. 품목 저장 시 백엔드에서 코드 자동 생성 +2. 순번 관리를 DB 시퀀스로 변경 (동시성 처리) +3. 프론트엔드 하드코딩 제거 + +### Phase 3: 고급 기능 +1. 품목별 코드 생성 규칙 커스터마이징 +2. 코드 중복 검사 강화 +3. 코드 변경 이력 관리 + +--- + +## 관련 파일 + +| 파일 | 설명 | +|-----|------| +| `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` | 기존 품목코드 자동생성 문서 | + +--- + +## 변경 이력 + +| 날짜 | 내용 | +|-----|------| +| 2025-12-03 | PT 품목코드 `영문약어-순번` 형식 구현 | +| 2025-12-03 | 하드코딩 내역 문서화 | \ No newline at end of file diff --git a/claudedocs/sales/[PLAN-2025-12-02] sales-pages-migration.md b/claudedocs/sales/[PLAN-2025-12-02] sales-pages-migration.md new file mode 100644 index 00000000..d912ccf5 --- /dev/null +++ b/claudedocs/sales/[PLAN-2025-12-02] sales-pages-migration.md @@ -0,0 +1,455 @@ +# 판매관리 페이지 마이그레이션 계획 + +> **작성일**: 2025-12-02 +> **소스**: `sam-design/sam-design` +> **타겟**: `sam-react-prod` (Next.js) +> **예상 소요 시간**: 4~6시간 + +--- + +## 0. 예상 작업 시간 + +| Phase | 작업 내용 | 예상 시간 | +|-------|----------|----------| +| Phase 1 | 공통 UI 컴포넌트 (~1,400줄) | 1~2시간 | +| Phase 2 | 거래처관리 (~500줄) | 30분~1시간 | +| Phase 3 | 견적관리 + 하위 컴포넌트 (~1,500줄+) | 2~3시간 | +| **총합** | **~3,400줄+** | **4~6시간** | + +> **참고**: 테스트/디버깅 시간 포함, 예상치 못한 이슈 발생 시 추가 소요 가능 + +--- + +## 1. 마이그레이션 대상 요약 + +| 페이지 | 파일 | 크기 | 분할 필요 | 템플릿 | +|--------|------|------|----------|--------| +| 견적관리 | `QuoteManagement.tsx` | 1,001줄 | O (경계선) | IntegratedListTemplateV2 | +| 거래처관리 | `CustomerAccountManagement.tsx` | 425줄 | X | ListPageTemplate | + +--- + +## 2. 견적관리 (`QuoteManagement.tsx`) + +### 2.1 파일 정보 +- **경로**: `sam-design/src/components/QuoteManagement.tsx` +- **크기**: 1,001줄 +- **템플릿**: `IntegratedListTemplateV2` + +### 2.2 컴포넌트 구조 +``` +QuoteManagement +├── IntegratedListTemplateV2 (목록 템플릿) +│ ├── PageHeader (제목: "견적 목록") +│ ├── StatCards (4개 통계 카드) +│ │ ├── 이번 달 견적 금액 +│ │ ├── 진행중 견적 금액 +│ │ ├── 이번 주 신규 견적 +│ │ └── 이번 달 수주 전환율 +│ ├── SearchFilter (검색바) +│ ├── TabChip 탭 +│ │ ├── 전체 +│ │ ├── 최초작성 +│ │ ├── 수정중 +│ │ ├── 최종확정 +│ │ └── 수주전환 +│ ├── DataTable (데스크톱 테이블) +│ └── ListMobileCard (모바일 카드) +├── QuoteManagement3Write (등록/수정 화면) +├── QuoteManagement3Detail (상세 화면) +├── QuoteDetailView (상세 화면 - 샘플용) +└── StandardDialog (산출내역서/삭제 확인) +``` + +### 2.3 주요 기능 +- 목록 조회 (페이지네이션, 검색, 필터) +- 탭 기반 상태 필터링 +- 체크박스 선택 + 일괄 삭제 +- 모바일 인피니티 스크롤 +- CRUD (등록/조회/수정/삭제) +- 산출내역서 다이얼로그 + +### 2.4 분할 제안 +``` +src/app/[locale]/(protected)/sales/quotes/ +├── page.tsx # 목록 (라우팅 + 상태 관리) +├── components/ +│ ├── QuoteList.tsx # 목록 컴포넌트 +│ ├── QuoteTableRow.tsx # 테이블 행 +│ └── QuoteMobileCard.tsx # 모바일 카드 +├── create/ +│ └── page.tsx # 견적 등록 +├── [id]/ +│ ├── page.tsx # 견적 상세 +│ └── edit/ +│ └── page.tsx # 견적 수정 +``` + +### 2.5 의존성 컴포넌트 +| 컴포넌트 | 경로 | 마이그레이션 필요 | +|----------|------|------------------| +| IntegratedListTemplateV2 | templates/ | O | +| PageHeader | organisms/ | 이미 존재 | +| PageLayout | organisms/ | 이미 존재 | +| StatCards | organisms/ | O | +| SearchFilter | organisms/ | O | +| TabChip | atoms/ | O | +| ListMobileCard | organisms/ | O | +| DataTable | organisms/ | O | +| StandardDialog | molecules/ | O | +| QuoteManagement3Write | components/ | O (별도 분석 필요) | +| QuoteManagement3Detail | components/ | O (별도 분석 필요) | + +--- + +## 3. 거래처관리 (`CustomerAccountManagement.tsx`) + +### 3.1 파일 정보 +- **경로**: `sam-design/src/components/CustomerAccountManagement.tsx` +- **크기**: 425줄 +- **템플릿**: `ListPageTemplate` + +### 3.2 컴포넌트 구조 +``` +CustomerAccountManagement +├── ListPageTemplate (목록 템플릿) +│ ├── PageHeader (제목: "매출처 목록") +│ ├── StatCards (3개 통계 카드) +│ │ ├── 전체 거래처 +│ │ ├── 활성 거래처 +│ │ └── 비활성 거래처 +│ ├── SearchFilter (검색바) +│ ├── DataTable (데스크톱 테이블) +│ │ └── columns: 코드, 거래처명, 사업자번호, 대표자, 전화번호, 업태, 업종, 상태, 관리 +│ └── MobileCard (모바일 카드) +└── Dialog (등록/수정 모달) + └── FormFields: 거래처명, 사업자번호, 대표자, 전화번호, 주소, 이메일, 업태, 업종 +``` + +### 3.3 주요 기능 +- 거래처 목록 조회 (검색) +- CRUD (등록/수정/삭제) +- 모달 기반 폼 + +### 3.4 Next.js 구조 제안 +``` +src/app/[locale]/(protected)/sales/customers/ +├── page.tsx # 목록 + 모달 (425줄이라 단일 파일 가능) +└── components/ + └── CustomerForm.tsx # 폼 분리 (선택) +``` + +### 3.5 의존성 컴포넌트 +| 컴포넌트 | 경로 | 마이그레이션 필요 | +|----------|------|------------------| +| ListPageTemplate | templates/ | O | +| PageHeader | organisms/ | 이미 존재 | +| PageLayout | organisms/ | 이미 존재 | +| StatCards | organisms/ | O | +| SearchFilter | organisms/ | O | +| DataTable | organisms/ | O | +| MobileCard | organisms/ | O | +| Dialog | ui/ | 이미 존재 | + +--- + +## 4. 공통 테이블 UI 기준 (중요) + +### 4.1 최종 기준: 견적관리 페이지 테이블 + +> **공통 테이블 UI는 `QuoteManagement.tsx`의 테이블 형태를 최종 기준으로 적용** + +거래처관리를 포함한 모든 목록 페이지는 견적관리 페이지의 테이블 UI를 따릅니다. + +### 4.2 테이블 화면 구조 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ [PageHeader] 견적 목록 [등록 버튼] │ +├─────────────────────────────────────────────────────────────┤ +│ [StatCards] 4개 통계 카드 (2x2 또는 4열) │ +├─────────────────────────────────────────────────────────────┤ +│ [SearchFilter] 검색바 + 추가 필터 │ +├─────────────────────────────────────────────────────────────┤ +│ [TabChip] 전체 | 최초작성 | 수정중 | 최종확정 | 수주전환 │ +├───┬───────────────────────────────────────────────────┬─────┤ +│ ☐ │ 번호 | 견적번호 | 접수일 | 상태 | ... | 비고 | 작업 │ +├───┼───────────────────────────────────────────────────┼─────┤ +│ ☐ │ 10 | Q2024-001| 2024-01 | 진행 | ... | - | [편집]│ +│ ☐ │ 9 | Q2024-002| 2024-01 | 확정 | ... | - | [편집]│ +├───┴───────────────────────────────────────────────────┴─────┤ +│ [Pagination] 전체 100개 중 1-20개 표시 [이전] 1 2 3 [다음]│ +└─────────────────────────────────────────────────────────────┘ +``` + +### 4.3 반응형 구조 + +| 화면 크기 | 뷰 타입 | 특징 | +|-----------|---------|------| +| **데스크톱** (1280px+) | 테이블 뷰 | 체크박스 + 전체 컬럼 + 페이지네이션 | +| **태블릿** (~1279px) | 카드 뷰 | 2~3열 그리드 + 인피니티 스크롤 | +| **모바일** (~767px) | 카드 뷰 | 1열 + 인피니티 스크롤 | + +**반응형 전환 기준**: +- `xl:` (1280px+) → 테이블 뷰 +- `md:` (768px~1279px) → 카드 그리드 (2~3열) +- 기본 → 카드 단일 열 + +### 4.4 테이블 기능 목록 + +#### 필수 기능 +- [ ] **체크박스 선택**: 개별 선택 + 전체 선택 +- [ ] **일괄 삭제**: 2개 이상 선택 시 삭제 버튼 표시 +- [ ] **역순 번호**: totalCount 기준 역순 (최신이 1번이 아닌 N번) +- [ ] **행 클릭**: 상세 페이지로 이동 +- [ ] **액션 버튼**: 선택된 행에만 수정/삭제 버튼 표시 + +#### 페이지네이션 (데스크톱) +- [ ] 페이지당 20개 표시 +- [ ] 현재 페이지 표시 (1-20 / 100개) +- [ ] 이전/다음 버튼 +- [ ] 페이지 번호 (현재 ±2 범위) + +#### 인피니티 스크롤 (모바일/태블릿) +- [ ] Intersection Observer 사용 +- [ ] 20개씩 추가 로드 +- [ ] Sentinel 요소로 트리거 + +### 4.5 적용 대상 + +| 페이지 | 적용 내용 | +|--------|----------| +| **견적관리** | 기준 페이지 (이미 적용됨) | +| **거래처관리** | 동일한 테이블 UI 적용 필요 | +| **향후 목록 페이지** | 모두 동일한 공통 테이블 UI 사용 | + +### 4.6 공통 테이블 컴포넌트 구조 + +``` +공통 테이블 UI +├── IntegratedListTemplateV2 (템플릿) +│ ├── PageHeader +│ ├── StatCards +│ ├── SearchFilter +│ ├── TabChip (선택적) +│ ├── DataTable (데스크톱) +│ │ ├── 체크박스 컬럼 +│ │ ├── 데이터 컬럼들 +│ │ ├── 액션 컬럼 +│ │ └── 페이지네이션 +│ └── ListMobileCard (모바일/태블릿) +│ ├── 체크박스 +│ ├── 헤더 뱃지 +│ ├── 정보 그리드 +│ └── 액션 버튼 (선택 시) +``` + +--- + +## 5. 공통 UI 컴포넌트 마이그레이션 + +### 5.1 마이그레이션 필요 목록 + +| 컴포넌트 | 소스 경로 | 타겟 경로 | 크기 | 우선순위 | +|----------|----------|----------|------|----------| +| StatCards | organisms/StatCards.tsx | organisms/ | 53줄 | P1 | +| SearchFilter | organisms/SearchFilter.tsx | organisms/ | 54줄 | P1 | +| DataTable | organisms/DataTable.tsx | organisms/ | 313줄 | P1 | +| MobileCard | organisms/MobileCard.tsx | organisms/ | 104줄 | P1 | +| ListMobileCard | organisms/ListMobileCard.tsx | organisms/ | 169줄 | P1 | +| TabChip | atoms/TabChip.tsx | atoms/ | ~50줄 | P2 | +| ListPageTemplate | templates/ListPageTemplate.tsx | templates/ | 143줄 | P2 | +| IntegratedListTemplateV2 | templates/IntegratedListTemplateV2.tsx | templates/ | 491줄 | P2 | +| StandardDialog | molecules/StandardDialog.tsx | molecules/ | ~150줄 | P3 | +| InfoField | organisms/ListMobileCard.tsx | organisms/ | 8줄 | P1 | + +### 5.2 Next.js 타겟 구조 +``` +src/components/ +├── atoms/ +│ └── TabChip.tsx # NEW +├── molecules/ +│ └── StandardDialog.tsx # NEW +├── organisms/ +│ ├── PageHeader.tsx # 기존 +│ ├── PageLayout.tsx # 기존 +│ ├── StatCards.tsx # NEW +│ ├── SearchFilter.tsx # NEW +│ ├── DataTable.tsx # NEW +│ ├── MobileCard.tsx # NEW +│ └── ListMobileCard.tsx # NEW +├── templates/ +│ ├── ListPageTemplate.tsx # NEW +│ └── IntegratedListTemplateV2.tsx # NEW +└── ui/ # 기존 (변경 없음) +``` + +### 5.3 컴포넌트 상세 + +#### StatCards (53줄) +```typescript +interface StatCardData { + label: string; + value: string | number; + icon?: LucideIcon; + iconColor?: string; + trend?: { value: string; isPositive: boolean }; +} +``` +- 2x2 또는 4열 그리드 +- 아이콘 + 라벨 + 값 + 트렌드 + +#### SearchFilter (54줄) +```typescript +interface SearchFilterProps { + searchValue: string; + onSearchChange: (value: string) => void; + searchPlaceholder?: string; + filterButton?: boolean; + onFilterClick?: () => void; + extraActions?: ReactNode; +} +``` +- 검색 아이콘 + Input +- 모바일 플레이스홀더 변경 + +#### DataTable (313줄) +```typescript +interface Column { + key: keyof T | string; + label: string; + type?: CellType; // text, number, currency, date, status, badge, actions, custom + render?: (value: any, row: T, index?: number) => ReactNode; +} +``` +- 타입별 셀 렌더링 +- 페이지네이션 지원 +- 로딩/빈 상태 처리 + +#### ListMobileCard (169줄) +```typescript +interface ListMobileCardProps { + id: string; + isSelected: boolean; + onToggleSelection: () => void; + onCardClick?: () => void; + headerBadges?: ReactNode; + title: string; + statusBadge?: ReactNode; + infoGrid: ReactNode; + actions?: ReactNode; +} +``` +- 체크박스 포함 +- 선택 시 스타일 변경 +- InfoField 하위 컴포넌트 + +--- + +## 6. 작업 순서 (권장) + +### Phase 1: 공통 UI 컴포넌트 (선행) ✅ 완료 +- [x] StatCards 마이그레이션 +- [x] SearchFilter 마이그레이션 +- [x] DataTable 마이그레이션 +- [x] MobileCard 마이그레이션 +- [x] ListMobileCard + InfoField 마이그레이션 +- [x] TabChip 마이그레이션 +- [x] ListPageTemplate 마이그레이션 +- [x] IntegratedListTemplateV2 마이그레이션 +- [x] BadgeSm 마이그레이션 (추가) +- [x] StatusBadge 마이그레이션 (추가) +- [x] IconWithBadge 마이그레이션 (추가) +- [x] TableActions 마이그레이션 (추가) +- [x] EmptyState 마이그레이션 (추가) +- [x] ScreenVersionHistory 마이그레이션 (추가) +- [x] StandardDialog 마이그레이션 (추가) +- [x] formatAmount 유틸리티 마이그레이션 (추가) + +### Phase 2: 거래처관리 (간단한 것 먼저) ✅ 완료 +- [x] 라우트 생성: `/sales/customers` +- [x] CustomerAccountManagement 마이그레이션 +- [x] 테스트 및 검증 (빌드 통과) + +### Phase 3: 견적관리 (복잡한 것) ✅ 완료 +- [x] 라우트 생성: `/sales/quotes` +- [x] QuoteManagement 목록 페이지 마이그레이션 +- [x] Separator 컴포넌트 추가 (의존성) +- [x] 테스트 및 검증 (빌드 통과) +- [ ] QuoteManagement3Write 분석 및 마이그레이션 (향후 작업) +- [ ] QuoteManagement3Detail 분석 및 마이그레이션 (향후 작업) + +--- + +## 7. 주의사항 + +### 7.1 React → Next.js 마이그레이션 체크리스트 +- [ ] `'use client'` 디렉티브 추가 (상태/이벤트 사용 시) +- [ ] `localStorage` 접근 시 `typeof window !== 'undefined'` 체크 +- [ ] `useRouter` → `next/navigation`으로 변경 +- [ ] 이미지 → `next/image` 사용 +- [ ] 링크 → `next/link` 사용 + +### 6.2 스타일 호환성 +- sam-design: Tailwind CSS + shadcn/ui +- sam-react-prod: Tailwind CSS + shadcn/ui (동일) +- cn() 유틸리티 경로 확인 필요 + +### 6.3 의존성 확인 +```bash +# sam-design에서 사용하는 라이브러리 +- lucide-react +- sonner (toast) +- react-hook-form (폼) +- zod (validation) +``` + +--- + +## 7. 예상 작업량 + +| 항목 | 예상 크기 | +|------|----------| +| 공통 UI 컴포넌트 | ~1,400줄 | +| 거래처관리 | ~500줄 | +| 견적관리 (분할 포함) | ~1,500줄+ | +| **총합** | **~3,400줄+** | + +--- + +## 8. 관련 파일 참조 + +### sam-design 소스 파일 +``` +sam-design/sam-design/src/components/ +├── QuoteManagement.tsx +├── CustomerAccountManagement.tsx +├── atoms/ +│ └── TabChip.tsx +├── molecules/ +│ └── StandardDialog.tsx +├── organisms/ +│ ├── StatCards.tsx +│ ├── SearchFilter.tsx +│ ├── DataTable.tsx +│ ├── MobileCard.tsx +│ └── ListMobileCard.tsx +└── templates/ + ├── ListPageTemplate.tsx + └── IntegratedListTemplateV2.tsx +``` + +### Next.js 타겟 경로 +``` +sam-react-prod/src/ +├── app/[locale]/(protected)/sales/ +│ ├── quotes/ +│ │ └── page.tsx +│ └── customers/ +│ └── page.tsx +└── components/ + ├── atoms/ + ├── molecules/ + ├── organisms/ + └── templates/ +``` \ No newline at end of file diff --git a/src/app/[locale]/(protected)/items/create/page.tsx b/src/app/[locale]/(protected)/items/create/page.tsx index 021444de..731da73e 100644 --- a/src/app/[locale]/(protected)/items/create/page.tsx +++ b/src/app/[locale]/(protected)/items/create/page.tsx @@ -1,28 +1,61 @@ /** * 품목 등록 페이지 + * + * DynamicItemForm을 사용하여 품목기준관리 데이터 기반 동적 폼 렌더링 */ 'use client'; -import ItemForm from '@/components/items/ItemForm'; -import type { CreateItemFormData } from '@/lib/utils/validation'; +import { useState } from 'react'; +import DynamicItemForm from '@/components/items/DynamicItemForm'; +import type { DynamicFormData } from '@/components/items/DynamicItemForm/types'; + +// 기존 ItemForm (주석처리 - 롤백 시 사용) +// import ItemForm from '@/components/items/ItemForm'; +// import type { CreateItemFormData } from '@/lib/utils/validation'; export default function CreateItemPage() { - const handleSubmit = async (data: CreateItemFormData) => { - // TODO: API 연동 시 createItem() 호출 - console.log('품목 등록 데이터:', data); + const [submitError, setSubmitError] = useState(null); - // Mock: 성공 메시지 - alert(`품목 "${data.itemName}" (${data.itemCode})이(가) 등록되었습니다.`); + const handleSubmit = async (data: DynamicFormData) => { + setSubmitError(null); - // API 연동 예시: - // const newItem = await createItem(data); - // router.push(`/items/${newItem.itemCode}`); + try { + // API 호출: POST /api/proxy/items + const response = await fetch('/api/proxy/items', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }); + + const result = await response.json(); + + if (!response.ok || !result.success) { + throw new Error(result.message || '품목 등록에 실패했습니다.'); + } + + // 성공 시 DynamicItemForm 내부에서 /items로 리다이렉트 처리됨 + console.log('[CreateItemPage] 품목 등록 성공:', result.data); + } catch (error) { + console.error('[CreateItemPage] 품목 등록 실패:', error); + setSubmitError(error instanceof Error ? error.message : '품목 등록에 실패했습니다.'); + throw error; // DynamicItemForm에서 에러 처리 + } }; return (
- + {submitError && ( +
+ ⚠️ {submitError} +
+ )} +
); } \ No newline at end of file diff --git a/src/app/[locale]/(protected)/items/page.tsx b/src/app/[locale]/(protected)/items/page.tsx index b57528e6..6cc29fe5 100644 --- a/src/app/[locale]/(protected)/items/page.tsx +++ b/src/app/[locale]/(protected)/items/page.tsx @@ -1,154 +1,20 @@ /** - * 품목 목록 페이지 (Server Component) + * 품목 관리 페이지 * - * Next.js 15 App Router - * 서버에서 데이터 fetching 후 Client Component로 전달 + * 품목기준관리 API 연동 + * - 품목 목록: API에서 조회 + * - 테이블 컬럼: custom-tabs API에서 동적 구성 */ -import { Suspense } from 'react'; import ItemListClient from '@/components/items/ItemListClient'; -import type { ItemMaster } from '@/types/item'; - -// 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, - productCategory: 'SCREEN', - lotAbbreviation: 'KD', - currentRevision: 0, - isFinal: false, - createdAt: '2025-01-10T00:00:00Z', - }, - { - id: '2', - itemCode: 'KD-PT-001', - itemName: '가이드레일(벽면형)', - itemType: 'PT', - unit: 'EA', - specification: '2438mm', - isActive: true, - category1: '본체부품', - category2: '가이드시스템', - category3: '가이드레일', - salesPrice: 50000, - purchasePrice: 35000, - 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', - }, - { - id: '6', - itemCode: 'KD-CS-001', - itemName: '절삭유', - itemType: 'CS', - unit: 'L', - specification: '20L', - isActive: true, - purchasePrice: 30000, - currentRevision: 0, - isFinal: false, - createdAt: '2025-01-10T00:00:00Z', - }, - { - id: '7', - itemCode: 'KD-FG-002', - itemName: '철재 제품 B', - itemType: 'FG', - unit: 'SET', - specification: '3000x2500', - isActive: false, - category1: '본체부품', - salesPrice: 200000, - productCategory: 'STEEL', - lotAbbreviation: 'KD', - currentRevision: 0, - isFinal: false, - createdAt: '2025-01-09T00:00:00Z', - }, -]; - -/** - * 품목 목록 조회 함수 - * TODO: API 연동 시 fetchItems()로 교체 - */ -async function getItems(): Promise { - // API 연동 전 mock 데이터 반환 - // const items = await fetchItems(); - return mockItems; -} /** * 품목 목록 페이지 */ -export default async function ItemsPage() { - const items = await getItems(); - +export default function ItemsPage() { return (
- 로딩 중...
}> - - + ); } diff --git a/src/app/[locale]/(protected)/sales/client-management-sales-admin/page.tsx b/src/app/[locale]/(protected)/sales/client-management-sales-admin/page.tsx new file mode 100644 index 00000000..1bb04ca7 --- /dev/null +++ b/src/app/[locale]/(protected)/sales/client-management-sales-admin/page.tsx @@ -0,0 +1,778 @@ +"use client"; + +/** + * 거래처관리 - IntegratedListTemplateV2 적용 + * + * 견적관리와 동일한 구조로 통일 + * - PageHeader, StatCards, SearchFilter + * - 탭 기반 필터 (데스크톱: TabsList, 모바일: 커스텀 버튼) + * - 체크박스 포함 DataTable (Desktop) + * - 체크박스 포함 모바일 카드 (Mobile) + * - 페이지네이션 + 모바일 인피니티 스크롤 + */ + +import { useState, useRef, useEffect } from "react"; +import { + Building2, + Plus, + Edit, + Trash2, + Users, + CheckCircle, + XCircle, +} 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, +} from "@/components/ui/table"; +import { Checkbox } from "@/components/ui/checkbox"; +import { ListMobileCard, InfoField } from "@/components/organisms/ListMobileCard"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + 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 [searchTerm, setSearchTerm] = useState(""); + const [filterType, setFilterType] = useState("all"); + const [selectedItems, setSelectedItems] = useState>(new Set()); + const [currentPage, setCurrentPage] = useState(1); + const itemsPerPage = 20; + + // 모달 상태 + const [isModalOpen, setIsModalOpen] = useState(false); + const [editingCustomer, setEditingCustomer] = useState(null); + const [formData, setFormData] = useState({ + name: "", + businessNo: "", + representative: "", + phone: "", + address: "", + email: "", + businessType: "", + businessItem: "", + }); + + // 삭제 확인 다이얼로그 state + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [deleteTargetId, setDeleteTargetId] = useState(null); + + // 일괄 삭제 확인 다이얼로그 state + const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false); + + // 모바일 인피니티 스크롤 state + const [mobileDisplayCount, setMobileDisplayCount] = useState(20); + const sentinelRef = useRef(null); + + // 로컬 데이터 state + const [customers, setCustomers] = useState(SAMPLE_CUSTOMERS); + + // 필터링 + const filteredCustomers = customers + .filter((customer) => { + const searchLower = searchTerm.toLowerCase(); + const matchesSearch = + !searchTerm || + customer.name.toLowerCase().includes(searchLower) || + customer.code.toLowerCase().includes(searchLower) || + customer.representative.toLowerCase().includes(searchLower) || + customer.phone.includes(searchTerm) || + customer.businessNo.includes(searchTerm); + + let matchesFilter = true; + if (filterType === "active") { + matchesFilter = customer.status === "활성"; + } else if (filterType === "inactive") { + matchesFilter = customer.status === "비활성"; + } + + return matchesSearch && matchesFilter; + }) + .sort((a, b) => { + return ( + new Date(b.registeredDate).getTime() - + new Date(a.registeredDate).getTime() + ); + }); + + // 페이지네이션 + const totalPages = Math.ceil(filteredCustomers.length / itemsPerPage); + const paginatedCustomers = filteredCustomers.slice( + (currentPage - 1) * itemsPerPage, + currentPage * itemsPerPage + ); + + // 모바일용 인피니티 스크롤 데이터 + const mobileCustomers = filteredCustomers.slice(0, mobileDisplayCount); + + // Intersection Observer를 이용한 인피니티 스크롤 + useEffect(() => { + if (typeof window === "undefined") return; + if (window.innerWidth >= 1280) return; + + const observer = new IntersectionObserver( + (entries) => { + if ( + entries[0].isIntersecting && + mobileDisplayCount < filteredCustomers.length + ) { + setMobileDisplayCount((prev) => + Math.min(prev + 20, filteredCustomers.length) + ); + } + }, + { + threshold: 0.1, + rootMargin: "100px", + } + ); + + if (sentinelRef.current) { + observer.observe(sentinelRef.current); + } + + return () => { + observer.disconnect(); + }; + }, [mobileDisplayCount, filteredCustomers.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; + + const stats = [ + { + label: "전체 거래처", + value: totalCustomers, + icon: Users, + iconColor: "text-blue-600", + }, + { + label: "활성 거래처", + value: activeCustomers, + icon: CheckCircle, + iconColor: "text-green-600", + }, + { + label: "비활성 거래처", + value: inactiveCustomers, + icon: XCircle, + iconColor: "text-gray-600", + }, + ]; + + // 핸들러 + const handleAddNew = () => { + setEditingCustomer(null); + setFormData({ + name: "", + businessNo: "", + representative: "", + phone: "", + address: "", + email: "", + businessType: "", + businessItem: "", + }); + setIsModalOpen(true); + }; + + 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 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 handleDelete = (customerId: string) => { + setDeleteTargetId(customerId); + setIsDeleteDialogOpen(true); + }; + + const handleConfirmDelete = () => { + 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); + } + }; + + // 체크박스 선택 + const toggleSelection = (id: string) => { + const newSelection = new Set(selectedItems); + if (newSelection.has(id)) { + newSelection.delete(id); + } else { + newSelection.add(id); + } + setSelectedItems(newSelection); + }; + + const toggleSelectAll = () => { + if ( + selectedItems.size === paginatedCustomers.length && + paginatedCustomers.length > 0 + ) { + setSelectedItems(new Set()); + } else { + setSelectedItems(new Set(paginatedCustomers.map((c) => c.id))); + } + }; + + // 일괄 삭제 + const handleBulkDelete = () => { + if (selectedItems.size === 0) { + toast.error("삭제할 항목을 선택해주세요"); + return; + } + setIsBulkDeleteDialogOpen(true); + }; + + const handleConfirmBulkDelete = () => { + setCustomers(customers.filter((c) => !selectedItems.has(c.id))); + toast.success(`${selectedItems.size}개의 거래처가 삭제되었습니다`); + setSelectedItems(new Set()); + setIsBulkDeleteDialogOpen(false); + }; + + // 상태 뱃지 + const getStatusBadge = (status: "활성" | "비활성") => { + if (status === "활성") { + return ( + + 활성 + + ); + } + return ( + + 비활성 + + ); + }; + + // 탭 구성 + const tabs: TabOption[] = [ + { + value: "all", + label: "전체", + count: customers.length, + color: "blue", + }, + { + value: "active", + label: "활성", + count: activeCustomers, + color: "green", + }, + { + value: "inactive", + label: "비활성", + count: inactiveCustomers, + color: "gray", + }, + ]; + + // 테이블 컬럼 정의 + const tableColumns: TableColumn[] = [ + { key: "rowNumber", label: "번호", className: "px-4" }, + { key: "code", label: "코드", className: "px-4" }, + { key: "name", label: "거래처명", className: "px-4" }, + { key: "businessNo", label: "사업자번호", className: "px-4" }, + { key: "representative", label: "대표자", className: "px-4" }, + { key: "phone", label: "전화번호", className: "px-4" }, + { key: "businessType", label: "업태", className: "px-4" }, + { key: "businessItem", label: "업종", className: "px-4" }, + { key: "status", label: "상태", className: "px-4" }, + { key: "actions", label: "작업", className: "px-4" }, + ]; + + // 테이블 행 렌더링 + const renderTableRow = ( + customer: CustomerAccount, + index: number, + globalIndex: number + ) => { + const itemId = customer.id; + const isSelected = selectedItems.has(itemId); + + return ( + handleView(customer)} + > + e.stopPropagation()} className="text-center"> + toggleSelection(itemId)} + /> + + {globalIndex} + + + {customer.code} + + + {customer.name} + {customer.businessNo} + {customer.representative} + {customer.phone} + {customer.businessType} + {customer.businessItem} + {getStatusBadge(customer.status)} + e.stopPropagation()}> + {isSelected && ( +
+ + +
+ )} +
+
+ ); + }; + + // 모바일 카드 렌더링 + const renderMobileCard = ( + customer: CustomerAccount, + index: number, + globalIndex: number, + isSelected: boolean, + onToggle: () => void + ) => { + return ( + handleView(customer)} + headerBadges={ + <> + + #{globalIndex} + + + {customer.code} + + + } + title={customer.name} + statusBadge={getStatusBadge(customer.status)} + infoGrid={ +
+ + + + + + + +
+ } + actions={ +
+ + +
+ } + /> + ); + }; + + return ( + <> + + + 거래처 등록 + + } + stats={stats} + searchValue={searchTerm} + onSearchChange={setSearchTerm} + searchPlaceholder="거래처명, 코드, 대표자, 전화번호, 사업자번호 검색..." + tabs={tabs} + activeTab={filterType} + onTabChange={(value) => { + setFilterType(value); + setCurrentPage(1); + }} + tableColumns={tableColumns} + tableTitle={`${tabs.find((t) => t.value === filterType)?.label || "전체"} (${filteredCustomers.length}개)`} + data={paginatedCustomers} + totalCount={filteredCustomers.length} + allData={mobileCustomers} + mobileDisplayCount={mobileDisplayCount} + infinityScrollSentinelRef={sentinelRef} + selectedItems={selectedItems} + onToggleSelection={toggleSelection} + onToggleSelectAll={toggleSelectAll} + onBulkDelete={handleBulkDelete} + getItemId={(customer) => customer.id} + renderTableRow={renderTableRow} + renderMobileCard={renderMobileCard} + pagination={{ + currentPage, + totalPages, + totalItems: filteredCustomers.length, + itemsPerPage, + onPageChange: setCurrentPage, + }} + /> + + {/* 등록/수정 모달 */} + + + + + {editingCustomer ? "거래처 수정" : "거래처 등록"} + + 거래처 정보를 입력하세요 + +
+
+ + + setFormData({ ...formData, name: e.target.value }) + } + placeholder="ABC건설" + /> +
+
+ + + setFormData({ ...formData, businessNo: e.target.value }) + } + placeholder="123-45-67890" + /> +
+
+ + + setFormData({ ...formData, representative: e.target.value }) + } + placeholder="홍길동" + /> +
+
+ + + setFormData({ ...formData, phone: e.target.value }) + } + placeholder="02-1234-5678" + /> +
+
+ + + setFormData({ ...formData, address: e.target.value }) + } + placeholder="서울시 강남구 테헤란로 123" + /> +
+
+ + + setFormData({ ...formData, email: e.target.value }) + } + placeholder="abc@company.com" + /> +
+
+ + + setFormData({ ...formData, businessType: e.target.value }) + } + placeholder="건설업" + /> +
+
+ + + setFormData({ ...formData, businessItem: e.target.value }) + } + placeholder="건축공사" + /> +
+
+ + + + +
+
+ + {/* 삭제 확인 다이얼로그 */} + + + + 거래처 삭제 확인 + + {deleteTargetId + ? `거래처: ${customers.find((c) => c.id === deleteTargetId)?.name || deleteTargetId}` + : ""} +
+ 이 거래처를 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다. +
+
+ + 취소 + + 삭제 + + +
+
+ + {/* 일괄 삭제 확인 다이얼로그 */} + + + + 일괄 삭제 확인 + + 선택한 {selectedItems.size}개의 거래처를 삭제하시겠습니까? +
+ 삭제된 데이터는 복구할 수 없습니다. +
+
+ + 취소 + + 삭제 + + +
+
+ + ); +} \ No newline at end of file diff --git a/src/app/[locale]/(protected)/sales/quote-management/page.tsx b/src/app/[locale]/(protected)/sales/quote-management/page.tsx new file mode 100644 index 00000000..3773cbc0 --- /dev/null +++ b/src/app/[locale]/(protected)/sales/quote-management/page.tsx @@ -0,0 +1,856 @@ +"use client"; + +/** + * 견적관리 - IntegratedListTemplateV2 적용 + * + * sam-design에서 마이그레이션된 견적 관리 페이지 + * - PageHeader, StatCards, SearchFilter, 체크박스 + * - 데스크톱: TabsList + DataTable + * - 모바일: 커스텀 버튼 탭 + 카드 리스트 + * - 완전한 반응형 지원 + */ + +import { useState, useRef, useEffect } from "react"; +import { + FileText, + Edit, + Trash2, + CheckCircle, + History, + Calculator, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { BadgeSm, getQuoteStatusBadge } from "@/components/atoms/BadgeSm"; +import { + IntegratedListTemplateV2, + TabOption, + TableColumn, +} from "@/components/templates/IntegratedListTemplateV2"; +import { toast } from "sonner"; +import { StandardDialog } from "@/components/molecules/StandardDialog"; +import { + Table, + TableHeader, + TableRow, + TableHead, + TableBody, + TableCell, +} from "@/components/ui/table"; +import { Separator } from "@/components/ui/separator"; +import { Checkbox } from "@/components/ui/checkbox"; +import { formatAmount, formatAmountManwon } from "@/utils/formatAmount"; +import { ListMobileCard, InfoField } from "@/components/organisms/ListMobileCard"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; + +// 견적 타입 +interface Quote { + id: string; + quoteNumber: string; + registrationDate: string; + client: string; + site?: string; + siteCode?: string; + manager?: string; + contact?: string; + productCode?: string; + quantity: number; + amount: number; + status: "draft" | "converted"; + currentRevision: number; + isFinal: boolean; + type?: string; + remarks?: string; +} + +// 샘플 견적 데이터 +const SAMPLE_QUOTES: Quote[] = [ + { + id: "SAMPLE-Q-001", + quoteNumber: "Q2024-001", + registrationDate: "2024-01-15", + client: "ABC건설", + site: "강남 오피스텔 현장", + siteCode: "PJ-20240115-01", + manager: "김철수", + contact: "010-1234-5678", + productCode: "SCR-001", + quantity: 10, + amount: 15000000, + status: "draft", + currentRevision: 0, + isFinal: false, + type: "스크린", + remarks: "급하게 진행 필요", + }, + { + id: "SAMPLE-Q-002", + quoteNumber: "Q2024-002", + registrationDate: "2024-01-16", + client: "XYZ산업", + site: "판교 공장", + siteCode: "PJ-20240116-01", + manager: "이영희", + contact: "010-2345-6789", + productCode: "STL-002", + quantity: 5, + amount: 8500000, + status: "draft", + currentRevision: 2, + isFinal: false, + type: "철재", + remarks: "", + }, + { + id: "SAMPLE-Q-003", + quoteNumber: "Q2024-003", + registrationDate: "2024-01-17", + client: "DEF개발", + site: "송도 아파트 현장", + siteCode: "PJ-20240117-01", + manager: "박민수", + contact: "010-3456-7890", + productCode: "SCR-003", + quantity: 20, + amount: 25000000, + status: "draft", + currentRevision: 0, + isFinal: true, + type: "스크린", + remarks: "확정 완료", + }, + { + id: "SAMPLE-Q-004", + quoteNumber: "Q2024-004", + registrationDate: "2024-01-18", + client: "GHI건축", + site: "분당 상가 현장", + siteCode: "PJ-20240118-01", + manager: "최지원", + contact: "010-4567-8901", + productCode: "STL-004", + quantity: 8, + amount: 12000000, + status: "converted", + currentRevision: 1, + isFinal: true, + type: "철재", + remarks: "수주 전환 완료", + }, + { + id: "SAMPLE-Q-005", + quoteNumber: "Q2024-005", + registrationDate: "2024-01-19", + client: "JKL개발", + site: "용산 오피스빌딩", + siteCode: "PJ-20240119-01", + manager: "정수민", + contact: "010-5678-9012", + productCode: "SCR-005", + quantity: 15, + amount: 22000000, + status: "draft", + currentRevision: 3, + isFinal: false, + type: "스크린", + remarks: "3차 수정 중", + }, +]; + +export default function QuoteManagementPage() { + const [searchTerm, setSearchTerm] = useState(""); + const [filterType, setFilterType] = useState("all"); + const [isCalculationDialogOpen, setIsCalculationDialogOpen] = useState(false); + const [calculationQuote, setCalculationQuote] = useState(null); + const [selectedItems, setSelectedItems] = useState>(new Set()); + const [currentPage, setCurrentPage] = useState(1); + const itemsPerPage = 20; + + // 삭제 확인 다이얼로그 state + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [deleteTargetId, setDeleteTargetId] = useState(null); + + // 일괄 삭제 확인 다이얼로그 state + const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false); + + // 모바일 인피니티 스크롤 state + const [mobileDisplayCount, setMobileDisplayCount] = useState(20); + const sentinelRef = useRef(null); + + // 로컬 데이터 state (실제 구현에서는 API 연동) + const [quotes, setQuotes] = useState(SAMPLE_QUOTES); + + // 필터링 및 정렬 + const filteredQuotes = quotes + .filter((quote) => { + const searchLower = searchTerm.toLowerCase(); + const matchesSearch = + !searchTerm || + quote.quoteNumber.toLowerCase().includes(searchLower) || + quote.client.toLowerCase().includes(searchLower) || + (quote.manager && quote.manager.toLowerCase().includes(searchLower)) || + (quote.productCode && + quote.productCode.toLowerCase().includes(searchLower)) || + (quote.site && quote.site.toLowerCase().includes(searchLower)); + + let matchesFilter = true; + if (filterType === "initial") { + matchesFilter = + quote.currentRevision === 0 && + !quote.isFinal && + quote.status !== "converted"; + } else if (filterType === "revising") { + matchesFilter = + quote.currentRevision > 0 && + !quote.isFinal && + quote.status !== "converted"; + } else if (filterType === "final") { + matchesFilter = quote.isFinal && quote.status !== "converted"; + } else if (filterType === "converted") { + matchesFilter = quote.status === "converted"; + } + + return matchesSearch && matchesFilter; + }) + .sort((a, b) => { + return ( + new Date(b.registrationDate).getTime() - + new Date(a.registrationDate).getTime() + ); + }); + + // 페이지네이션 + const totalPages = Math.ceil(filteredQuotes.length / itemsPerPage); + const paginatedQuotes = filteredQuotes.slice( + (currentPage - 1) * itemsPerPage, + currentPage * itemsPerPage + ); + + // 모바일용 인피니티 스크롤 데이터 + const mobileQuotes = filteredQuotes.slice(0, mobileDisplayCount); + + // Intersection Observer를 이용한 인피니티 스크롤 + useEffect(() => { + // SSR 체크 + if (typeof window === "undefined") return; + + // 모바일/태블릿 환경(xl 미만)에서만 작동 + if (window.innerWidth >= 1280) return; + + const observer = new IntersectionObserver( + (entries) => { + // sentinel 요소가 화면에 보이고, 더 불러올 데이터가 있으면 + if ( + entries[0].isIntersecting && + mobileDisplayCount < filteredQuotes.length + ) { + setMobileDisplayCount((prev) => + Math.min(prev + 20, filteredQuotes.length) + ); + } + }, + { + threshold: 0.1, // 10%만 보여도 트리거 + rootMargin: "100px", // 하단 100px 전에 미리 로드 + } + ); + + if (sentinelRef.current) { + observer.observe(sentinelRef.current); + } + + return () => { + observer.disconnect(); + }; + }, [mobileDisplayCount, filteredQuotes.length]); + + // 탭이나 검색어 변경 시 모바일 표시 개수 초기화 + useEffect(() => { + setMobileDisplayCount(20); + }, [searchTerm, filterType]); + + // 통계 계산 + const now = new Date(); + const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); + const startOfWeek = new Date(now); + startOfWeek.setDate(now.getDate() - now.getDay()); // 이번 주 일요일 + + // 이번 달 견적 금액 + const thisMonthQuotes = quotes.filter( + (q) => new Date(q.registrationDate) >= startOfMonth + ); + const thisMonthAmount = thisMonthQuotes.reduce((sum, q) => sum + q.amount, 0); + + // 진행중 견적 금액 (status === "draft") + const ongoingQuotes = quotes.filter((q) => q.status === "draft"); + const ongoingAmount = ongoingQuotes.reduce((sum, q) => sum + q.amount, 0); + + // 이번 주 신규 견적 + const thisWeekQuotes = quotes.filter( + (q) => new Date(q.registrationDate) >= startOfWeek + ); + + // 이번 달 수주 전환율 (이번 달 등록된 견적 중 수주로 전환된 비율) + const thisMonthConvertedCount = thisMonthQuotes.filter( + (q) => q.status === "converted" + ).length; + const thisMonthConversionRate = + thisMonthQuotes.length > 0 + ? ((thisMonthConvertedCount / thisMonthQuotes.length) * 100).toFixed(1) + : "0.0"; + + const stats = [ + { + label: "이번 달 견적 금액", + value: formatAmountManwon(thisMonthAmount), + icon: Calculator, + iconColor: "text-blue-600", + }, + { + label: "진행중 견적 금액", + value: formatAmountManwon(ongoingAmount), + icon: FileText, + iconColor: "text-orange-600", + }, + { + label: "이번 주 신규 견적", + value: `${thisWeekQuotes.length}건`, + icon: Edit, + iconColor: "text-green-600", + }, + { + label: "이번 달 수주 전환율", + value: `${thisMonthConversionRate}%`, + icon: CheckCircle, + iconColor: "text-purple-600", + }, + ]; + + // 핸들러 + const handleView = (quote: Quote) => { + toast.info(`상세보기: ${quote.quoteNumber}`); + }; + + const handleEdit = (quote: Quote) => { + toast.info(`수정: ${quote.quoteNumber}`); + }; + + const handleDelete = (quoteId: string) => { + setDeleteTargetId(quoteId); + setIsDeleteDialogOpen(true); + }; + + // 삭제 확인 후 실행 + const handleConfirmDelete = () => { + if (deleteTargetId) { + const quote = quotes.find((q) => q.id === deleteTargetId); + setQuotes(quotes.filter((q) => q.id !== deleteTargetId)); + toast.success( + `견적이 삭제되었습니다${quote ? `: ${quote.quoteNumber}` : ""}` + ); + setIsDeleteDialogOpen(false); + setDeleteTargetId(null); + } + }; + + const handleViewHistory = (quote: Quote) => { + toast.info(`수정 이력: ${quote.quoteNumber} (${quote.currentRevision}차 수정)`); + }; + + const handleViewCalculation = (quote: Quote) => { + setCalculationQuote(quote); + setIsCalculationDialogOpen(true); + }; + + // 체크박스 선택 + const toggleSelection = (id: string) => { + const newSelection = new Set(selectedItems); + if (newSelection.has(id)) { + newSelection.delete(id); + } else { + newSelection.add(id); + } + setSelectedItems(newSelection); + }; + + const toggleSelectAll = () => { + if ( + selectedItems.size === paginatedQuotes.length && + paginatedQuotes.length > 0 + ) { + setSelectedItems(new Set()); + } else { + setSelectedItems(new Set(paginatedQuotes.map((q) => q.id))); + } + }; + + // 일괄 삭제 + const handleBulkDelete = () => { + if (selectedItems.size === 0) { + toast.error("삭제할 항목을 선택해주세요"); + return; + } + setIsBulkDeleteDialogOpen(true); + }; + + const handleConfirmBulkDelete = () => { + setQuotes(quotes.filter((q) => !selectedItems.has(q.id))); + toast.success(`${selectedItems.size}개의 견적이 삭제되었습니다`); + setSelectedItems(new Set()); + setIsBulkDeleteDialogOpen(false); + }; + + // 상태 뱃지 + const getRevisionBadge = (quote: Quote) => { + return getQuoteStatusBadge(quote); + }; + + // 탭 구성 + const tabs: TabOption[] = [ + { + value: "all", + label: "전체", + count: quotes.length, + color: "blue", + }, + { + value: "initial", + label: "최초작성", + count: quotes.filter( + (q) => + q.currentRevision === 0 && !q.isFinal && q.status !== "converted" + ).length, + color: "gray", + }, + { + value: "revising", + label: "수정중", + count: quotes.filter( + (q) => + q.currentRevision > 0 && !q.isFinal && q.status !== "converted" + ).length, + color: "orange", + }, + { + value: "final", + label: "최종확정", + count: quotes.filter((q) => q.isFinal && q.status !== "converted").length, + color: "green", + }, + { + value: "converted", + label: "수주전환", + count: quotes.filter((q) => q.status === "converted").length, + color: "purple", + }, + ]; + + // 테이블 컬럼 정의 + const tableColumns: TableColumn[] = [ + { key: "rowNumber", label: "번호", className: "px-4" }, + { key: "quoteNumber", label: "견적번호", className: "px-4" }, + { key: "registrationDate", label: "접수일", className: "px-4" }, + { key: "status", label: "상태", className: "px-4" }, + { key: "productName", label: "제품명", className: "px-4" }, + { key: "quantity", label: "수량", className: "px-4" }, + { key: "amount", label: "금액", className: "px-4" }, + { key: "client", label: "발주처", className: "px-4" }, + { key: "site", label: "현장명", className: "px-4" }, + { key: "manager", label: "담당자", className: "px-4" }, + { key: "remarks", label: "비고", className: "px-4" }, + { key: "actions", label: "작업", className: "px-4" }, + ]; + + // 테이블 행 렌더링 + const renderTableRow = ( + quote: Quote, + index: number, + globalIndex: number + ) => { + const itemId = quote.id; + const isSelected = selectedItems.has(itemId); + + return ( + handleView(quote)} + > + e.stopPropagation()} className="text-center"> + toggleSelection(itemId)} + /> + + {globalIndex} + + + {quote.quoteNumber || "-"} + + + {quote.registrationDate} + {getRevisionBadge(quote)} + {quote.productCode || "-"} + {quote.quantity} + {formatAmount(quote.amount)} + {quote.client} + +
+ {quote.site || "-"} + {quote.siteCode && ( + + {quote.siteCode} + + )} +
+
+ {quote.manager || "-"} + +
+ {quote.remarks || "-"} +
+
+ e.stopPropagation()}> + {isSelected && ( +
+ {quote.currentRevision > 0 && ( + + )} + + {!quote.isFinal && ( + + )} +
+ )} +
+
+ ); + }; + + // 모바일 카드 렌더링 + const renderMobileCard = ( + quote: Quote, + index: number, + globalIndex: number, + isSelected: boolean, + onToggle: () => void + ) => { + return ( + handleView(quote)} + headerBadges={ + <> + + #{globalIndex} + + + {quote.quoteNumber} + + + } + title={quote.client} + statusBadge={getRevisionBadge(quote)} + infoGrid={ +
+ + + + + + + +
+ } + actions={ +
+ {quote.currentRevision > 0 && ( + + )} + + {!quote.isFinal && ( + + )} +
+ } + /> + ); + }; + + // 목록 화면 - IntegratedListTemplateV2 사용 + return ( + <> + toast.info("견적 등록 기능 준비중")}> + + 견적 등록 + + } + stats={stats} + searchValue={searchTerm} + onSearchChange={setSearchTerm} + searchPlaceholder="견적번호, 발주처, 담당자, 제품명, 현장코드, 현장명 검색..." + tabs={tabs} + activeTab={filterType} + onTabChange={(value) => { + setFilterType(value); + setCurrentPage(1); + }} + tableColumns={tableColumns} + tableTitle={`${tabs.find((t) => t.value === filterType)?.label || "전체"} (${filteredQuotes.length}개)`} + data={paginatedQuotes} + totalCount={filteredQuotes.length} + allData={mobileQuotes} + mobileDisplayCount={mobileDisplayCount} + infinityScrollSentinelRef={sentinelRef} + selectedItems={selectedItems} + onToggleSelection={toggleSelection} + onToggleSelectAll={toggleSelectAll} + onBulkDelete={handleBulkDelete} + getItemId={(quote) => quote.id} + renderTableRow={renderTableRow} + renderMobileCard={renderMobileCard} + pagination={{ + currentPage, + totalPages, + totalItems: filteredQuotes.length, + itemsPerPage, + onPageChange: setCurrentPage, + }} + /> + + {/* 산출내역서 다이얼로그 */} + setIsCalculationDialogOpen(false)}>닫기 + } + > + {calculationQuote && ( +
+ {/* 기본 정보 */} +
+
+

견적번호

+

{calculationQuote.quoteNumber}

+
+
+

발주처

+

{calculationQuote.client}

+
+
+

현장명

+

{calculationQuote.site || "-"}

+
+
+

접수일

+

+ {calculationQuote.registrationDate} +

+
+
+ + + + {/* 산출 내역 테이블 */} +
+

+ + 산출 내역 +

+
+ + + + 번호 + 품목명 + 규격 + 수량 + 단가 + 공급가 + 부가세 + 합계 + + + + + 1 + {calculationQuote.type || "스크린"} + {calculationQuote.productCode || "-"} + + {calculationQuote.quantity} + + + {formatAmount( + Math.floor( + calculationQuote.amount / calculationQuote.quantity + ) + )} + 원 + + + {formatAmount( + Math.floor(calculationQuote.amount / 1.1) + )} + 원 + + + {formatAmount( + Math.floor( + calculationQuote.amount - + calculationQuote.amount / 1.1 + ) + )} + 원 + + + {formatAmount(calculationQuote.amount)}원 + + + +
+
+
+ + {/* 합계 */} +
+
+ 총 금액 + + {formatAmount(calculationQuote.amount)}원 + +
+
+
+ )} +
+ + {/* 삭제 확인 다이얼로그 */} + + + + 견적 삭제 확인 + + {deleteTargetId + ? `견적번호: ${quotes.find((q) => q.id === deleteTargetId)?.quoteNumber || deleteTargetId}` + : ""} +
+ 이 견적을 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다. +
+
+ + 취소 + + 삭제 + + +
+
+ + {/* 일괄 삭제 확인 다이얼로그 */} + + + + 일괄 삭제 확인 + + 선택한 {selectedItems.size}개의 견적을 삭제하시겠습니까? +
+ 삭제된 데이터는 복구할 수 없습니다. +
+
+ + 취소 + + 삭제 + + +
+
+ + ); +} diff --git a/src/components/atoms/BadgeSm.tsx b/src/components/atoms/BadgeSm.tsx new file mode 100644 index 00000000..1f6c304e --- /dev/null +++ b/src/components/atoms/BadgeSm.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { ReactNode } from "react"; +import { cn } from "@/lib/utils"; + +/** + * BadgeSm - 작은 크기의 상태 뱃지 컴포넌트 + * + * 견적 관리 시스템의 상태를 표시하는 데 사용되는 작은 뱃지입니다. + * + * @example + * // 수주전환 상태 + * 수주전환 + * + * // 최종확정 상태 + * 최종확정 + * + * // 수정중 상태 + * 2차 수정 + * + * // 최초작성 상태 + * 최초 작성 + */ + +export type BadgeSmVariant = + | "converted" // 수주전환 - 보라색 + | "finalized" // 최종확정 - 초록색 + | "revising" // 수정중 - 주황색 + | "initial" // 최초작성 - 회색 + | "current" // 현재버전 - 파란색 + | "default" // 기본 - 회색 + | "outline"; // 외곽선 - 회색 테두리 + +interface BadgeSmProps { + variant?: BadgeSmVariant; + children: ReactNode; + className?: string; +} + +const variantStyles: Record = { + converted: "bg-purple-600 text-white border-purple-600", + finalized: "bg-green-600 text-white border-green-600", + revising: "bg-orange-100 text-orange-700 border-orange-300", + initial: "bg-gray-50 text-gray-700 border-gray-200", + current: "bg-blue-600 text-white border-blue-600", + default: "bg-gray-100 text-gray-700 border-gray-200", + outline: "bg-transparent text-gray-700 border-gray-300", +}; + +export function BadgeSm({ + variant = "default", + children, + className +}: BadgeSmProps) { + return ( + + {children} + + ); +} + +/** + * 견적 상태에 따라 적절한 BadgeSm을 반환하는 헬퍼 함수 + */ +export function getQuoteStatusBadge(quote: { + status?: string; + isFinal?: boolean; + currentRevision?: number; +}) { + // 수주전환 + if (quote.status === "converted") { + return ( + + 수주전환 + + ); + } + + // 최종확정 + if (quote.isFinal) { + return ( + + 최종확정 + + ); + } + + // 수정중 + if (quote.currentRevision && quote.currentRevision > 0) { + return ( + + {quote.currentRevision}차 수정 + + ); + } + + // 최초작성 + return ( + + 최초 작성 + + ); +} \ No newline at end of file diff --git a/src/components/atoms/TabChip.tsx b/src/components/atoms/TabChip.tsx new file mode 100644 index 00000000..9e8dcf7a --- /dev/null +++ b/src/components/atoms/TabChip.tsx @@ -0,0 +1,66 @@ +"use client"; + +/** + * TabChip - 모바일용 탭 칩 컴포넌트 + * + * 디자인시스템 Atoms로 등록된 재사용 가능한 탭 칩입니다. + * - 활성/비활성 상태 스타일 + * - 카운트 표시 + * - 색상 변형 지원 + */ + +export interface TabChipProps { + /** 탭 라벨 */ + label: string; + /** 카운트 숫자 */ + count?: number; + /** 활성 상태 */ + active?: boolean; + /** 클릭 이벤트 */ + onClick?: () => void; + /** 색상 테마 */ + color?: "blue" | "gray" | "green" | "orange" | "purple" | "red"; + /** 추가 className */ + className?: string; +} + +export function TabChip({ + label, + count, + active = false, + onClick, + color = "blue", + className = "", +}: TabChipProps) { + return ( + + ); +} \ No newline at end of file diff --git a/src/components/atoms/index.ts b/src/components/atoms/index.ts new file mode 100644 index 00000000..180124c3 --- /dev/null +++ b/src/components/atoms/index.ts @@ -0,0 +1,5 @@ +export { BadgeSm, getQuoteStatusBadge } from "./BadgeSm"; +export type { BadgeSmVariant } from "./BadgeSm"; + +export { TabChip } from "./TabChip"; +export type { TabChipProps } from "./TabChip"; \ No newline at end of file diff --git a/src/components/items/DynamicItemForm/DynamicField.tsx b/src/components/items/DynamicItemForm/DynamicField.tsx deleted file mode 100644 index e8eca7f5..00000000 --- a/src/components/items/DynamicItemForm/DynamicField.tsx +++ /dev/null @@ -1,63 +0,0 @@ -/** - * DynamicField Component - * - * 필드 타입에 따라 적절한 필드 컴포넌트를 렌더링 - */ - -'use client'; - -import type { DynamicFieldProps, FieldType } from './types'; -import { - TextField, - DropdownField, - NumberField, - DateField, - CheckboxField, - FileField, - CustomField, -} from './fields'; - -/** - * 필드 타입 → 컴포넌트 매핑 - */ -const FIELD_COMPONENTS: Record< - FieldType, - React.ComponentType -> = { - textbox: TextField, - textarea: TextField, - dropdown: DropdownField, - 'searchable-dropdown': DropdownField, - number: NumberField, - currency: NumberField, - date: DateField, - 'date-range': DateField, - checkbox: CheckboxField, - switch: CheckboxField, - file: FileField, - 'custom:drawing-canvas': CustomField, - 'custom:bending-detail-table': CustomField, - 'custom:bom-table': CustomField, -}; - -export function DynamicField(props: DynamicFieldProps) { - const { field } = props; - - // 필드 타입에 맞는 컴포넌트 선택 - const FieldComponent = FIELD_COMPONENTS[field.field_type]; - - if (!FieldComponent) { - console.warn(`[DynamicField] Unknown field type: ${field.field_type}`); - return ( -
-

- 알 수 없는 필드 타입: {field.field_type} -

-
- ); - } - - return ; -} - -export default DynamicField; \ No newline at end of file diff --git a/src/components/items/DynamicItemForm/DynamicFormRenderer.tsx b/src/components/items/DynamicItemForm/DynamicFormRenderer.tsx deleted file mode 100644 index 758dcc0d..00000000 --- a/src/components/items/DynamicItemForm/DynamicFormRenderer.tsx +++ /dev/null @@ -1,75 +0,0 @@ -/** - * DynamicFormRenderer Component - * - * 전체 폼 구조를 렌더링하는 메인 렌더러 - * - 섹션 순서대로 렌더링 - * - 조건부 섹션/필드 처리 - */ - -'use client'; - -import { DynamicSection } from './DynamicSection'; -import { useConditionalFields } from './hooks/useConditionalFields'; -import type { DynamicFormRendererProps, DynamicSection as DynamicSectionType } from './types'; - -export function DynamicFormRenderer({ - sections, - conditionalSections, - conditionalFields, - values, - errors, - onChange, - onBlur, - disabled, -}: DynamicFormRendererProps) { - // 조건부 표시 로직 - const { isSectionVisible, isFieldVisible } = useConditionalFields({ - sections, - conditionalSections, - conditionalFields, - values, - }); - - // 섹션 순서대로 정렬 - const sortedSections = [...sections].sort((a, b) => a.order_no - b.order_no); - - // 표시할 섹션만 필터링 - const visibleSections = sortedSections.filter((section) => - isSectionVisible(section.id) - ); - - // 각 섹션의 표시할 필드만 필터링 - const sectionsWithVisibleFields: DynamicSectionType[] = visibleSections.map((section) => ({ - ...section, - fields: section.fields.filter((field) => - isFieldVisible(section.id, field.id) - ), - })); - - if (sectionsWithVisibleFields.length === 0) { - return ( -
-

표시할 섹션이 없습니다.

-

품목 유형을 선택하거나 필수 필드를 입력해주세요.

-
- ); - } - - return ( -
- {sectionsWithVisibleFields.map((section) => ( - - ))} -
- ); -} - -export default DynamicFormRenderer; \ No newline at end of file diff --git a/src/components/items/DynamicItemForm/DynamicSection.tsx b/src/components/items/DynamicItemForm/DynamicSection.tsx deleted file mode 100644 index 8ed23c94..00000000 --- a/src/components/items/DynamicItemForm/DynamicSection.tsx +++ /dev/null @@ -1,162 +0,0 @@ -/** - * DynamicSection Component - * - * 동적 섹션 렌더링 (Card + 접기/펼치기 + 필드 그리드) - */ - -'use client'; - -import { useState } from 'react'; -import { ChevronDown, ChevronRight } from 'lucide-react'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Button } from '@/components/ui/button'; -import { DynamicField } from './DynamicField'; -import type { DynamicSectionProps, DynamicField as DynamicFieldType } from './types'; -import { cn } from '@/lib/utils'; - -/** - * 필드를 그리드 레이아웃으로 정렬 - */ -function organizeFieldsIntoGrid(fields: DynamicFieldType[]): DynamicFieldType[][] { - if (fields.length === 0) return []; - - // 정렬: order_no → grid_row → grid_col - const sortedFields = [...fields].sort((a, b) => { - if (a.order_no !== b.order_no) return a.order_no - b.order_no; - if ((a.grid_row || 1) !== (b.grid_row || 1)) return (a.grid_row || 1) - (b.grid_row || 1); - return (a.grid_col || 1) - (b.grid_col || 1); - }); - - // 행별로 그룹화 - const rows: Map = new Map(); - - for (const field of sortedFields) { - const row = field.grid_row || 1; - if (!rows.has(row)) { - rows.set(row, []); - } - rows.get(row)!.push(field); - } - - // 배열로 변환 (행 번호 순서대로) - return Array.from(rows.entries()) - .sort(([a], [b]) => a - b) - .map(([, fields]) => fields); -} - -/** - * grid_span을 Tailwind 클래스로 변환 - */ -function getGridSpanClass(span: number = 1): string { - const spanClasses: Record = { - 1: 'col-span-1', - 2: 'col-span-2', - 3: 'col-span-3', - 4: 'col-span-4', - }; - return spanClasses[span] || 'col-span-1'; -} - -export function DynamicSection({ - section, - values, - errors, - onChange, - onBlur, - disabled, -}: DynamicSectionProps) { - const [isOpen, setIsOpen] = useState(section.is_default_open); - - // BOM 섹션은 별도 처리 - const isBomSection = section.section_type === 'BOM'; - - // 필드를 그리드로 정렬 - const fieldRows = organizeFieldsIntoGrid(section.fields); - - const handleToggle = () => { - if (section.is_collapsible) { - setIsOpen(!isOpen); - } - }; - - return ( - - -
- - {section.is_collapsible && ( - - {isOpen ? ( - - ) : ( - - )} - - )} - {section.title} - - - {section.description && ( -

{section.description}

- )} -
-
- - {(isOpen || !section.is_collapsible) && ( - - {isBomSection ? ( - // BOM 섹션: 별도 컴포넌트로 처리 -
-

부품 구성 (BOM)

-

- 기존 BOMSection 컴포넌트 통합 예정 -

-
- ) : ( - // 일반 섹션: 필드 그리드 렌더링 -
- {fieldRows.map((rowFields, rowIndex) => ( -
- {rowFields.map((field) => ( -
= 2 && 'md:col-span-2', - field.grid_span && field.grid_span >= 3 && 'lg:col-span-3', - field.grid_span && field.grid_span >= 4 && 'lg:col-span-4' - )} - > - onChange(field.field_key, value)} - onBlur={() => onBlur(field.field_key)} - disabled={disabled} - /> -
- ))} -
- ))} -
- )} -
- )} -
- ); -} - -export default DynamicSection; \ No newline at end of file diff --git a/src/components/items/DynamicItemForm/fields/CheckboxField.tsx b/src/components/items/DynamicItemForm/fields/CheckboxField.tsx index ba4edf3a..ca940984 100644 --- a/src/components/items/DynamicItemForm/fields/CheckboxField.tsx +++ b/src/components/items/DynamicItemForm/fields/CheckboxField.tsx @@ -1,91 +1,46 @@ /** - * CheckboxField Component - * - * 체크박스/스위치 필드 (checkbox, switch) + * 체크박스 필드 컴포넌트 + * 기존 ItemForm과 100% 동일한 디자인 */ 'use client'; -import { Checkbox } from '@/components/ui/checkbox'; -import { Switch } from '@/components/ui/switch'; import { Label } from '@/components/ui/label'; -import type { DynamicFieldProps } from '../types'; -import { cn } from '@/lib/utils'; +import { Checkbox } from '@/components/ui/checkbox'; +import type { DynamicFieldRendererProps } from '../types'; export function CheckboxField({ field, value, - error, onChange, - onBlur, + error, disabled, -}: DynamicFieldProps) { - const isSwitch = field.field_type === 'switch'; - const checked = value === true || value === 'true' || value === 1; - - const handleChange = (newChecked: boolean) => { - onChange(newChecked); - onBlur(); - }; - - if (isSwitch) { - return ( -
-
- - -
- - {field.help_text && !error && ( -

{field.help_text}

- )} - - {error &&

{error}

} -
- ); - } +}: DynamicFieldRendererProps) { + const fieldKey = field.field_key || `field_${field.id}`; + const boolValue = value === true || value === 'true' || value === 1; return ( -
+
onChange(checked as boolean)} + disabled={disabled} /> -
- - {field.help_text && !error && ( -

{field.help_text}

+ {error && ( +

{error}

+ )} + {!error && field.description && ( +

+ * {field.description} +

)} - - {error &&

{error}

}
); } - -export default CheckboxField; diff --git a/src/components/items/DynamicItemForm/fields/CustomField.tsx b/src/components/items/DynamicItemForm/fields/CustomField.tsx deleted file mode 100644 index dcadd75e..00000000 --- a/src/components/items/DynamicItemForm/fields/CustomField.tsx +++ /dev/null @@ -1,479 +0,0 @@ -/** - * CustomField Component - * - * 특수 필드 컴포넌트 래퍼 (전개도, BOM 등) - * - custom:drawing-canvas → DrawingCanvas - * - custom:bending-detail-table → BendingDetailTable (전개도 상세 테이블) - * - custom:bom-table → BOMSection - * - * 기존 ItemForm의 특수 컴포넌트를 재사용하면서 동적 폼과 통합 - */ - -'use client'; - -import { useState, useCallback } from 'react'; -import { Label } from '@/components/ui/label'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { FileImage, Plus, Trash2 } from 'lucide-react'; -import type { DynamicFieldProps, FormValue } from '../types'; -import type { BendingDetail, BOMLine } from '@/types/item'; -import { cn } from '@/lib/utils'; - -// ===== BOM 테이블 컴포넌트 ===== - -interface BOMTableProps { - value: BOMLine[]; - onChange: (lines: BOMLine[]) => void; - disabled?: boolean; -} - -function BOMTable({ value, onChange, disabled }: BOMTableProps) { - const bomLines = Array.isArray(value) ? value : []; - - const addLine = () => { - const newLine: BOMLine = { - id: `bom-${Date.now()}`, - childItemCode: '', - childItemName: '', - quantity: 1, - unit: 'EA', - }; - onChange([...bomLines, newLine]); - }; - - const updateLine = (index: number, field: keyof BOMLine, fieldValue: string | number) => { - const updated = [...bomLines]; - updated[index] = { ...updated[index], [field]: fieldValue }; - onChange(updated); - }; - - const removeLine = (index: number) => { - onChange(bomLines.filter((_, i) => i !== index)); - }; - - return ( - - -
- 부품 구성 (BOM) - -
-
- - {bomLines.length === 0 ? ( -
-

등록된 BOM 항목이 없습니다.

-

위 버튼을 클릭하여 품목을 추가하세요.

-
- ) : ( -
-
-
품목코드
-
품목명
-
수량
-
단위
-
-
- {bomLines.map((line, index) => ( -
- updateLine(index, 'childItemCode', e.target.value)} - disabled={disabled} - /> - updateLine(index, 'childItemName', e.target.value)} - disabled={disabled} - /> - updateLine(index, 'quantity', parseInt(e.target.value) || 1)} - disabled={disabled} - /> - updateLine(index, 'unit', e.target.value)} - disabled={disabled} - /> - -
- ))} -
- )} -
-
- ); -} - -// ===== 전개도 상세 테이블 컴포넌트 ===== - -interface BendingDetailTableProps { - value: BendingDetail[]; - onChange: (details: BendingDetail[]) => void; - disabled?: boolean; -} - -function BendingDetailTable({ value, onChange, disabled }: BendingDetailTableProps) { - const details = Array.isArray(value) ? value : []; - - // 폭 합계 계산 - const totalWidth = details.reduce((acc, d) => acc + d.input + d.elongation, 0); - - const addRow = () => { - const newRow: BendingDetail = { - id: `bend-${Date.now()}`, - no: details.length + 1, - input: 0, - elongation: -1, - calculated: 0, - sum: 0, - shaded: false, - }; - onChange([...details, newRow]); - }; - - const updateRow = (index: number, field: keyof BendingDetail, fieldValue: number | boolean) => { - const updated = [...details]; - updated[index] = { ...updated[index], [field]: fieldValue }; - - // calculated와 sum 자동 계산 - if (field === 'input' || field === 'elongation') { - const input = field === 'input' ? (fieldValue as number) : updated[index].input; - const elongation = field === 'elongation' ? (fieldValue as number) : updated[index].elongation; - updated[index].calculated = input + elongation; - - // 누적 합계 재계산 - let runningSum = 0; - for (let i = 0; i <= index; i++) { - runningSum += updated[i].input + updated[i].elongation; - updated[i].sum = runningSum; - } - } - - onChange(updated); - }; - - const removeRow = (index: number) => { - const updated = details.filter((_, i) => i !== index); - // 번호 재정렬 - updated.forEach((row, i) => { - row.no = i + 1; - }); - onChange(updated); - }; - - return ( - - -
- - - 전개도 상세 입력 - -
- - 폭 합계: {totalWidth.toFixed(1)} mm - - -
-
-
- - {details.length === 0 ? ( -
-

전개도 상세 데이터가 없습니다.

-

위 버튼을 클릭하여 행을 추가하세요.

-
- ) : ( -
-
-
No
-
입력값
-
연신율
-
계산값
-
합계
-
음영
-
-
- {details.map((row, index) => ( -
-
- {row.no} -
- updateRow(index, 'input', parseFloat(e.target.value) || 0)} - disabled={disabled} - /> - updateRow(index, 'elongation', parseFloat(e.target.value) || 0)} - disabled={disabled} - /> -
- {(row.input + row.elongation).toFixed(1)} -
-
- {row.sum.toFixed(1)} -
-
- updateRow(index, 'shaded', e.target.checked)} - disabled={disabled} - className="h-4 w-4" - /> -
- -
- ))} -
- )} -
-
- ); -} - -// ===== 전개도 캔버스 (간단 버전) ===== - -interface DrawingCanvasProps { - value: string | null; - onChange: (dataUrl: string | null) => void; - disabled?: boolean; -} - -function DrawingCanvasSimple({ value, onChange, disabled }: DrawingCanvasProps) { - const [inputMethod, setInputMethod] = useState<'file' | 'drawing'>('file'); - - const handleFileChange = (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - - const reader = new FileReader(); - reader.onload = (event) => { - onChange(event.target?.result as string); - }; - reader.readAsDataURL(file); - }; - - const handleClear = () => { - onChange(null); - }; - - return ( - - - - - 전개도 이미지 - - - - {/* 입력 방식 선택 */} -
- - -
- - {/* 파일 업로드 */} - {inputMethod === 'file' && ( -
- {value ? ( -
- 전개도 - -
- ) : ( -
- - -
- )} -
- )} - - {/* 직접 그리기 (플레이스홀더) */} - {inputMethod === 'drawing' && ( -
-

직접 그리기 기능은 준비 중입니다.

-

기존 DrawingCanvas 컴포넌트와 통합 예정

-
- )} -
-
- ); -} - -// ===== 메인 CustomField 컴포넌트 ===== - -export function CustomField({ - field, - value, - error, - onChange, - onBlur, - disabled, -}: DynamicFieldProps) { - const renderCustomComponent = () => { - switch (field.field_type) { - case 'custom:drawing-canvas': - return ( - { - onChange(dataUrl); - onBlur(); - }} - disabled={disabled} - /> - ); - - case 'custom:bending-detail-table': - return ( - { - onChange(details as unknown as FormValue); - onBlur(); - }} - disabled={disabled} - /> - ); - - case 'custom:bom-table': - return ( - { - onChange(lines as unknown as FormValue); - onBlur(); - }} - disabled={disabled} - /> - ); - - default: - return ( -
-

- 알 수 없는 커스텀 필드 타입: {field.field_type} -

-
- ); - } - }; - - // 커스텀 필드는 자체 레이블이 있으므로 별도 레이블 불필요 - return ( -
- {renderCustomComponent()} - - {field.help_text && !error && ( -

{field.help_text}

- )} - - {error &&

{error}

} -
- ); -} - -export default CustomField; \ No newline at end of file diff --git a/src/components/items/DynamicItemForm/fields/DateField.tsx b/src/components/items/DynamicItemForm/fields/DateField.tsx index 27b69515..130e4cb7 100644 --- a/src/components/items/DynamicItemForm/fields/DateField.tsx +++ b/src/components/items/DynamicItemForm/fields/DateField.tsx @@ -1,100 +1,46 @@ /** - * DateField Component - * - * 날짜 선택 필드 (date, date-range) + * 날짜 입력 필드 컴포넌트 + * 기존 ItemForm과 100% 동일한 디자인 */ 'use client'; -import { useState } from 'react'; -import { format } from 'date-fns'; -import { ko } from 'date-fns/locale'; -import { CalendarIcon } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Calendar } from '@/components/ui/calendar'; import { Label } from '@/components/ui/label'; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@/components/ui/popover'; -import type { DynamicFieldProps } from '../types'; -import { cn } from '@/lib/utils'; +import { Input } from '@/components/ui/input'; +import type { DynamicFieldRendererProps } from '../types'; export function DateField({ field, value, - error, onChange, - onBlur, + error, disabled, -}: DynamicFieldProps) { - const [open, setOpen] = useState(false); - - // 값을 Date 객체로 변환 - const dateValue = value ? new Date(value as string) : undefined; - const isValidDate = dateValue && !isNaN(dateValue.getTime()); - - const handleSelect = (date: Date | undefined) => { - if (date) { - // ISO 문자열로 변환 (YYYY-MM-DD) - onChange(format(date, 'yyyy-MM-dd')); - } else { - onChange(null); - } - setOpen(false); - onBlur(); - }; +}: DynamicFieldRendererProps) { + const fieldKey = field.field_key || `field_${field.id}`; + const stringValue = value !== null && value !== undefined ? String(value) : ''; return ( -
-