diff --git a/claudedocs/_index.md b/claudedocs/_index.md index 72a9dbe7..f28b089c 100644 --- a/claudedocs/_index.md +++ b/claudedocs/_index.md @@ -152,6 +152,8 @@ claudedocs/ | 파일 | μ„€λͺ… | |------|------| +| `[DESIGN-2025-12-20] item-master-zustand-refactoring.md` | πŸ”΄ **핡심** - ν’ˆλͺ©κΈ°μ€€κ΄€λ¦¬ Zustand λ¦¬νŒ©ν† λ§ μ„€κ³„μ„œ (3λ°©ν–₯ 동기화 β†’ μ •κ·œν™” μƒνƒœ, ν…ŒμŠ€νŠΈ νŽ˜μ΄μ§€ μ „λž΅) | +| `[NEXT-2025-12-20] zustand-refactoring-session-context.md` | ⭐ **μ„Έμ…˜ 체크포인트** - Phase 1 μ‹œμž‘ μ „, λ‹€μŒ μ„Έμ…˜ μ΄μ–΄ν•˜κΈ°μš© | | `multi-tenancy-implementation.md` | λ©€ν‹°ν…Œλ„Œμ‹œ κ΅¬ν˜„ | | `multi-tenancy-test-guide.md` | λ©€ν‹°ν…Œλ„Œμ‹œ ν…ŒμŠ€νŠΈ | | `architecture-integration-risks.md` | 톡합 리슀크 | diff --git a/claudedocs/architecture/[DESIGN-2025-12-20] item-master-zustand-refactoring.md b/claudedocs/architecture/[DESIGN-2025-12-20] item-master-zustand-refactoring.md new file mode 100644 index 00000000..2639c3f2 --- /dev/null +++ b/claudedocs/architecture/[DESIGN-2025-12-20] item-master-zustand-refactoring.md @@ -0,0 +1,538 @@ +# ν’ˆλͺ©κΈ°μ€€κ΄€λ¦¬ Zustand λ¦¬νŒ©ν† λ§ μ„€κ³„μ„œ + +> **핡심 λͺ©ν‘œ**: λͺ¨λ“  κΈ°λŠ₯을 100% λ™μΌν•˜κ²Œ μœ μ§€ν•˜λ©΄μ„œ, μˆ˜μ • 절차λ₯Ό 간단화 + +## πŸ“Œ 핡심 원칙 + +``` +⚠️ μ€‘μš”: λͺ¨λ“  ν’ˆλͺ©κΈ°μ€€κ΄€λ¦¬ κΈ°λŠ₯을 κ·ΈλŒ€λ‘œ 가져와야 함 +⚠️ μ€‘μš”: μˆ˜μ • 절차 간단화가 핡심 (3λ°©ν–₯ 동기화 β†’ 1κ³³ μˆ˜μ •) +⚠️ μ€‘μš”: λͺ¨λ“  κΈ°λŠ₯이 μ •ν™•νžˆ λ™μΌν•˜κ²Œ μž‘λ™ν•΄μ•Ό 함 +``` + +## πŸ”΄ μ΅œμ’… 검증 κΈ°μ€€ (κ°€μž₯ μ€‘μš”!) + +### νŽ˜μ΄μ§€ 관계도 + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ [DB / API] β”‚ +β”‚ (단일 μ§„μ‹€ 곡급원) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + ↑ ↑ ↓ + β”‚ β”‚ β”‚ +β”Œβ”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ ν’ˆλͺ©κΈ°μ€€κ΄€λ¦¬ β”‚ β”‚ ν’ˆλͺ©κΈ°μ€€κ΄€λ¦¬ β”‚ β”‚ ν’ˆλͺ©κ΄€λ¦¬ β”‚ +β”‚ ν…ŒμŠ€νŠΈ νŽ˜μ΄μ§€ β”‚ β”‚ νŽ˜μ΄μ§€ (κΈ°μ‘΄) β”‚ β”‚ νŽ˜μ΄μ§€ β”‚ +β”‚ (Zustand) β”‚ β”‚ (Context) β”‚ β”‚ (동적 폼 λ Œλ”λ§) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + [μ‹ κ·œ] [κΈ°μ‘΄] [μ΅œμ’… μ‚¬μš©μ²˜] +``` + +### 검증 μ‹œλ‚˜λ¦¬μ˜€ + +``` +1. ν…ŒμŠ€νŠΈ νŽ˜μ΄μ§€μ—μ„œ μ„Ήμ…˜/ν•„λ“œ μˆ˜μ • + ↓ +2. API 호좜 β†’ DB μ €μž₯ + ↓ +3. ν’ˆλͺ©κΈ°μ€€κ΄€λ¦¬ νŽ˜μ΄μ§€ (κΈ°μ‘΄)μ—μ„œ λ™μΌν•˜κ²Œ ν‘œμ‹œλ˜μ–΄μ•Ό 함 + ↓ +4. ν’ˆλͺ©κ΄€λ¦¬ νŽ˜μ΄μ§€μ—μ„œ 동적 폼이 λ³€κ²½λœ ꡬ쑰둜 λ Œλ”λ§λ˜μ–΄μ•Ό 함 +``` + +### ν•„μˆ˜ 검증 ν•­λͺ© + +| # | 검증 ν•­λͺ© | μ„€λͺ… | +|---|----------|------| +| 1 | **API 동일성** | ν…ŒμŠ€νŠΈ νŽ˜μ΄μ§€κ°€ κΈ°μ‘΄ νŽ˜μ΄μ§€μ™€ λ™μΌν•œ API μ—”λ“œν¬μΈνŠΈ μ‚¬μš© | +| 2 | **데이터 동일성** | API 응닡/μš”μ²­ 데이터 ν˜•μ‹ 100% 동일 | +| 3 | **κΈ°μ‘΄ νŽ˜μ΄μ§€ 반영** | ν…ŒμŠ€νŠΈ νŽ˜μ΄μ§€μ—μ„œ μˆ˜μ • β†’ κΈ°μ‘΄ ν’ˆλͺ©κΈ°μ€€κ΄€λ¦¬ νŽ˜μ΄μ§€μ— 반영 | +| 4 | **ν’ˆλͺ©κ΄€λ¦¬ 반영** | ν…ŒμŠ€νŠΈ νŽ˜μ΄μ§€μ—μ„œ μˆ˜μ • β†’ ν’ˆλͺ©κ΄€λ¦¬ 동적 폼에 반영 | + +### μ™œ 이게 μ€‘μš”ν•œκ°€? + +``` +ν…ŒμŠ€νŠΈ νŽ˜μ΄μ§€ (Zustand) ──┐ + β”œβ”€β”€β†’ 같은 API ──→ 같은 DB ──→ ν’ˆλͺ©κ΄€λ¦¬ νŽ˜μ΄μ§€ +κΈ°μ‘΄ νŽ˜μ΄μ§€ (Context) β”€β”€β”€β”€β”˜ + +β†’ μƒνƒœ 관리 λ°©μ‹λ§Œ λ‹€λ₯΄κ³ , API/DBλŠ” 곡유 +β†’ ν…ŒμŠ€νŠΈ νŽ˜μ΄μ§€μ—μ„œ μˆ˜μ •ν•œ λ‚΄μš©μ΄ ν’ˆλͺ©κ΄€λ¦¬ νŽ˜μ΄μ§€μ— κ·ΈλŒ€λ‘œ μ μš©λ˜μ–΄μ•Ό 함 +β†’ 이것이 μ„±κ³΅ν•˜λ©΄ Zustand λ¦¬νŒ©ν† λ§μ΄ μ™„μ „νžˆ κ²€μ¦λœ 것 +``` + +--- + +## 1. ν˜„μž¬ 문제점 뢄석 + +### 1.1 쀑볡 μƒνƒœ 관리 (3λ°©ν–₯ 동기화) + +ν˜„μž¬ `ItemMasterContext.tsx`μ—μ„œ μ„Ήμ…˜ μˆ˜μ • μ‹œ: + +```typescript +// updateSection() ν•¨μˆ˜ λ‚΄λΆ€ (Line 1464-1486) +setItemPages(...) // 1. 계측ꡬ쑰 νƒ­ +setSectionTemplates(...) // 2. μ„Ήμ…˜ νƒ­ +setIndependentSections(...) // 3. 독립 μ„Ήμ…˜ +``` + +**문제점**: +- 같은 데이터λ₯Ό 3κ³³μ—μ„œ 쀑볡 관리 +- ν•œ κ³³ μ—…λ°μ΄νŠΈ λˆ„λ½ μ‹œ 데이터 뢈일치 +- λͺ¨λ“  CRUD ν•¨μˆ˜μ— 동일 νŒ¨ν„΄ 반볡 +- μƒˆ κΈ°λŠ₯ μΆ”κ°€ μ‹œ 3κ³³ λͺ¨λ‘ μˆ˜μ • ν•„μš” + +### 1.2 ν˜„μž¬ μƒνƒœ λ³€μˆ˜ λͺ©λ‘ (16개) + +| # | μƒνƒœ λ³€μˆ˜ | μ„€λͺ… | 쀑볡 μ—¬λΆ€ | +|---|----------|------|----------| +| 1 | `itemMasters` | ν’ˆλͺ© λ§ˆμŠ€ν„° | - | +| 2 | `specificationMasters` | 규격 λ§ˆμŠ€ν„° | - | +| 3 | `materialItemNames` | 자재 ν’ˆλͺ©λͺ… | - | +| 4 | `itemCategories` | ν’ˆλͺ© λΆ„λ₯˜ | - | +| 5 | `itemUnits` | λ‹¨μœ„ | - | +| 6 | `itemMaterials` | 재질 | - | +| 7 | `surfaceTreatments` | ν‘œλ©΄μ²˜λ¦¬ | - | +| 8 | `partTypeOptions` | λΆ€ν’ˆμœ ν˜• μ˜΅μ…˜ | - | +| 9 | `partUsageOptions` | λΆ€ν’ˆμš©λ„ μ˜΅μ…˜ | - | +| 10 | `guideRailOptions` | κ°€μ΄λ“œλ ˆμΌ μ˜΅μ…˜ | - | +| 11 | `sectionTemplates` | μ„Ήμ…˜ ν…œν”Œλ¦Ώ | ⚠️ 쀑볡 | +| 12 | `itemMasterFields` | ν•„λ“œ λ§ˆμŠ€ν„° | ⚠️ 쀑볡 | +| 13 | `itemPages` | νŽ˜μ΄μ§€ (μ„Ήμ…˜/ν•„λ“œ 포함) | ⚠️ 쀑볡 | +| 14 | `independentSections` | 독립 μ„Ήμ…˜ | ⚠️ 쀑볡 | +| 15 | `independentFields` | 독립 ν•„λ“œ | ⚠️ 쀑볡 | +| 16 | `independentBomItems` | 독립 BOM | ⚠️ 쀑볡 | + +**쀑볡 λ¬Έμ œκ°€ μžˆλŠ” μ—”ν‹°ν‹°**: +- **μ„Ήμ…˜**: `sectionTemplates`, `itemPages.sections`, `independentSections` +- **ν•„λ“œ**: `itemMasterFields`, `itemPages.sections.fields`, `independentFields` +- **BOM**: `itemPages.sections.bom_items`, `independentBomItems` + +--- + +## 2. λ¦¬νŒ©ν† λ§ 섀계 + +### 2.1 μ •κ·œν™”λœ μƒνƒœ ꡬ쑰 (Normalized State) + +```typescript +// stores/useItemMasterStore.ts +interface ItemMasterState { + // ===== μ •κ·œν™”λœ μ—”ν‹°ν‹° (ID 기반 λ”•μ…”λ„ˆλ¦¬) ===== + entities: { + pages: Record; + sections: Record; + fields: Record; + bomItems: Record; + }; + + // ===== ID λͺ©λ‘ (μˆœμ„œ 관리) ===== + ids: { + pages: number[]; + independentSections: number[]; // page_idκ°€ null인 μ„Ήμ…˜ + independentFields: number[]; // section_idκ°€ null인 ν•„λ“œ + independentBomItems: number[]; // section_idκ°€ null인 BOM + }; + + // ===== μ°Έμ‘° 데이터 (쀑볡 μ—†μŒ) ===== + references: { + itemMasters: ItemMaster[]; + specificationMasters: SpecificationMaster[]; + materialItemNames: MaterialItemName[]; + itemCategories: ItemCategory[]; + itemUnits: ItemUnit[]; + itemMaterials: ItemMaterial[]; + surfaceTreatments: SurfaceTreatment[]; + partTypeOptions: PartTypeOption[]; + partUsageOptions: PartUsageOption[]; + guideRailOptions: GuideRailOption[]; + }; + + // ===== UI μƒνƒœ ===== + ui: { + isLoading: boolean; + error: string | null; + selectedPageId: number | null; + selectedSectionId: number | null; + }; +} +``` + +### 2.2 μ—”ν‹°ν‹° ꡬ쑰 + +```typescript +// νŽ˜μ΄μ§€ μ—”ν‹°ν‹° (μ„Ήμ…˜ ID만 μ°Έμ‘°) +interface PageEntity { + id: number; + page_name: string; + item_type: string; + description?: string; + order_no: number; + is_active: boolean; + sectionIds: number[]; // μ„Ήμ…˜ 객체 λŒ€μ‹  ID만 μ €μž₯ + created_at?: string; + updated_at?: string; +} + +// μ„Ήμ…˜ μ—”ν‹°ν‹° (ν•„λ“œ/BOM ID만 μ°Έμ‘°) +interface SectionEntity { + id: number; + title: string; + page_id: number | null; // null이면 독립 μ„Ήμ…˜ + order_no: number; + is_collapsible: boolean; + default_open: boolean; + fieldIds: number[]; // ν•„λ“œ ID λͺ©λ‘ + bomItemIds: number[]; // BOM ID λͺ©λ‘ + created_at?: string; + updated_at?: string; +} + +// ν•„λ“œ μ—”ν‹°ν‹° +interface FieldEntity { + id: number; + field_key: string; + field_name: string; + field_type: string; + section_id: number | null; // null이면 독립 ν•„λ“œ + order_no: number; + is_required: boolean; + options?: any; + default_value?: any; + created_at?: string; + updated_at?: string; +} + +// BOM μ—”ν‹°ν‹° +interface BOMItemEntity { + id: number; + section_id: number | null; // null이면 독립 BOM + child_item_code: string; + child_item_name: string; + quantity: number; + unit: string; + order_no: number; + created_at?: string; + updated_at?: string; +} +``` + +### 2.3 μˆ˜μ • 절차 비ꡐ + +#### Before (ν˜„μž¬): 3λ°©ν–₯ 동기화 + +```typescript +const updateSection = async (sectionId, updates) => { + const response = await api.update(sectionId, updates); + + // 1. itemPages μ—…λ°μ΄νŠΈ + setItemPages(prev => prev.map(page => ({ + ...page, + sections: page.sections.map(s => s.id === sectionId ? {...s, ...updates} : s) + }))); + + // 2. sectionTemplates μ—…λ°μ΄νŠΈ + setSectionTemplates(prev => prev.map(t => + t.id === sectionId ? {...t, ...updates} : t + )); + + // 3. independentSections μ—…λ°μ΄νŠΈ + setIndependentSections(prev => prev.map(s => + s.id === sectionId ? {...s, ...updates} : s + )); +}; +``` + +#### After (Zustand): 1곳만 μˆ˜μ • + +```typescript +const updateSection = async (sectionId, updates) => { + const response = await api.update(sectionId, updates); + + // λ”± 1곳만 μˆ˜μ •ν•˜λ©΄ 끝! + set((state) => ({ + entities: { + ...state.entities, + sections: { + ...state.entities.sections, + [sectionId]: { ...state.entities.sections[sectionId], ...updates } + } + } + })); +}; +``` + +### 2.4 νŒŒμƒ μƒνƒœ (Selectors) + +```typescript +// 계측ꡬ쑰 νƒ­μš©: νŽ˜μ΄μ§€ + μ„Ήμ…˜ + ν•„λ“œ μ‘°ν•© +const usePageWithDetails = (pageId: number) => { + return useItemMasterStore((state) => { + const page = state.entities.pages[pageId]; + if (!page) return null; + + return { + ...page, + sections: page.sectionIds.map(sId => { + const section = state.entities.sections[sId]; + return { + ...section, + fields: section.fieldIds.map(fId => state.entities.fields[fId]), + bom_items: section.bomItemIds.map(bId => state.entities.bomItems[bId]), + }; + }), + }; + }); +}; + +// μ„Ήμ…˜ νƒ­μš©: λͺ¨λ“  μ„Ήμ…˜ (νŽ˜μ΄μ§€ μ—°κ²° μ—¬λΆ€ 무관) +const useAllSections = () => { + return useItemMasterStore((state) => + Object.values(state.entities.sections) + ); +}; + +// 독립 μ„Ήμ…˜λ§Œ +const useIndependentSections = () => { + return useItemMasterStore((state) => + state.ids.independentSections.map(id => state.entities.sections[id]) + ); +}; +``` + +--- + +## 3. κΈ°λŠ₯ λ§€ν•‘ 체크리슀트 + +### 3.1 νŽ˜μ΄μ§€ 관리 + +| κΈ°μ‘΄ ν•¨μˆ˜ | μƒˆ ν•¨μˆ˜ | μƒνƒœ | +|----------|--------|------| +| `loadItemPages` | `loadPages` | ⬜ | +| `addItemPage` | `createPage` | ⬜ | +| `updateItemPage` | `updatePage` | ⬜ | +| `deleteItemPage` | `deletePage` | ⬜ | + +### 3.2 μ„Ήμ…˜ 관리 + +| κΈ°μ‘΄ ν•¨μˆ˜ | μƒˆ ν•¨μˆ˜ | μƒνƒœ | +|----------|--------|------| +| `loadSectionTemplates` | `loadSections` | ⬜ | +| `loadIndependentSections` | (loadSections에 톡합) | ⬜ | +| `addSectionTemplate` | `createSection` | ⬜ | +| `addSectionToPage` | `createSectionInPage` | ⬜ | +| `createIndependentSection` | `createSection` (page_id: null) | ⬜ | +| `updateSectionTemplate` | `updateSection` | ⬜ | +| `updateSection` | `updateSection` | ⬜ | +| `deleteSectionTemplate` | `deleteSection` | ⬜ | +| `deleteSection` | `deleteSection` | ⬜ | +| `linkSectionToPage` | `linkSectionToPage` | ⬜ | +| `unlinkSectionFromPage` | `unlinkSectionFromPage` | ⬜ | +| `getSectionUsage` | `getSectionUsage` | ⬜ | + +### 3.3 ν•„λ“œ 관리 + +| κΈ°μ‘΄ ν•¨μˆ˜ | μƒˆ ν•¨μˆ˜ | μƒνƒœ | +|----------|--------|------| +| `loadItemMasterFields` | `loadFields` | ⬜ | +| `loadIndependentFields` | (loadFields에 톡합) | ⬜ | +| `addItemMasterField` | `createField` | ⬜ | +| `addFieldToSection` | `createFieldInSection` | ⬜ | +| `createIndependentField` | `createField` (section_id: null) | ⬜ | +| `updateItemMasterField` | `updateField` | ⬜ | +| `updateField` | `updateField` | ⬜ | +| `deleteItemMasterField` | `deleteField` | ⬜ | +| `deleteField` | `deleteField` | ⬜ | +| `linkFieldToSection` | `linkFieldToSection` | ⬜ | +| `unlinkFieldFromSection` | `unlinkFieldFromSection` | ⬜ | +| `getFieldUsage` | `getFieldUsage` | ⬜ | + +### 3.4 BOM 관리 + +| κΈ°μ‘΄ ν•¨μˆ˜ | μƒˆ ν•¨μˆ˜ | μƒνƒœ | +|----------|--------|------| +| `loadIndependentBomItems` | `loadBomItems` | ⬜ | +| `addBOMItem` | `createBomItem` | ⬜ | +| `createIndependentBomItem` | `createBomItem` (section_id: null) | ⬜ | +| `updateBOMItem` | `updateBomItem` | ⬜ | +| `deleteBOMItem` | `deleteBomItem` | ⬜ | + +### 3.5 μ°Έμ‘° 데이터 관리 + +| κΈ°μ‘΄ ν•¨μˆ˜ | μƒˆ ν•¨μˆ˜ | μƒνƒœ | +|----------|--------|------| +| `addItemMaster` / `updateItemMaster` / `deleteItemMaster` | `itemMasterActions` | ⬜ | +| `addSpecificationMaster` / `updateSpecificationMaster` / `deleteSpecificationMaster` | `specificationActions` | ⬜ | +| `addMaterialItemName` / `updateMaterialItemName` / `deleteMaterialItemName` | `materialItemNameActions` | ⬜ | +| `addItemCategory` / `updateItemCategory` / `deleteItemCategory` | `categoryActions` | ⬜ | +| `addItemUnit` / `updateItemUnit` / `deleteItemUnit` | `unitActions` | ⬜ | +| `addItemMaterial` / `updateItemMaterial` / `deleteItemMaterial` | `materialActions` | ⬜ | +| `addSurfaceTreatment` / `updateSurfaceTreatment` / `deleteSurfaceTreatment` | `surfaceTreatmentActions` | ⬜ | +| `addPartTypeOption` / `updatePartTypeOption` / `deletePartTypeOption` | `partTypeActions` | ⬜ | +| `addPartUsageOption` / `updatePartUsageOption` / `deletePartUsageOption` | `partUsageActions` | ⬜ | +| `addGuideRailOption` / `updateGuideRailOption` / `deleteGuideRailOption` | `guideRailActions` | ⬜ | + +--- + +## 4. κ΅¬ν˜„ κ³„νš + +### Phase 1: 기반 ꡬ좕 βœ… μ™„λ£Œ (2025-12-20) + +- [x] Zustand, Immer μ„€μΉ˜ +- [x] ν…ŒμŠ€νŠΈ νŽ˜μ΄μ§€ 라우트 생성 (`/items-management-test`) +- [x] κΈ°λ³Έ μŠ€ν† μ–΄ ꡬ쑰 생성 (`useItemMasterStore.ts`) +- [x] νƒ€μž… μ •μ˜ (`types.ts`) + +### Phase 2: API 연동 βœ… μ™„λ£Œ (2025-12-20) + +- [x] κΈ°μ‘΄ API ꡬ쑰 뢄석 (`item-master.ts`) +- [x] API 응닡 β†’ μ •κ·œν™” μƒνƒœ λ³€ν™˜ ν•¨μˆ˜ (`normalizers.ts`) +- [x] μŠ€ν† μ–΄μ— `initFromApi()` ν•¨μˆ˜ κ΅¬ν˜„ +- [x] ν…ŒμŠ€νŠΈ νŽ˜μ΄μ§€μ—μ„œ μ‹€μ œ API 데이터 λ‘œλ“œ κΈ°λŠ₯ μΆ”κ°€ + +**μƒμ„±λœ 파일**: +- `src/stores/item-master/normalizers.ts` - API 응닡 μ •κ·œν™” ν•¨μˆ˜ + +**ν…ŒμŠ€νŠΈ νŽ˜μ΄μ§€ κΈ°λŠ₯**: +- "μ‹€μ œ API λ‘œλ“œ" λ²„νŠΌ - λ°±μ—”λ“œ APIμ—μ„œ μ‹€μ œ 데이터 λ‘œλ“œ +- "ν…ŒμŠ€νŠΈ 데이터 λ‘œλ“œ" λ²„νŠΌ - ν•˜λ“œμ½”λ”©λœ ν…ŒμŠ€νŠΈ 데이터 λ‘œλ“œ +- 데이터 μ†ŒμŠ€ ν‘œμ‹œ (API/ν…ŒμŠ€νŠΈ/μ—†μŒ) + +### Phase 3: 핡심 μ—”ν‹°ν‹° κ΅¬ν˜„ + +- [x] νŽ˜μ΄μ§€ CRUD κ΅¬ν˜„ (둜컬 μƒνƒœ) +- [x] μ„Ήμ…˜ CRUD κ΅¬ν˜„ (둜컬 μƒνƒœ) +- [x] ν•„λ“œ CRUD κ΅¬ν˜„ (둜컬 μƒνƒœ) +- [x] BOM CRUD κ΅¬ν˜„ (둜컬 μƒνƒœ) +- [x] link/unlink κΈ°λŠ₯ κ΅¬ν˜„ (둜컬 μƒνƒœ) +- [ ] API 연동 CRUD (DB μ €μž₯) - **λ‹€μŒ 단계** + +### Phase 3: μ°Έμ‘° 데이터 κ΅¬ν˜„ + +- [ ] ν’ˆλͺ© λ§ˆμŠ€ν„° 관리 +- [ ] 규격 λ§ˆμŠ€ν„° 관리 +- [ ] λΆ„λ₯˜/λ‹¨μœ„/재질 λ“± μ˜΅μ…˜ 관리 + +### Phase 4: νŒŒμƒ μƒνƒœ & μ…€λ ‰ν„° + +- [ ] 계측ꡬ쑰 뷰용 μ…€λ ‰ν„° +- [ ] μ„Ήμ…˜ νƒ­μš© μ…€λ ‰ν„° +- [ ] ν•„λ“œ νƒ­μš© μ…€λ ‰ν„° +- [ ] 독립 ν•­λͺ© μ…€λ ‰ν„° + +### Phase 5: UI 연동 + +- [ ] ν…ŒμŠ€νŠΈ νŽ˜μ΄μ§€ μ»΄ν¬λ„ŒνŠΈ 생성 +- [ ] κΈ°μ‘΄ μ»΄ν¬λ„ŒνŠΈ μž¬μ‚¬μš© (μŠ€ν† μ–΄λ§Œ ꡐ체) +- [ ] λ™μž‘ 검증 + +### Phase 6: 검증 & λ§ˆμ΄κ·Έλ ˆμ΄μ…˜ + +- [ ] κΈ°μ‘΄ νŽ˜μ΄μ§€μ™€ 1:1 λ™μž‘ 비ꡐ +- [ ] μ—£μ§€ μΌ€μ΄μŠ€ ν…ŒμŠ€νŠΈ +- [ ] μ„±λŠ₯ 비ꡐ +- [ ] κΈ°μ‘΄ νŽ˜μ΄μ§€ λ§ˆμ΄κ·Έλ ˆμ΄μ…˜ κ²°μ • + +--- + +## 5. 파일 ꡬ쑰 + +``` +src/ +β”œβ”€β”€ stores/ +β”‚ └── item-master/ +β”‚ β”œβ”€β”€ useItemMasterStore.ts # 메인 μŠ€ν† μ–΄ +β”‚ β”œβ”€β”€ slices/ +β”‚ β”‚ β”œβ”€β”€ pageSlice.ts # νŽ˜μ΄μ§€ μ•‘μ…˜ +β”‚ β”‚ β”œβ”€β”€ sectionSlice.ts # μ„Ήμ…˜ μ•‘μ…˜ +β”‚ β”‚ β”œβ”€β”€ fieldSlice.ts # ν•„λ“œ μ•‘μ…˜ +β”‚ β”‚ β”œβ”€β”€ bomSlice.ts # BOM μ•‘μ…˜ +β”‚ β”‚ └── referenceSlice.ts # μ°Έμ‘° 데이터 μ•‘μ…˜ +β”‚ β”œβ”€β”€ selectors/ +β”‚ β”‚ β”œβ”€β”€ pageSelectors.ts # νŽ˜μ΄μ§€ νŒŒμƒ μƒνƒœ +β”‚ β”‚ β”œβ”€β”€ sectionSelectors.ts # μ„Ήμ…˜ νŒŒμƒ μƒνƒœ +β”‚ β”‚ └── fieldSelectors.ts # ν•„λ“œ νŒŒμƒ μƒνƒœ +β”‚ └── types.ts # νƒ€μž… μ •μ˜ +β”‚ +β”œβ”€β”€ app/[locale]/(protected)/ +β”‚ └── items-management-test/ +β”‚ └── page.tsx # ν…ŒμŠ€νŠΈ νŽ˜μ΄μ§€ +``` + +--- + +## 6. ν…ŒμŠ€νŠΈ μ‹œλ‚˜λ¦¬μ˜€ + +### 6.1 μ„Ήμ…˜ μˆ˜μ • 동기화 ν…ŒμŠ€νŠΈ + +``` +μ‹œλ‚˜λ¦¬μ˜€: μ„Ήμ…˜ 이름 μˆ˜μ • +1. 계측ꡬ쑰 νƒ­μ—μ„œ μ„Ήμ…˜ 선택 +2. μ„Ήμ…˜ 이름 "기본정보" β†’ "κΈ°λ³Έ 정보" μˆ˜μ • +3. 검증: + - [ ] 계측ꡬ쑰 탭에 반영 + - [ ] μ„Ήμ…˜ 탭에 반영 + - [ ] 독립 μ„Ήμ…˜(μ—°κ²° ν•΄μ œ μ‹œ) 반영 + - [ ] API 호좜 1회만 λ°œμƒ +``` + +### 6.2 ν•„λ“œ 이동 ν…ŒμŠ€νŠΈ + +``` +μ‹œλ‚˜λ¦¬μ˜€: ν•„λ“œλ₯Ό λ‹€λ₯Έ μ„Ήμ…˜μœΌλ‘œ 이동 +1. μ„Ήμ…˜ Aμ—μ„œ ν•„λ“œ 선택 +2. μ„Ήμ…˜ B둜 이동 (unlink β†’ link) +3. 검증: + - [ ] μ„Ήμ…˜ Aμ—μ„œ ν•„λ“œ 제거 + - [ ] μ„Ήμ…˜ B에 ν•„λ“œ μΆ”κ°€ + - [ ] 계측ꡬ쑰 νƒ­ 반영 + - [ ] ν•„λ“œ νƒ­μ—μ„œ section_id λ³€κ²½ +``` + +### 6.3 독립 β†’ μ—°κ²° ν…ŒμŠ€νŠΈ + +``` +μ‹œλ‚˜λ¦¬μ˜€: 독립 μ„Ήμ…˜μ„ νŽ˜μ΄μ§€μ— μ—°κ²° +1. 독립 μ„Ήμ…˜ 선택 +2. νŽ˜μ΄μ§€μ— μ—°κ²° (linkSectionToPage) +3. 검증: + - [ ] 독립 μ„Ήμ…˜ λͺ©λ‘μ—μ„œ 제거 + - [ ] νŽ˜μ΄μ§€μ˜ μ„Ήμ…˜ λͺ©λ‘μ— μΆ”κ°€ + - [ ] μ„Ήμ…˜ νƒ­μ—μ„œ page_id λ³€κ²½ +``` + +--- + +## 7. λ‘€λ°± κ³„νš + +문제 λ°œμƒ μ‹œ: +1. ν…ŒμŠ€νŠΈ νŽ˜μ΄μ§€ 라우트 제거 +2. μŠ€ν† μ–΄ μ½”λ“œ μ‚­μ œ +3. κΈ°μ‘΄ `ItemMasterContext` κ·ΈλŒ€λ‘œ μ‚¬μš© + +**리슀크 μ΅œμ†Œν™”**: +- κΈ°μ‘΄ μ½”λ“œ μˆ˜μ • μ—†μŒ +- μƒˆ μ½”λ“œλ§Œ μΆ”κ°€ +- μ–Έμ œλ“  λ‘€λ°± κ°€λŠ₯ + +--- + +## 8. 성곡 κΈ°μ€€ + +| ν•­λͺ© | κΈ°μ€€ | +|-----|------| +| **κΈ°λŠ₯ 동등성** | κΈ°μ‘΄ λͺ¨λ“  κΈ°λŠ₯ 100% λ™μž‘ | +| **동기화** | 1κ³³ μˆ˜μ •μœΌλ‘œ λͺ¨λ“  λ·° μ—…λ°μ΄νŠΈ | +| **μ½”λ“œλŸ‰** | CRUD ν•¨μˆ˜ μ½”λ“œ 50% 이상 κ°μ†Œ | +| **버그** | 데이터 뢈일치 버그 0건 | +| **μ„±λŠ₯** | κΈ°μ‘΄ λŒ€λΉ„ 동등 λ˜λŠ” ν–₯상 | + +--- + +## λ³€κ²½ 이λ ₯ + +| λ‚ μ§œ | μž‘μ„±μž | λ‚΄μš© | +|-----|--------|------| +| 2025-12-20 | Claude | μ΄ˆμ•ˆ μž‘μ„± | +| 2025-12-20 | Claude | Phase 1 μ™„λ£Œ - 기반 ꡬ좕 | +| 2025-12-20 | Claude | Phase 2 μ™„λ£Œ - API 연동 (normalizers.ts, initFromApi) | \ No newline at end of file diff --git a/claudedocs/architecture/[NEXT-2025-12-20] zustand-refactoring-session-context.md b/claudedocs/architecture/[NEXT-2025-12-20] zustand-refactoring-session-context.md new file mode 100644 index 00000000..5678d808 --- /dev/null +++ b/claudedocs/architecture/[NEXT-2025-12-20] zustand-refactoring-session-context.md @@ -0,0 +1,295 @@ +# ν’ˆλͺ©κΈ°μ€€κ΄€λ¦¬ Zustand λ¦¬νŒ©ν† λ§ - μ„Έμ…˜ μ»¨ν…μŠ€νŠΈ + +> λ‹€μŒ μ„Έμ…˜μ—μ„œ 이 λ¬Έμ„œλ₯Ό λ¨Όμ € 읽고 μž‘μ—… 이어가기 + +## 🎯 ν”„λ‘œμ νŠΈ λͺ©ν‘œ + +**핡심 λͺ©ν‘œ:** +1. ν’ˆλͺ©κΈ°μ€€κ΄€λ¦¬ 100% 동일 κΈ°λŠ₯ κ΅¬ν˜„ +2. **더 μœ μ—°ν•œ 데이터 관리** (Zustand μ •κ·œν™” ꡬ쑰) +3. **κ°œμ„ λœ UX** (Context 3λ°©ν–₯ 동기화 β†’ Zustand 1κ³³ μˆ˜μ •) + +**μ ‘κ·Ό 방식:** +- κΈ°μ‘΄ μ»΄ν¬λ„ŒνŠΈ μž¬μ‚¬μš© ❌ +- ν…ŒμŠ€νŠΈ νŽ˜μ΄μ§€μ—μ„œ μ™„μ „νžˆ μƒˆλ‘œ κ΅¬ν˜„ βœ… +- λΆ„λ¦¬λœ μƒνƒœ μœ μ§€ β†’ 볡ꡬ μ‹œλ‚˜λ¦¬μ˜€ 보μž₯ + +--- + +## μ„Έμ…˜ μš”μ•½ (2025-12-21 - 10μ°¨ μ„Έμ…˜) + +### βœ… 였늘 μ™„λ£Œλœ μž‘μ—… + +1. **κΈ°μ‘΄ ν’ˆλͺ©κΈ°μ€€κ΄€λ¦¬μ™€ κΈ°λŠ₯ 비ꡐ 뢄석** + - κΈ°μ‘΄ νŽ˜μ΄μ§€μ˜ λͺ¨λ“  핡심 κΈ°λŠ₯ κ΅¬ν˜„ 확인 + - μ»€μŠ€ν…€ νƒ­ κ΄€λ¦¬λŠ” κΈ°μ‘΄ νŽ˜μ΄μ§€μ—μ„œλ„ λΉ„ν™œμ„±ν™”(주석 처리)됨 + - νƒ­ 관리 κΈ°λŠ₯은 둜컬 μƒνƒœλ§Œ μ‚¬μš© (λ°±μ—”λ“œ 미연동, μƒˆλ‘œκ³ μΉ¨ μ‹œ μ΄ˆκΈ°ν™”) + +2. **Phase D-2 (μ»€μŠ€ν…€ νƒ­ 관리) 뢄석 κ²°κ³Ό** + - κΈ°μ‘΄ νŽ˜μ΄μ§€μ˜ "νƒ­ 관리" λ²„νŠΌ: 주석 처리됨 (λ―Έμ‚¬μš©) + - 속성 ν•˜μœ„ νƒ­ 관리: 둜컬 μƒνƒœλ‘œλ§Œ λ™μž‘ (μ˜μ†μ„± μ—†μŒ) + - **κ²°λ‘ **: 선택적 κΈ°λŠ₯으둜 λΆ„λ₯˜, 핡심 κΈ°λŠ₯ κ΅¬ν˜„ μ™„λ£Œ + +--- + +## μ„Έμ…˜ μš”μ•½ (2025-12-21 - 9μ°¨ μ„Έμ…˜) + +### βœ… μ™„λ£Œλœ μž‘μ—… + +1. **속성 CRUD API 연동 μ™„λ£Œ** + - `types.ts`: PropertyActions μΈν„°νŽ˜μ΄μŠ€ μΆ”κ°€ + - `useItemMasterStore.ts`: addUnit, updateUnit, deleteUnit, addMaterial, updateMaterial, deleteMaterial, addTreatment, updateTreatment, deleteTreatment κ΅¬ν˜„ + - `item-master-api.ts`: UnitOptionRequest/Response νƒ€μž… μˆ˜μ • (unit_code, unit_name μ‚¬μš©) + +2. **Import κΈ°λŠ₯ κ΅¬ν˜„ μ™„λ£Œ** + - `ImportSectionDialog.tsx`: 독립 μ„Ήμ…˜ λͺ©λ‘μ—μ„œ μ„ νƒν•˜μ—¬ νŽ˜μ΄μ§€μ— μ—°κ²° + - `ImportFieldDialog.tsx`: 독립 ν•„λ“œ λͺ©λ‘μ—μ„œ μ„ νƒν•˜μ—¬ μ„Ήμ…˜μ— μ—°κ²° + - `dialogs/index.ts`: Import λ‹€μ΄μ–Όλ‘œκ·Έ export μΆ”κ°€ + - `HierarchyTab.tsx`: 뢈러였기 λ²„νŠΌμ— Import λ‹€μ΄μ–Όλ‘œκ·Έ μ—°κ²° + +3. **μ„Ήμ…˜ 볡제 API 연동 μ™„λ£Œ** + - `SectionsTab.tsx`: handleCloneSection ν•¨μˆ˜ κ΅¬ν˜„ (API 연동 + toast μ•Œλ¦Ό) + +4. **νƒ€μž… μˆ˜μ •** + - `transformers.ts`: transformUnitOptionResponse μˆ˜μ • (unit_name, unit_code μ‚¬μš©) + - `useFormStructure.ts`: λ‹¨μœ„ μ˜΅μ…˜ λ§€ν•‘ μˆ˜μ • (unit_name, unit_code μ‚¬μš©) + +--- + +### βœ… μ™„λ£Œλœ Phase + +| Phase | λ‚΄μš© | μƒνƒœ | +|-------|------|------| +| Phase 1 | Zustand μŠ€ν† μ–΄ κΈ°λ³Έ ꡬ쑰 | βœ… | +| Phase 2 | API 연동 (initFromApi) | βœ… | +| Phase 3 | API CRUD 연동 (update ν•¨μˆ˜λ“€) | βœ… | +| Phase A-1 | 계측ꡬ쑰 κΈ°λ³Έ ν‘œμ‹œ | βœ… | +| Phase A-2 | λ“œλž˜κ·Έμ•€λ“œλ‘­ μˆœμ„œ λ³€κ²½ | βœ… | +| Phase A-3 | 인라인 νŽΈμ§‘ (νŽ˜μ΄μ§€/μ„Ήμ…˜/경둜) | βœ… | +| Phase B-1 | νŽ˜μ΄μ§€ CRUD λ‹€μ΄μ–Όλ‘œκ·Έ | βœ… | +| Phase B-2 | μ„Ήμ…˜ CRUD λ‹€μ΄μ–Όλ‘œκ·Έ | βœ… | +| Phase B-3 | ν•„λ“œ CRUD λ‹€μ΄μ–Όλ‘œκ·Έ | βœ… | +| Phase B-4 | BOM 관리 UI | βœ… | +| Phase C-1 | μ„Ήμ…˜ νƒ­ κ΅¬ν˜„ (SectionsTab.tsx) | βœ… | +| Phase C-2 | ν•­λͺ© νƒ­ κ΅¬ν˜„ (FieldsTab.tsx) | βœ… | +| Phase D-1 | 속성 νƒ­ κΈ°λ³Έ ꡬ쑰 (PropertiesTab.tsx) | βœ… | +| Phase E | Import κΈ°λŠ₯ (μ„Ήμ…˜/ν•„λ“œ 뢈러였기) | βœ… | + +### βœ… ν˜„μž¬ μƒνƒœ: 핡심 κΈ°λŠ₯ κ΅¬ν˜„ μ™„λ£Œ + +**Phase D-2 (μ»€μŠ€ν…€ νƒ­ 관리)**: 선택적 κΈ°λŠ₯으둜 λΆ„λ₯˜λ¨ +- κΈ°μ‘΄ νŽ˜μ΄μ§€μ—μ„œλ„ "νƒ­ 관리" λ²„νŠΌμ€ 주석 처리 (λ―Έμ‚¬μš©) +- 속성 ν•˜μœ„ νƒ­ 관리도 둜컬 μƒνƒœλ‘œλ§Œ λ™μž‘ (λ°±μ—”λ“œ 미연동) +- ν•„μš” μ‹œ μΆ”ν›„ κ΅¬ν˜„ κ°€λŠ₯ + +--- + +## πŸ“‹ κΈ°λŠ₯ 비ꡐ κ²°κ³Ό + +### βœ… κ΅¬ν˜„ μ™„λ£Œλœ 핡심 κΈ°λŠ₯ + +| κΈ°λŠ₯ | ν…ŒμŠ€νŠΈ νŽ˜μ΄μ§€ | κΈ°μ‘΄ νŽ˜μ΄μ§€ | +|------|-------------|------------| +| 계측ꡬ쑰 관리 | βœ… | βœ… | +| νŽ˜μ΄μ§€ CRUD | βœ… | βœ… | +| μ„Ήμ…˜ CRUD | βœ… | βœ… | +| ν•„λ“œ CRUD | βœ… | βœ… | +| BOM 관리 | βœ… | βœ… | +| λ“œλž˜κ·Έμ•€λ“œλ‘­ μˆœμ„œ λ³€κ²½ | βœ… | βœ… | +| 인라인 νŽΈμ§‘ | βœ… | βœ… | +| Import (μ„Ήμ…˜/ν•„λ“œ) | βœ… | βœ… | +| μ„Ήμ…˜ 볡제 | βœ… | βœ… | +| λ‹¨μœ„/재질/ν‘œλ©΄μ²˜λ¦¬ CRUD | βœ… | βœ… | +| 검색/ν•„ν„° | βœ… | βœ… | + +### ⚠️ 선택적 κΈ°λŠ₯ (κΈ°μ‘΄ νŽ˜μ΄μ§€μ—μ„œλ„ μ œν•œμ  μ‚¬μš©) + +| κΈ°λŠ₯ | μƒνƒœ | λΉ„κ³  | +|------|------|------| +| μ»€μŠ€ν…€ 메인 νƒ­ 관리 | λ―Έκ΅¬ν˜„ | κΈ°μ‘΄ νŽ˜μ΄μ§€μ—μ„œ 주석 처리됨 | +| 속성 ν•˜μœ„ νƒ­ 관리 | λ―Έκ΅¬ν˜„ | 둜컬 μƒνƒœλ§Œ (μ˜μ†μ„± μ—†μŒ) | +| 칼럼 관리 | λ―Έκ΅¬ν˜„ | 둜컬 μƒνƒœλ§Œ (μ˜μ†μ„± μ—†μŒ) | + +--- + +## πŸ“‹ 전체 κΈ°λŠ₯ 체크리슀트 + +### Phase A: κΈ°λ³Έ UI ꡬ쑰 (계측ꡬ쑰 νƒ­ μ™„μ„±) βœ… + +#### A-1. 계측ꡬ쑰 κΈ°λ³Έ ν‘œμ‹œ βœ… μ™„λ£Œ +- [x] νŽ˜μ΄μ§€ λͺ©λ‘ ν‘œμ‹œ (쒌츑 νŒ¨λ„) +- [x] νŽ˜μ΄μ§€ 선택 μ‹œ μ„Ήμ…˜ λͺ©λ‘ ν‘œμ‹œ (우츑 νŒ¨λ„) +- [x] μ„Ήμ…˜ λ‚΄λΆ€ ν•„λ“œ λͺ©λ‘ ν‘œμ‹œ +- [x] ν•„λ“œ νƒ€μž…λ³„ 뱃지 ν‘œμ‹œ +- [x] BOM νƒ€μž… μ„Ήμ…˜ ꡬ뢄 ν‘œμ‹œ + +#### A-2. λ“œλž˜κ·Έμ•€λ“œλ‘­ μˆœμ„œ λ³€κ²½ βœ… μ™„λ£Œ +- [x] μ„Ήμ…˜ λ“œλž˜κ·Έμ•€λ“œλ‘­ μˆœμ„œ λ³€κ²½ +- [x] ν•„λ“œ λ“œλž˜κ·Έμ•€λ“œλ‘­ μˆœμ„œ λ³€κ²½ +- [x] μŠ€ν† μ–΄ reorderSections ν•¨μˆ˜ κ΅¬ν˜„ +- [x] μŠ€ν† μ–΄ reorderFields ν•¨μˆ˜ κ΅¬ν˜„ +- [x] DraggableSection μ»΄ν¬λ„ŒνŠΈ 생성 +- [x] DraggableField μ»΄ν¬λ„ŒνŠΈ 생성 + +#### A-3. 인라인 νŽΈμ§‘ βœ… μ™„λ£Œ +- [x] InlineEdit μž¬μ‚¬μš© μ»΄ν¬λ„ŒνŠΈ 생성 +- [x] νŽ˜μ΄μ§€ 이름 더블클릭 인라인 μˆ˜μ • +- [x] μ„Ήμ…˜ 제λͺ© 더블클릭 인라인 μˆ˜μ • +- [x] μ ˆλŒ€κ²½λ‘œ 인라인 μˆ˜μ • + +--- + +### Phase B: CRUD λ‹€μ΄μ–Όλ‘œκ·Έ βœ… + +#### B-1. νŽ˜μ΄μ§€ 관리 βœ… μ™„λ£Œ +- [x] PageDialog μ»΄ν¬λ„ŒνŠΈ (νŽ˜μ΄μ§€ μΆ”κ°€/μˆ˜μ •) +- [x] DeleteConfirmDialog (μž¬μ‚¬μš© κ°€λŠ₯ν•œ μ‚­μ œ 확인) +- [x] νŽ˜μ΄μ§€ μΆ”κ°€ λ²„νŠΌ μ—°κ²° +- [x] νŽ˜μ΄μ§€ μ‚­μ œ λ²„νŠΌ μ—°κ²° + +#### B-2. μ„Ήμ…˜ 관리 βœ… μ™„λ£Œ +- [x] SectionDialog μ»΄ν¬λ„ŒνŠΈ (μ„Ήμ…˜ μΆ”κ°€/μˆ˜μ •) +- [x] μ„Ήμ…˜ μ‚­μ œ λ‹€μ΄μ–Όλ‘œκ·Έ +- [x] μ„Ήμ…˜ μ—°κ²°ν•΄μ œ λ‹€μ΄μ–Όλ‘œκ·Έ +- [x] μ„Ήμ…˜ μΆ”κ°€ λ²„νŠΌ μ—°κ²° +- [x] ImportSectionDialog (μ„Ήμ…˜ 뢈러였기) βœ… + +#### B-3. ν•„λ“œ 관리 βœ… μ™„λ£Œ +- [x] FieldDialog μ»΄ν¬λ„ŒνŠΈ (ν•„λ“œ μΆ”κ°€/μˆ˜μ •) +- [x] λ“œλ‘­λ‹€μš΄ μ˜΅μ…˜ 동적 관리 +- [x] ν•„λ“œ μ‚­μ œ λ‹€μ΄μ–Όλ‘œκ·Έ +- [x] ν•„λ“œ μ—°κ²°ν•΄μ œ λ‹€μ΄μ–Όλ‘œκ·Έ +- [x] ν•„λ“œ μΆ”κ°€ λ²„νŠΌ μ—°κ²° +- [x] ImportFieldDialog (ν•„λ“œ 뢈러였기) βœ… + +#### B-4. BOM 관리 βœ… μ™„λ£Œ +- [x] BOMDialog μ»΄ν¬λ„ŒνŠΈ (BOM μΆ”κ°€/μˆ˜μ •) +- [x] BOM ν•­λͺ© μ‚­μ œ λ‹€μ΄μ–Όλ‘œκ·Έ +- [x] BOM μΆ”κ°€ λ²„νŠΌ μ—°κ²° +- [x] BOM μˆ˜μ • λ²„νŠΌ μ—°κ²° + +--- + +### Phase C: μ„Ήμ…˜ νƒ­ + ν•­λͺ© νƒ­ βœ… + +#### C-1. μ„Ήμ…˜ νƒ­ βœ… μ™„λ£Œ +- [x] λͺ¨λ“  μ„Ήμ…˜ λͺ©λ‘ ν‘œμ‹œ (μ—°κ²°λœ + 독립) +- [x] μ„Ήμ…˜ 상세 정보 ν‘œμ‹œ +- [x] μ„Ήμ…˜ λ‚΄λΆ€ ν•„λ“œ ν‘œμ‹œ (ν™•μž₯/μΆ•μ†Œ) +- [x] 일반 μ„Ήμ…˜ / BOM μ„Ήμ…˜ νƒ­ 뢄리 +- [x] νŽ˜μ΄μ§€ μ—°κ²° μƒνƒœ ν‘œμ‹œ +- [x] μ„Ήμ…˜ μΆ”κ°€/μˆ˜μ •/μ‚­μ œ λ‹€μ΄μ–Όλ‘œκ·Έ 연동 +- [x] μ„Ήμ…˜ 볡제 κΈ°λŠ₯ (API 연동 μ™„λ£Œ) βœ… + +#### C-2. ν•­λͺ© νƒ­ (λ§ˆμŠ€ν„° ν•„λ“œ) βœ… μ™„λ£Œ +- [x] λͺ¨λ“  ν•„λ“œ λͺ©λ‘ ν‘œμ‹œ +- [x] ν•„λ“œ 상세 정보 ν‘œμ‹œ +- [x] 검색 κΈ°λŠ₯ (ν•„λ“œλͺ…, ν•„λ“œν‚€, νƒ€μž…) +- [x] ν•„ν„° κΈ°λŠ₯ (전체/독립/μ—°κ²°λœ ν•„λ“œ) +- [x] ν•„λ“œ μΆ”κ°€/μˆ˜μ •/μ‚­μ œ λ‹€μ΄μ–Όλ‘œκ·Έ 연동 +- [x] 독립 ν•„λ“œ β†’ μ„Ήμ…˜ μ—°κ²° κΈ°λŠ₯ + +--- + +### Phase D: 속성 νƒ­ (μ§„ν–‰ 쀑) + +#### D-1. 속성 관리 βœ… μ™„λ£Œ +- [x] PropertiesTab.tsx κΈ°λ³Έ ꡬ쑰 +- [x] λ‹¨μœ„ 관리 (CRUD) - API 연동 μ™„λ£Œ +- [x] 재질 관리 (CRUD) - API 연동 μ™„λ£Œ +- [x] ν‘œλ©΄μ²˜λ¦¬ 관리 (CRUD) - API 연동 μ™„λ£Œ +- [x] PropertyDialog (속성 μ˜΅μ…˜ μΆ”κ°€) + +#### D-2. νƒ­ 관리 (μ˜ˆμ •) +- [ ] μ»€μŠ€ν…€ νƒ­ μΆ”κ°€/μˆ˜μ •/μ‚­μ œ +- [ ] 속성 ν•˜μœ„ νƒ­ μΆ”κ°€/μˆ˜μ •/μ‚­μ œ +- [ ] νƒ­ μˆœμ„œ λ³€κ²½ + +--- + +### Phase E: Import κΈ°λŠ₯ βœ… + +- [x] ImportSectionDialog (μ„Ήμ…˜ 뢈러였기) +- [x] ImportFieldDialog (ν•„λ“œ 뢈러였기) +- [x] HierarchyTab 뢈러였기 λ²„νŠΌ μ—°κ²° + +--- + +## πŸ“ 파일 ꡬ쑰 + +``` +src/stores/item-master/ +β”œβ”€β”€ types.ts # μ •κ·œν™”λœ μ—”ν‹°ν‹° νƒ€μž… + PropertyActions +β”œβ”€β”€ useItemMasterStore.ts # Zustand μŠ€ν† μ–΄ +β”œβ”€β”€ normalizers.ts # API 응닡 μ •κ·œν™” + +src/app/[locale]/(protected)/items-management-test/ +β”œβ”€β”€ page.tsx # ν…ŒμŠ€νŠΈ νŽ˜μ΄μ§€ 메인 +β”œβ”€β”€ components/ # ν…ŒμŠ€νŠΈ νŽ˜μ΄μ§€ μ „μš© μ»΄ν¬λ„ŒνŠΈ +β”‚ β”œβ”€β”€ HierarchyTab.tsx # 계측ꡬ쑰 νƒ­ βœ… +β”‚ β”œβ”€β”€ DraggableSection.tsx # λ“œλž˜κ·Έ μ„Ήμ…˜ βœ… +β”‚ β”œβ”€β”€ DraggableField.tsx # λ“œλž˜κ·Έ ν•„λ“œ βœ… +β”‚ β”œβ”€β”€ InlineEdit.tsx # 인라인 νŽΈμ§‘ μ»΄ν¬λ„ŒνŠΈ βœ… +β”‚ β”œβ”€β”€ SectionsTab.tsx # μ„Ήμ…˜ νƒ­ βœ… (볡제 κΈ°λŠ₯ μΆ”κ°€) +β”‚ β”œβ”€β”€ FieldsTab.tsx # ν•­λͺ© νƒ­ βœ… +β”‚ β”œβ”€β”€ PropertiesTab.tsx # 속성 νƒ­ βœ… +β”‚ └── dialogs/ # λ‹€μ΄μ–Όλ‘œκ·Έ μ»΄ν¬λ„ŒνŠΈ βœ… +β”‚ β”œβ”€β”€ index.ts # 인덱슀 βœ… +β”‚ β”œβ”€β”€ DeleteConfirmDialog.tsx # μ‚­μ œ 확인 βœ… +β”‚ β”œβ”€β”€ PageDialog.tsx # νŽ˜μ΄μ§€ λ‹€μ΄μ–Όλ‘œκ·Έ βœ… +β”‚ β”œβ”€β”€ SectionDialog.tsx # μ„Ήμ…˜ λ‹€μ΄μ–Όλ‘œκ·Έ βœ… +β”‚ β”œβ”€β”€ FieldDialog.tsx # ν•„λ“œ λ‹€μ΄μ–Όλ‘œκ·Έ βœ… +β”‚ β”œβ”€β”€ BOMDialog.tsx # BOM λ‹€μ΄μ–Όλ‘œκ·Έ βœ… +β”‚ β”œβ”€β”€ PropertyDialog.tsx # 속성 λ‹€μ΄μ–Όλ‘œκ·Έ βœ… +β”‚ β”œβ”€β”€ ImportSectionDialog.tsx # μ„Ήμ…˜ 뢈러였기 βœ… +β”‚ └── ImportFieldDialog.tsx # ν•„λ“œ 뢈러였기 βœ… +``` + +--- + +## 핡심 파일 μœ„μΉ˜ + +| 파일 | μš©λ„ | +|-----|------| +| `claudedocs/architecture/[DESIGN-2025-12-20] item-master-zustand-refactoring.md` | πŸ“‹ 섀계 λ¬Έμ„œ | +| `src/stores/item-master/useItemMasterStore.ts` | πŸͺ Zustand μŠ€ν† μ–΄ | +| `src/stores/item-master/types.ts` | πŸ“ νƒ€μž… μ •μ˜ | +| `src/stores/item-master/normalizers.ts` | πŸ”„ API 응닡 μ •κ·œν™” | +| `src/app/[locale]/(protected)/items-management-test/page.tsx` | πŸ§ͺ ν…ŒμŠ€νŠΈ νŽ˜μ΄μ§€ | +| `src/components/items/ItemMasterDataManagement.tsx` | πŸ“š κΈ°μ‘΄ νŽ˜μ΄μ§€ (참쑰용) | + +--- + +## ν…ŒμŠ€νŠΈ νŽ˜μ΄μ§€ 접속 + +``` +http://localhost:3000/ko/items-management-test +``` + +--- + +## λ‹€μŒ μ„Έμ…˜ μ‹œμž‘ λͺ…λ Ή + +``` +ν…ŒμŠ€νŠΈ νŽ˜μ΄μ§€ μ‹€μ œ μ‚¬μš©ν•΄λ³΄κ³  버그 μˆ˜μ •ν•΄μ€˜ +``` + +λ˜λŠ” + +``` +λ§ˆμ΄κ·Έλ ˆμ΄μ…˜ μ€€λΉ„ν•΄μ€˜ - κΈ°μ‘΄ νŽ˜μ΄μ§€λ₯Ό ν…ŒμŠ€νŠΈ νŽ˜μ΄μ§€λ‘œ λŒ€μ²΄ +``` + +--- + +## 남은 μž‘μ—… + +### μš°μ„ μˆœμœ„ λ†’μŒ +1. **μ‹€μ‚¬μš© ν…ŒμŠ€νŠΈ**: ν…ŒμŠ€νŠΈ νŽ˜μ΄μ§€μ—μ„œ μ‹€μ œ λ°μ΄ν„°λ‘œ CRUD ν…ŒμŠ€νŠΈ +2. **버그 μˆ˜μ •**: λ°œκ²¬λ˜λŠ” 버그 μ¦‰μ‹œ μˆ˜μ • +3. **λ§ˆμ΄κ·Έλ ˆμ΄μ…˜**: ν…ŒμŠ€νŠΈ μ™„λ£Œ ν›„ κΈ°μ‘΄ νŽ˜μ΄μ§€ λŒ€μ²΄ + +### 선택적 (ν•„μš” μ‹œ) +4. **Phase D-2**: μ»€μŠ€ν…€ νƒ­ 관리 (속성 ν•˜μœ„ νƒ­ μΆ”κ°€/μˆ˜μ •/μ‚­μ œ) + - κΈ°μ‘΄ νŽ˜μ΄μ§€μ—μ„œλ„ μ‚¬μš©λ˜μ§€ μ•ŠλŠ” κΈ°λŠ₯ + - λ°±μ—”λ“œ API 연동 ν•„μš” \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index d9c01ebc..1b3aadd6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.1.0", + "immer": "^11.0.1", "lucide-react": "^0.552.0", "next": "^15.5.7", "next-intl": "^4.4.0", @@ -48,7 +49,7 @@ "tailwind-merge": "^3.3.1", "vaul": "^1.1.2", "zod": "^4.1.12", - "zustand": "^5.0.8" + "zustand": "^5.0.9" }, "devDependencies": { "@playwright/test": "^1.57.0", @@ -3341,6 +3342,16 @@ } } }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@remirror/core-constants": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz", @@ -6810,9 +6821,9 @@ } }, "node_modules/immer": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", - "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.0.1.tgz", + "integrity": "sha512-naDCyggtcBWANtIrjQEajhhBEuL9b0Zg4zmlWK2CzS6xCWSE39/vvf4LqnMjUAWHBhot4m9MHCM/Z+mfWhUkiA==", "license": "MIT", "funding": { "type": "opencollective", @@ -8785,6 +8796,16 @@ "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/recharts/node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", @@ -10002,9 +10023,9 @@ } }, "node_modules/zustand": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz", - "integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.9.tgz", + "integrity": "sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==", "license": "MIT", "engines": { "node": ">=12.20.0" diff --git a/package.json b/package.json index 0736f23a..e0086e13 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.1.0", + "immer": "^11.0.1", "lucide-react": "^0.552.0", "next": "^15.5.7", "next-intl": "^4.4.0", @@ -52,7 +53,7 @@ "tailwind-merge": "^3.3.1", "vaul": "^1.1.2", "zod": "^4.1.12", - "zustand": "^5.0.8" + "zustand": "^5.0.9" }, "devDependencies": { "@playwright/test": "^1.57.0", diff --git a/src/app/[locale]/(protected)/items-management-test/components/DraggableField.tsx b/src/app/[locale]/(protected)/items-management-test/components/DraggableField.tsx new file mode 100644 index 00000000..1f4c4044 --- /dev/null +++ b/src/app/[locale]/(protected)/items-management-test/components/DraggableField.tsx @@ -0,0 +1,133 @@ +'use client'; + +/** + * λ“œλž˜κ·Έ κ°€λŠ₯ν•œ ν•„λ“œ μ»΄ν¬λ„ŒνŠΈ (Zustand 버전) + * + * κΈ°λŠ₯: + * - ν•„λ“œ λ“œλž˜κ·Έμ•€λ“œλ‘­ μˆœμ„œ λ³€κ²½ + * - ν•„λ“œ νƒ€μž…λ³„ 뱃지 ν‘œμ‹œ + * - ν•„λ“œ νŽΈμ§‘/μ‚­μ œ λ²„νŠΌ + */ + +import { useState } from 'react'; +import type { FieldEntity } from '@/stores/item-master/types'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { GripVertical, Edit, Trash2, Unlink, FileText } from 'lucide-react'; + +// ν•„λ“œ νƒ€μž… 라벨 +const FIELD_TYPE_LABELS: Record = { + textbox: 'ν…μŠ€νŠΈ', + number: '숫자', + dropdown: 'λ“œλ‘­λ‹€μš΄', + checkbox: 'μ²΄ν¬λ°•μŠ€', + date: 'λ‚ μ§œ', + textarea: 'ν…μŠ€νŠΈμ˜μ—­', +}; + +interface DraggableFieldProps { + field: FieldEntity; + sectionId: number; + onReorder: (dragFieldId: number, hoverFieldId: number) => void; + onEdit?: () => void; + onDelete?: () => void; + onUnlink?: () => void; +} + +export function DraggableField({ + field, + sectionId, + onReorder, + onEdit, + onDelete, + onUnlink, +}: DraggableFieldProps) { + const [isDragging, setIsDragging] = useState(false); + + const handleDragStart = (e: React.DragEvent) => { + e.stopPropagation(); // μ„Ήμ…˜ λ“œλž˜κ·Έ μ΄λ²€νŠΈμ™€ 좩돌 λ°©μ§€ + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData( + 'application/json', + JSON.stringify({ type: 'field', id: field.id, sectionId }) + ); + setIsDragging(true); + }; + + const handleDragEnd = () => { + setIsDragging(false); + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); // 이벀트 버블링 λ°©μ§€ + e.dataTransfer.dropEffect = 'move'; + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); // 이벀트 버블링 λ°©μ§€ + try { + const data = JSON.parse(e.dataTransfer.getData('application/json')); + // 같은 μ„Ήμ…˜μ˜ ν•„λ“œλ§Œ 처리 + if (data.type !== 'field' || data.sectionId !== sectionId) { + return; + } + if (data.id !== field.id) { + onReorder(data.id, field.id); + } + } catch (err) { + // Ignore - λ‹€λ₯Έ νƒ€μž…μ˜ λ“œλž˜κ·Έ 데이터 + } + }; + + return ( +
+
+ + + {field.field_name} + {field.field_key && ( + + {field.field_key} + + )} +
+
+ + {FIELD_TYPE_LABELS[field.field_type] || field.field_type} + + {field.is_required && ( + + ν•„μˆ˜ + + )} + {onEdit && ( + + )} + {onUnlink && ( + + )} + {onDelete && ( + + )} +
+
+ ); +} \ No newline at end of file diff --git a/src/app/[locale]/(protected)/items-management-test/components/DraggableSection.tsx b/src/app/[locale]/(protected)/items-management-test/components/DraggableSection.tsx new file mode 100644 index 00000000..3a77cbf5 --- /dev/null +++ b/src/app/[locale]/(protected)/items-management-test/components/DraggableSection.tsx @@ -0,0 +1,168 @@ +'use client'; + +/** + * λ“œλž˜κ·Έ κ°€λŠ₯ν•œ μ„Ήμ…˜ μ»΄ν¬λ„ŒνŠΈ (Zustand 버전) + * + * κΈ°λŠ₯: + * - μ„Ήμ…˜ λ“œλž˜κ·Έμ•€λ“œλ‘­ μˆœμ„œ λ³€κ²½ + * - μ„Ήμ…˜ μ ‘νž˜/펼침 + * - μ„Ήμ…˜ νŽΈμ§‘/μ‚­μ œ λ²„νŠΌ + */ + +import { useState } from 'react'; +import type { SectionEntity, FieldEntity, BOMItemEntity } from '@/stores/item-master/types'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { + GripVertical, + ChevronDown, + ChevronRight, + Trash2, + Unlink, + Layers, + List, +} from 'lucide-react'; +import { InlineEdit } from './InlineEdit'; + +interface DraggableSectionProps { + section: SectionEntity; + pageId: number; + isCollapsed: boolean; + onToggleCollapse: () => void; + onReorder: (dragSectionId: number, hoverSectionId: number) => void; + onTitleSave?: (title: string) => void | Promise; + onDelete?: () => void; + onUnlink?: () => void; + children: React.ReactNode; + fieldCount: number; + bomCount: number; +} + +export function DraggableSection({ + section, + pageId, + isCollapsed, + onToggleCollapse, + onReorder, + onTitleSave, + onDelete, + onUnlink, + children, + fieldCount, + bomCount, +}: DraggableSectionProps) { + const [isDragging, setIsDragging] = useState(false); + + const isBomSection = section.section_type === 'BOM'; + + const handleDragStart = (e: React.DragEvent) => { + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData( + 'application/json', + JSON.stringify({ type: 'section', id: section.id, pageId }) + ); + setIsDragging(true); + }; + + const handleDragEnd = () => { + setIsDragging(false); + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + try { + const data = JSON.parse(e.dataTransfer.getData('application/json')); + // 같은 νŽ˜μ΄μ§€μ˜ μ„Ήμ…˜λ§Œ 처리 + if (data.type !== 'section' || data.pageId !== pageId) { + return; + } + if (data.id !== section.id) { + onReorder(data.id, section.id); + } + } catch (err) { + // Ignore - λ‹€λ₯Έ νƒ€μž…μ˜ λ“œλž˜κ·Έ 데이터 + } + }; + + return ( +
+ {/* μ„Ήμ…˜ 헀더 */} +
+ + + + +
+
+ {isBomSection ? ( + + ) : ( + + )} + {onTitleSave ? ( + + ) : ( + {section.title} + )} + + {isBomSection ? 'BOM' : 'FIELDS'} + + + ({isBomSection ? bomCount : fieldCount}개) + +
+
+ +
+ {onUnlink && ( + + )} + {onDelete && ( + + )} +
+
+ + {/* μ„Ήμ…˜ λ‚΄μš© (μ ‘νž˜ μƒνƒœμ— 따라 ν‘œμ‹œ) */} + {!isCollapsed &&
{children}
} +
+ ); +} \ No newline at end of file diff --git a/src/app/[locale]/(protected)/items-management-test/components/FieldsTab.tsx b/src/app/[locale]/(protected)/items-management-test/components/FieldsTab.tsx new file mode 100644 index 00000000..2eec6704 --- /dev/null +++ b/src/app/[locale]/(protected)/items-management-test/components/FieldsTab.tsx @@ -0,0 +1,421 @@ +'use client'; + +/** + * ν•­λͺ©(ν•„λ“œ) νƒ­ μ»΄ν¬λ„ŒνŠΈ (Zustand 버전) + * + * - λͺ¨λ“  ν•„λ“œ λͺ©λ‘ ν‘œμ‹œ (μ—°κ²°λœ + 독립) + * - ν•„λ“œ μΆ”κ°€/μˆ˜μ •/μ‚­μ œ + * - μ„Ήμ…˜ μ—°κ²°/μ—°κ²°ν•΄μ œ + * - ν•„λ“œ 상세 정보 ν‘œμ‹œ + */ + +import { useState, useMemo } from 'react'; +import { useItemMasterStore } from '@/stores/item-master/useItemMasterStore'; +import type { FieldEntity, SectionEntity } from '@/stores/item-master/types'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { + Plus, + Edit, + Trash2, + Link, + Unlink, + Search, + FileText, + Filter, +} from 'lucide-react'; +import { FieldDialog, DeleteConfirmDialog } from './dialogs'; + +// μž…λ ₯ νƒ€μž… μ˜΅μ…˜ +const INPUT_TYPE_OPTIONS = [ + { value: 'textbox', label: 'ν…μŠ€νŠΈλ°•μŠ€' }, + { value: 'dropdown', label: 'λ“œλ‘­λ‹€μš΄' }, + { value: 'checkbox', label: 'μ²΄ν¬λ°•μŠ€' }, + { value: 'number', label: '숫자' }, + { value: 'date', label: 'λ‚ μ§œ' }, + { value: 'textarea', label: 'ν…μŠ€νŠΈμ˜μ—­' }, +]; + +// ν•„ν„° νƒ€μž… +type FilterType = 'all' | 'independent' | 'linked'; + +// λ‹€μ΄μ–Όλ‘œκ·Έ μƒνƒœ νƒ€μž… +interface DialogState { + type: 'field-add' | 'field-edit' | 'field-delete' | 'field-link' | null; + fieldId?: number; +} + +export function FieldsTab() { + // === Zustand μŠ€ν† μ–΄ === + const { entities, ids, deleteField, linkFieldToSection } = useItemMasterStore(); + + // === 둜컬 μƒνƒœ === + const [searchTerm, setSearchTerm] = useState(''); + const [filterType, setFilterType] = useState('all'); + const [dialog, setDialog] = useState({ type: null }); + const [selectedFieldForLink, setSelectedFieldForLink] = useState(null); + + // === νŒŒμƒ μƒνƒœ: λͺ¨λ“  ν•„λ“œ λͺ©λ‘ === + const allFields = useMemo(() => { + return Object.values(entities.fields); + }, [entities.fields]); + + // 독립 ν•„λ“œ (section_id === null) + const independentFields = useMemo(() => { + return ids.independentFields.map((id) => entities.fields[id]).filter(Boolean); + }, [ids.independentFields, entities.fields]); + + // μ—°κ²°λœ ν•„λ“œ (section_id !== null) + const linkedFields = useMemo(() => { + return allFields.filter((f) => f.section_id !== null); + }, [allFields]); + + // ν•„ν„°λ§λœ ν•„λ“œ + const filteredFields = useMemo(() => { + let fields: FieldEntity[] = []; + + switch (filterType) { + case 'independent': + fields = independentFields; + break; + case 'linked': + fields = linkedFields; + break; + default: + fields = allFields; + } + + // 검색어 ν•„ν„° + if (searchTerm) { + const term = searchTerm.toLowerCase(); + fields = fields.filter( + (f) => + f.field_name.toLowerCase().includes(term) || + f.field_key?.toLowerCase().includes(term) || + f.field_type.toLowerCase().includes(term) + ); + } + + return fields; + }, [allFields, independentFields, linkedFields, filterType, searchTerm]); + + // === μ„Ήμ…˜ 이름 κ°€μ Έμ˜€κΈ° === + const getSectionName = (sectionId: number | null): string => { + if (sectionId === null) return '-'; + const section = entities.sections[sectionId]; + return section?.title || 'μ•Œ 수 μ—†μŒ'; + }; + + // === μ„Ήμ…˜ λͺ©λ‘ (μ—°κ²°μš©) === + const sectionOptions = useMemo(() => { + return Object.values(entities.sections).map((s) => ({ + id: s.id, + title: s.title, + section_type: s.section_type, + })); + }, [entities.sections]); + + // === λ‹€μ΄μ–Όλ‘œκ·Έ ν•Έλ“€λŸ¬ === + const handleAddField = () => { + setDialog({ type: 'field-add' }); + }; + + const handleEditField = (fieldId: number) => { + setDialog({ type: 'field-edit', fieldId }); + }; + + const handleDeleteField = (fieldId: number) => { + setDialog({ type: 'field-delete', fieldId }); + }; + + const handleLinkField = (fieldId: number) => { + setSelectedFieldForLink(fieldId); + setDialog({ type: 'field-link', fieldId }); + }; + + const closeDialog = () => { + setDialog({ type: null }); + setSelectedFieldForLink(null); + }; + + // === μ‚­μ œ μ‹€ν–‰ === + const handleConfirmDelete = async () => { + if (dialog.type === 'field-delete' && dialog.fieldId) { + await deleteField(dialog.fieldId); + } + closeDialog(); + }; + + // === μ„Ήμ…˜ μ—°κ²° μ‹€ν–‰ === + const handleConfirmLink = async (sectionId: number) => { + if (selectedFieldForLink) { + await linkFieldToSection(selectedFieldForLink, sectionId); + } + closeDialog(); + }; + + // === ν˜„μž¬ νŽΈμ§‘ 쀑인 ν•„λ“œ === + const currentField = dialog.fieldId ? entities.fields[dialog.fieldId] : undefined; + + return ( + + +
+
+ ν•„λ“œ 관리 + + μž¬μ‚¬μš© κ°€λŠ₯ν•œ ν•„λ“œλ₯Ό κ΄€λ¦¬ν•©λ‹ˆλ‹€. 총 {allFields.length}개 ν•„λ“œ (독립: {independentFields.length}개) + +
+ +
+
+ + {/* 검색 및 ν•„ν„° */} +
+
+ + setSearchTerm(e.target.value)} + className="pl-10" + /> +
+
+ + + +
+
+ + {/* ν•„λ“œ λͺ©λ‘ */} + {filteredFields.length === 0 ? ( +
+ +

+ {searchTerm ? '검색 κ²°κ³Όκ°€ μ—†μŠ΅λ‹ˆλ‹€' : 'λ“±λ‘λœ ν•„λ“œκ°€ μ—†μŠ΅λ‹ˆλ‹€'} +

+ {!searchTerm && ( +

+ ν•„λ“œ μΆ”κ°€ λ²„νŠΌμ„ 눌러 μž¬μ‚¬μš© κ°€λŠ₯ν•œ ν•„λ“œλ₯Ό λ“±λ‘ν•˜μ„Έμš”. +

+ )} +
+ ) : ( +
+ {filteredFields.map((field, index) => ( +
+
+
+ {field.field_name} + + {INPUT_TYPE_OPTIONS.find((t) => t.value === field.field_type)?.label || + field.field_type} + + {field.is_required && ( + + ν•„μˆ˜ + + )} + {field.section_id ? ( + + + {getSectionName(field.section_id)} + + ) : ( + + 독립 ν•„λ“œ + + )} +
+
+ ID: {field.id} + {field.field_key && β€’ ν‚€: {field.field_key}} + {field.placeholder && β€’ {field.placeholder}} +
+ {field.options && field.options.length > 0 && ( +
+ μ˜΅μ…˜: {field.options.map((opt) => opt.label).join(', ')} +
+ )} +
+
+ {/* 독립 ν•„λ“œμΈ 경우 μ—°κ²° λ²„νŠΌ ν‘œμ‹œ */} + {field.section_id === null && ( + + )} + + +
+
+ ))} +
+ )} +
+ + {/* === λ‹€μ΄μ–Όλ‘œκ·Έ === */} + + {/* ν•„λ“œ μΆ”κ°€ λ‹€μ΄μ–Όλ‘œκ·Έ (독립 ν•„λ“œλ‘œ 생성) */} + !open && closeDialog()} + mode="add" + sectionId={null} + /> + + {/* ν•„λ“œ μˆ˜μ • λ‹€μ΄μ–Όλ‘œκ·Έ */} + {currentField && ( + !open && closeDialog()} + mode="edit" + sectionId={currentField.section_id} + field={currentField} + /> + )} + + {/* ν•„λ“œ μ‚­μ œ 확인 */} + !open && closeDialog()} + onConfirm={handleConfirmDelete} + title="ν•„λ“œ μ‚­μ œ" + description="이 ν•„λ“œλ₯Ό μ‚­μ œν•˜μ‹œκ² μŠ΅λ‹ˆκΉŒ? 이 μž‘μ—…μ€ μ·¨μ†Œν•  수 μ—†μŠ΅λ‹ˆλ‹€." + itemName={currentField?.field_name} + /> + + {/* μ„Ήμ…˜ μ—°κ²° λ‹€μ΄μ–Όλ‘œκ·Έ */} + !open && closeDialog()} + sections={sectionOptions} + onConfirm={handleConfirmLink} + fieldName={currentField?.field_name} + /> +
+ ); +} + +// === μ„Ήμ…˜ μ—°κ²° λ‹€μ΄μ–Όλ‘œκ·Έ === +interface LinkFieldDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + sections: Array<{ id: number; title: string; section_type: string }>; + onConfirm: (sectionId: number) => void; + fieldName?: string; +} + +function LinkFieldDialog({ open, onOpenChange, sections, onConfirm, fieldName }: LinkFieldDialogProps) { + const [selectedSectionId, setSelectedSectionId] = useState(null); + + const handleConfirm = () => { + if (selectedSectionId) { + onConfirm(selectedSectionId); + setSelectedSectionId(null); + } + }; + + if (!open) return null; + + return ( +
+ {/* Overlay */} +
onOpenChange(false)} + /> + + {/* Dialog */} +
+

μ„Ήμ…˜μ— μ—°κ²°

+

+ "{fieldName}" ν•„λ“œλ₯Ό μ—°κ²°ν•  μ„Ήμ…˜μ„ μ„ νƒν•˜μ„Έμš”. +

+ +
+ {sections.length === 0 ? ( +

+ μ—°κ²° κ°€λŠ₯ν•œ μ„Ήμ…˜μ΄ μ—†μŠ΅λ‹ˆλ‹€. +

+ ) : ( + sections.map((section) => ( + + )) + )} +
+ +
+ + +
+
+
+ ); +} \ No newline at end of file diff --git a/src/app/[locale]/(protected)/items-management-test/components/HierarchyTab.tsx b/src/app/[locale]/(protected)/items-management-test/components/HierarchyTab.tsx new file mode 100644 index 00000000..c66e8831 --- /dev/null +++ b/src/app/[locale]/(protected)/items-management-test/components/HierarchyTab.tsx @@ -0,0 +1,774 @@ +'use client'; + +/** + * 계측ꡬ쑰 νƒ­ (Zustand 버전) + * + * κΈ°λŠ₯: + * - νŽ˜μ΄μ§€ λͺ©λ‘ ν‘œμ‹œ (쒌츑 νŒ¨λ„) + * - μ„ νƒλœ νŽ˜μ΄μ§€μ˜ μ„Ήμ…˜ λͺ©λ‘ ν‘œμ‹œ (우츑 νŒ¨λ„) + * - μ„Ήμ…˜ λ‚΄λΆ€ ν•„λ“œ λͺ©λ‘ ν‘œμ‹œ + * - BOM νƒ€μž… μ„Ήμ…˜ ꡬ뢄 ν‘œμ‹œ + * - μ„Ήμ…˜/ν•„λ“œ λ“œλž˜κ·Έμ•€λ“œλ‘­ μˆœμ„œ λ³€κ²½ + * - Phase B: CRUD λ‹€μ΄μ–Όλ‘œκ·Έ 톡합 + */ + +import { useState } from 'react'; +import { useItemMasterStore } from '@/stores/item-master/useItemMasterStore'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Plus, Download, Link, GripVertical, Edit, Trash2 } from 'lucide-react'; +import { DraggableSection } from './DraggableSection'; +import { DraggableField } from './DraggableField'; +import { InlineEdit } from './InlineEdit'; +import { + DeleteConfirmDialog, + PageDialog, + SectionDialog, + FieldDialog, + BOMDialog, + ImportSectionDialog, + ImportFieldDialog, +} from './dialogs'; +import type { + PageEntity, + SectionEntity, + FieldEntity, + BOMItemEntity, + ItemType, + SectionType, + FieldType, +} from '@/stores/item-master/types'; + +// ν’ˆλͺ© νƒ€μž… μ˜΅μ…˜ +const ITEM_TYPE_OPTIONS = [ + { value: 'FG', label: 'μ œν’ˆ (FG)' }, + { value: 'PT', label: 'λΆ€ν’ˆ (PT)' }, + { value: 'SM', label: 'λΆ€μžμž¬ (SM)' }, + { value: 'RM', label: 'μ›μžμž¬ (RM)' }, + { value: 'CS', label: 'μ†Œλͺ¨ν’ˆ (CS)' }, +]; + +// λ‹€μ΄μ–Όλ‘œκ·Έ μƒνƒœ νƒ€μž… +interface DialogState { + // νŽ˜μ΄μ§€ λ‹€μ΄μ–Όλ‘œκ·Έ + pageDialog: { + open: boolean; + mode: 'create' | 'edit'; + page: PageEntity | null; + }; + // μ„Ήμ…˜ λ‹€μ΄μ–Όλ‘œκ·Έ + sectionDialog: { + open: boolean; + mode: 'create' | 'edit'; + section: SectionEntity | null; + pageId: number | null; + }; + // ν•„λ“œ λ‹€μ΄μ–Όλ‘œκ·Έ + fieldDialog: { + open: boolean; + mode: 'create' | 'edit'; + field: FieldEntity | null; + sectionId: number | null; + }; + // BOM λ‹€μ΄μ–Όλ‘œκ·Έ + bomDialog: { + open: boolean; + mode: 'create' | 'edit'; + bomItem: BOMItemEntity | null; + sectionId: number | null; + }; + // μ‚­μ œ 확인 λ‹€μ΄μ–Όλ‘œκ·Έ + deleteConfirm: { + open: boolean; + type: 'delete' | 'unlink'; + target: 'page' | 'section' | 'field' | 'bom'; + item: PageEntity | SectionEntity | FieldEntity | BOMItemEntity | null; + title: string; + description: string; + }; + // μ„Ήμ…˜ 뢈러였기 λ‹€μ΄μ–Όλ‘œκ·Έ + importSectionDialog: { + open: boolean; + pageId: number | null; + }; + // ν•„λ“œ 뢈러였기 λ‹€μ΄μ–Όλ‘œκ·Έ + importFieldDialog: { + open: boolean; + sectionId: number | null; + }; +} + +const initialDialogState: DialogState = { + pageDialog: { open: false, mode: 'create', page: null }, + sectionDialog: { open: false, mode: 'create', section: null, pageId: null }, + fieldDialog: { open: false, mode: 'create', field: null, sectionId: null }, + bomDialog: { open: false, mode: 'create', bomItem: null, sectionId: null }, + deleteConfirm: { open: false, type: 'delete', target: 'page', item: null, title: '', description: '' }, + importSectionDialog: { open: false, pageId: null }, + importFieldDialog: { open: false, sectionId: null }, +}; + +export function HierarchyTab() { + const { + entities, + ids, + reorderSections, + reorderFields, + updatePage, + updateSection, + createPage, + deletePage, + createSectionInPage, + deleteSection, + unlinkSectionFromPage, + createFieldInSection, + deleteField, + unlinkFieldFromSection, + createBomItem, + updateBomItem, + deleteBomItem, + } = useItemMasterStore(); + + // μ„ νƒλœ νŽ˜μ΄μ§€ ID + const [selectedPageId, setSelectedPageId] = useState(null); + + // μ„Ήμ…˜ μ ‘νž˜ μƒνƒœ 관리 + const [collapsedSections, setCollapsedSections] = useState>({}); + + // λ‹€μ΄μ–Όλ‘œκ·Έ μƒνƒœ + const [dialogState, setDialogState] = useState(initialDialogState); + + // νŽ˜μ΄μ§€ λͺ©λ‘ + const pages = ids.pages.map((id) => entities.pages[id]).filter(Boolean); + + // μ„ νƒλœ νŽ˜μ΄μ§€ + const selectedPage = selectedPageId ? entities.pages[selectedPageId] : null; + + // μ„ νƒλœ νŽ˜μ΄μ§€μ˜ μ„Ήμ…˜ λͺ©λ‘ (sectionIds μˆœμ„œ μœ μ§€) + const pageSections = selectedPage?.sectionIds + .map((id) => entities.sections[id]) + .filter(Boolean) || []; + + // μ„Ήμ…˜ μ ‘νž˜ ν† κΈ€ + const toggleSection = (sectionId: number) => { + setCollapsedSections((prev) => ({ + ...prev, + [sectionId]: !prev[sectionId], + })); + }; + + // μ„Ήμ…˜μ˜ ν•„λ“œ κ°€μ Έμ˜€κΈ° (fieldIds μˆœμ„œ μœ μ§€) + const getSectionFields = (sectionId: number) => { + const section = entities.sections[sectionId]; + if (!section?.fieldIds) return []; + + return section.fieldIds + .map((id) => entities.fields[id]) + .filter(Boolean); + }; + + // μ„Ήμ…˜μ˜ BOM ν•­λͺ© κ°€μ Έμ˜€κΈ° + const getSectionBomItems = (sectionId: number) => { + const section = entities.sections[sectionId]; + if (!section?.bomItemIds) return []; + + return section.bomItemIds + .map((id) => entities.bomItems[id]) + .filter(Boolean); + }; + + // μ„Ήμ…˜ μˆœμ„œ λ³€κ²½ ν•Έλ“€λŸ¬ + const handleReorderSections = (dragSectionId: number, hoverSectionId: number) => { + if (selectedPageId) { + reorderSections(selectedPageId, dragSectionId, hoverSectionId); + } + }; + + // ν•„λ“œ μˆœμ„œ λ³€κ²½ ν•Έλ“€λŸ¬ + const handleReorderFields = (sectionId: number) => (dragFieldId: number, hoverFieldId: number) => { + reorderFields(sectionId, dragFieldId, hoverFieldId); + }; + + // ===== λ‹€μ΄μ–Όλ‘œκ·Έ ν•Έλ“€λŸ¬ ===== + + // νŽ˜μ΄μ§€ μΆ”κ°€ + const openAddPageDialog = () => { + setDialogState((prev) => ({ + ...prev, + pageDialog: { open: true, mode: 'create', page: null }, + })); + }; + + // νŽ˜μ΄μ§€ μ €μž₯ + const handleSavePage = async (data: { + page_name: string; + item_type: ItemType; + description: string; + absolute_path: string; + is_active: boolean; + order_no: number; + }) => { + if (dialogState.pageDialog.mode === 'create') { + await createPage(data); + } else if (dialogState.pageDialog.page) { + await updatePage(dialogState.pageDialog.page.id, data); + } + }; + + // νŽ˜μ΄μ§€ μ‚­μ œ 확인 + const openDeletePageDialog = (page: PageEntity) => { + setDialogState((prev) => ({ + ...prev, + deleteConfirm: { + open: true, + type: 'delete', + target: 'page', + item: page, + title: 'νŽ˜μ΄μ§€ μ‚­μ œ', + description: `"${page.page_name}" νŽ˜μ΄μ§€λ₯Ό μ‚­μ œν•˜μ‹œκ² μŠ΅λ‹ˆκΉŒ? μ—°κ²°λœ μ„Ήμ…˜μ€ 독립 μ„Ήμ…˜μœΌλ‘œ λ³€κ²½λ©λ‹ˆλ‹€.`, + }, + })); + }; + + // μ„Ήμ…˜ μΆ”κ°€ + const openAddSectionDialog = () => { + if (!selectedPageId) return; + setDialogState((prev) => ({ + ...prev, + sectionDialog: { open: true, mode: 'create', section: null, pageId: selectedPageId }, + })); + }; + + // μ„Ήμ…˜ μ €μž₯ + const handleSaveSection = async (data: { + title: string; + section_type: SectionType; + description: string; + is_collapsible: boolean; + is_default_open: boolean; + is_template: boolean; + is_default: boolean; + order_no: number; + }, pageId?: number | null) => { + if (dialogState.sectionDialog.mode === 'create' && pageId) { + await createSectionInPage(pageId, data); + } else if (dialogState.sectionDialog.section) { + await updateSection(dialogState.sectionDialog.section.id, data); + } + }; + + // μ„Ήμ…˜ μ‚­μ œ 확인 + const openDeleteSectionDialog = (section: SectionEntity) => { + setDialogState((prev) => ({ + ...prev, + deleteConfirm: { + open: true, + type: 'delete', + target: 'section', + item: section, + title: 'μ„Ήμ…˜ μ‚­μ œ', + description: `"${section.title}" μ„Ήμ…˜μ„ μ‚­μ œν•˜μ‹œκ² μŠ΅λ‹ˆκΉŒ? μ—°κ²°λœ ν•„λ“œλŠ” 독립 ν•„λ“œλ‘œ λ³€κ²½λ©λ‹ˆλ‹€.`, + }, + })); + }; + + // μ„Ήμ…˜ μ—°κ²° ν•΄μ œ 확인 + const openUnlinkSectionDialog = (section: SectionEntity) => { + setDialogState((prev) => ({ + ...prev, + deleteConfirm: { + open: true, + type: 'unlink', + target: 'section', + item: section, + title: 'μ„Ήμ…˜ μ—°κ²° ν•΄μ œ', + description: `"${section.title}" μ„Ήμ…˜μ„ 이 νŽ˜μ΄μ§€μ—μ„œ μ—°κ²° ν•΄μ œν•˜μ‹œκ² μŠ΅λ‹ˆκΉŒ? μ„Ήμ…˜μ€ 독립 μ„Ήμ…˜μœΌλ‘œ λ³€κ²½λ©λ‹ˆλ‹€.`, + }, + })); + }; + + // ν•„λ“œ μΆ”κ°€ + const openAddFieldDialog = (sectionId: number) => { + setDialogState((prev) => ({ + ...prev, + fieldDialog: { open: true, mode: 'create', field: null, sectionId }, + })); + }; + + // ν•„λ“œ μˆ˜μ • + const openEditFieldDialog = (field: FieldEntity, sectionId: number) => { + setDialogState((prev) => ({ + ...prev, + fieldDialog: { open: true, mode: 'edit', field, sectionId }, + })); + }; + + // ν•„λ“œ μ €μž₯ + const handleSaveField = async (data: { + field_name: string; + field_key: string; + field_type: FieldType; + is_required: boolean; + placeholder: string; + default_value: string; + options: Array<{ label: string; value: string }>; + order_no: number; + }, sectionId?: number | null) => { + if (dialogState.fieldDialog.mode === 'create' && sectionId) { + await createFieldInSection(sectionId, data); + } else if (dialogState.fieldDialog.field) { + // TODO: updateField κ΅¬ν˜„ + } + }; + + // ν•„λ“œ μ‚­μ œ 확인 + const openDeleteFieldDialog = (field: FieldEntity) => { + setDialogState((prev) => ({ + ...prev, + deleteConfirm: { + open: true, + type: 'delete', + target: 'field', + item: field, + title: 'ν•„λ“œ μ‚­μ œ', + description: `"${field.field_name}" ν•„λ“œλ₯Ό μ‚­μ œν•˜μ‹œκ² μŠ΅λ‹ˆκΉŒ?`, + }, + })); + }; + + // ν•„λ“œ μ—°κ²° ν•΄μ œ 확인 + const openUnlinkFieldDialog = (field: FieldEntity) => { + setDialogState((prev) => ({ + ...prev, + deleteConfirm: { + open: true, + type: 'unlink', + target: 'field', + item: field, + title: 'ν•„λ“œ μ—°κ²° ν•΄μ œ', + description: `"${field.field_name}" ν•„λ“œλ₯Ό 이 μ„Ήμ…˜μ—μ„œ μ—°κ²° ν•΄μ œν•˜μ‹œκ² μŠ΅λ‹ˆκΉŒ?`, + }, + })); + }; + + // BOM μΆ”κ°€ + const openAddBomDialog = (sectionId: number) => { + setDialogState((prev) => ({ + ...prev, + bomDialog: { open: true, mode: 'create', bomItem: null, sectionId }, + })); + }; + + // BOM μˆ˜μ • + const openEditBomDialog = (bomItem: BOMItemEntity, sectionId: number) => { + setDialogState((prev) => ({ + ...prev, + bomDialog: { open: true, mode: 'edit', bomItem, sectionId }, + })); + }; + + // BOM μ €μž₯ + const handleSaveBom = async (data: { + item_code: string; + item_name: string; + quantity: number; + unit: string; + unit_price: number; + spec: string; + note: string; + }, sectionId: number) => { + if (dialogState.bomDialog.mode === 'create') { + await createBomItem({ + ...data, + section_id: sectionId, + order_no: getSectionBomItems(sectionId).length, + }); + } else if (dialogState.bomDialog.bomItem) { + await updateBomItem(dialogState.bomDialog.bomItem.id, data); + } + }; + + // BOM μ‚­μ œ 확인 + const openDeleteBomDialog = (bomItem: BOMItemEntity) => { + setDialogState((prev) => ({ + ...prev, + deleteConfirm: { + open: true, + type: 'delete', + target: 'bom', + item: bomItem, + title: 'BOM ν•­λͺ© μ‚­μ œ', + description: `"${bomItem.item_name}" ν•­λͺ©μ„ μ‚­μ œν•˜μ‹œκ² μŠ΅λ‹ˆκΉŒ?`, + }, + })); + }; + + // μ‚­μ œ/μ—°κ²°ν•΄μ œ 확인 처리 + const handleConfirmDelete = async () => { + const { type, target, item } = dialogState.deleteConfirm; + if (!item) return; + + if (type === 'delete') { + switch (target) { + case 'page': + await deletePage((item as PageEntity).id); + if (selectedPageId === (item as PageEntity).id) { + setSelectedPageId(null); + } + break; + case 'section': + await deleteSection((item as SectionEntity).id); + break; + case 'field': + await deleteField((item as FieldEntity).id); + break; + case 'bom': + await deleteBomItem((item as BOMItemEntity).id); + break; + } + } else if (type === 'unlink') { + switch (target) { + case 'section': + await unlinkSectionFromPage((item as SectionEntity).id); + break; + case 'field': + await unlinkFieldFromSection((item as FieldEntity).id); + break; + } + } + }; + + // λ‹€μ΄μ–Όλ‘œκ·Έ λ‹«κΈ° + const closeDialog = (dialogName: keyof DialogState) => { + setDialogState((prev) => ({ + ...prev, + [dialogName]: { ...initialDialogState[dialogName] }, + })); + }; + + // μ„Ήμ…˜ 뢈러였기 λ‹€μ΄μ–Όλ‘œκ·Έ μ—΄κΈ° + const openImportSectionDialog = () => { + if (!selectedPageId) return; + setDialogState((prev) => ({ + ...prev, + importSectionDialog: { open: true, pageId: selectedPageId }, + })); + }; + + // ν•„λ“œ 뢈러였기 λ‹€μ΄μ–Όλ‘œκ·Έ μ—΄κΈ° + const openImportFieldDialog = (sectionId: number) => { + setDialogState((prev) => ({ + ...prev, + importFieldDialog: { open: true, sectionId }, + })); + }; + + return ( + <> +
+ {/* 쒌츑: νŽ˜μ΄μ§€ λͺ©λ‘ */} + + +
+ νŽ˜μ΄μ§€ + +
+
+ + {pages.length === 0 ? ( +

+ νŽ˜μ΄μ§€κ°€ μ—†μŠ΅λ‹ˆλ‹€ +

+ ) : ( + pages.map((page) => ( +
setSelectedPageId(page.id)} + className={` + p-3 rounded-lg cursor-pointer transition-colors border group + ${selectedPageId === page.id + ? 'bg-blue-50 border-blue-200 dark:bg-blue-900/20 dark:border-blue-800' + : 'hover:bg-gray-50 dark:hover:bg-gray-800' + } + `} + > +
+
+
+ updatePage(page.id, { page_name: value })} + placeholder="νŽ˜μ΄μ§€ 이름" + displayClassName="truncate block" + /> +
+
+ {ITEM_TYPE_OPTIONS.find((t) => t.value === page.item_type)?.label || page.item_type} +
+
+ + updatePage(page.id, { absolute_path: value })} + placeholder="경둜 μž…λ ₯" + allowEmpty + displayClassName="truncate font-mono" + /> +
+
+
+ + {page.is_active ? 'ν™œμ„±' : 'λΉ„ν™œμ„±'} + + +
+
+
+ )) + )} +
+
+ + {/* 우츑: μ„Ήμ…˜ 및 ν•„λ“œ λͺ©λ‘ */} + + +
+ + {selectedPage ? selectedPage.page_name : 'νŽ˜μ΄μ§€λ₯Ό μ„ νƒν•˜μ„Έμš”'} + + {selectedPage && ( +
+ + +
+ )} +
+
+ + {!selectedPage ? ( +

+ μ™Όμͺ½μ—μ„œ νŽ˜μ΄μ§€λ₯Ό μ„ νƒν•˜μ„Έμš” +

+ ) : pageSections.length === 0 ? ( +

+ μ„Ήμ…˜μ„ μΆ”κ°€ν•΄μ£Όμ„Έμš” +

+ ) : ( +
+ {pageSections.map((section) => { + const isCollapsed = collapsedSections[section.id] ?? false; + const fields = getSectionFields(section.id); + const bomItems = getSectionBomItems(section.id); + const isBomSection = section.section_type === 'BOM'; + + return ( + toggleSection(section.id)} + onReorder={handleReorderSections} + onTitleSave={(title) => updateSection(section.id, { title })} + onUnlink={() => openUnlinkSectionDialog(section)} + onDelete={() => openDeleteSectionDialog(section)} + fieldCount={fields.length} + bomCount={bomItems.length} + > + {isBomSection ? ( + // BOM μ„Ήμ…˜ + <> + {bomItems.length === 0 ? ( +

+ BOM ν•­λͺ©μ΄ μ—†μŠ΅λ‹ˆλ‹€ +

+ ) : ( + bomItems.map((bom) => ( +
+
+ + {bom.item_name} + + ({bom.item_code}) + +
+
+ + {bom.quantity} {bom.unit} + + + +
+
+ )) + )} + + + ) : ( + // ν•„λ“œ μ„Ήμ…˜ + <> + {fields.length === 0 ? ( +

+ ν•„λ“œκ°€ μ—†μŠ΅λ‹ˆλ‹€ +

+ ) : ( + fields.map((field) => ( + openEditFieldDialog(field, section.id)} + onUnlink={() => openUnlinkFieldDialog(field)} + onDelete={() => openDeleteFieldDialog(field)} + /> + )) + )} +
+ + +
+ + )} +
+ ); + })} +
+ )} +
+
+
+ + {/* λ‹€μ΄μ–Όλ‘œκ·Έλ“€ */} + !open && closeDialog('pageDialog')} + mode={dialogState.pageDialog.mode} + page={dialogState.pageDialog.page} + onSave={handleSavePage} + existingPagesCount={pages.length} + /> + + !open && closeDialog('sectionDialog')} + mode={dialogState.sectionDialog.mode} + section={dialogState.sectionDialog.section} + pageId={dialogState.sectionDialog.pageId} + onSave={handleSaveSection} + existingSectionsCount={pageSections.length} + /> + + !open && closeDialog('fieldDialog')} + mode={dialogState.fieldDialog.mode} + field={dialogState.fieldDialog.field} + sectionId={dialogState.fieldDialog.sectionId} + onSave={handleSaveField} + existingFieldsCount={ + dialogState.fieldDialog.sectionId + ? getSectionFields(dialogState.fieldDialog.sectionId).length + : 0 + } + /> + + {dialogState.bomDialog.sectionId && ( + !open && closeDialog('bomDialog')} + mode={dialogState.bomDialog.mode} + bomItem={dialogState.bomDialog.bomItem} + sectionId={dialogState.bomDialog.sectionId} + onSave={handleSaveBom} + /> + )} + + !open && closeDialog('deleteConfirm')} + type={dialogState.deleteConfirm.type} + title={dialogState.deleteConfirm.title} + description={dialogState.deleteConfirm.description} + onConfirm={handleConfirmDelete} + /> + + {/* Import λ‹€μ΄μ–Όλ‘œκ·Έ */} + {dialogState.importSectionDialog.pageId && ( + !open && closeDialog('importSectionDialog')} + pageId={dialogState.importSectionDialog.pageId} + /> + )} + + {dialogState.importFieldDialog.sectionId && ( + !open && closeDialog('importFieldDialog')} + sectionId={dialogState.importFieldDialog.sectionId} + /> + )} + + ); +} \ No newline at end of file diff --git a/src/app/[locale]/(protected)/items-management-test/components/InlineEdit.tsx b/src/app/[locale]/(protected)/items-management-test/components/InlineEdit.tsx new file mode 100644 index 00000000..bc13f4a4 --- /dev/null +++ b/src/app/[locale]/(protected)/items-management-test/components/InlineEdit.tsx @@ -0,0 +1,161 @@ +'use client'; + +/** + * 인라인 νŽΈμ§‘ μ»΄ν¬λ„ŒνŠΈ (Zustand 버전) + * + * κΈ°λŠ₯: + * - λ”λΈ”ν΄λ¦­μœΌλ‘œ νŽΈμ§‘ λͺ¨λ“œ μ „ν™˜ + * - Enter둜 μ €μž₯, Escape둜 μ·¨μ†Œ + * - 포컀슀 아웃 μ‹œ μžλ™ μ €μž₯ + */ + +import { useState, useRef, useEffect, useCallback } from 'react'; +import { Input } from '@/components/ui/input'; +import { cn } from '@/lib/utils'; + +interface InlineEditProps { + value: string; + onSave: (value: string) => void | Promise; + placeholder?: string; + className?: string; + inputClassName?: string; + displayClassName?: string; + disabled?: boolean; + /** 빈 κ°’ ν—ˆμš© μ—¬λΆ€ */ + allowEmpty?: boolean; + /** νŽΈμ§‘ λͺ¨λ“œμ—μ„œ ν‘œμ‹œν•  라벨 */ + editLabel?: string; +} + +export function InlineEdit({ + value, + onSave, + placeholder = 'μž…λ ₯ν•˜μ„Έμš”', + className, + inputClassName, + displayClassName, + disabled = false, + allowEmpty = false, + editLabel, +}: InlineEditProps) { + const [isEditing, setIsEditing] = useState(false); + const [editValue, setEditValue] = useState(value); + const [isSaving, setIsSaving] = useState(false); + const inputRef = useRef(null); + + // value prop이 λ³€κ²½λ˜λ©΄ editValue도 μ—…λ°μ΄νŠΈ + useEffect(() => { + if (!isEditing) { + setEditValue(value); + } + }, [value, isEditing]); + + // νŽΈμ§‘ λͺ¨λ“œ μ‹œμž‘ μ‹œ input에 포컀슀 + useEffect(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, [isEditing]); + + const handleDoubleClick = useCallback(() => { + if (!disabled) { + setIsEditing(true); + setEditValue(value); + } + }, [disabled, value]); + + const handleSave = useCallback(async () => { + // 빈 κ°’ 검증 + if (!allowEmpty && !editValue.trim()) { + setEditValue(value); + setIsEditing(false); + return; + } + + // 값이 λ³€κ²½λ˜μ§€ μ•Šμ€ 경우 + if (editValue === value) { + setIsEditing(false); + return; + } + + setIsSaving(true); + try { + await onSave(editValue.trim()); + setIsEditing(false); + } catch (error) { + console.error('[InlineEdit] μ €μž₯ μ‹€νŒ¨:', error); + // μ—λŸ¬ μ‹œ μ›λž˜ κ°’μœΌλ‘œ 볡원 + setEditValue(value); + } finally { + setIsSaving(false); + } + }, [allowEmpty, editValue, value, onSave]); + + const handleCancel = useCallback(() => { + setEditValue(value); + setIsEditing(false); + }, [value]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleSave(); + } else if (e.key === 'Escape') { + e.preventDefault(); + handleCancel(); + } + }, + [handleSave, handleCancel] + ); + + const handleBlur = useCallback(() => { + // μ €μž₯ 쀑이 아닐 λ•Œλ§Œ blur 처리 + if (!isSaving) { + handleSave(); + } + }, [isSaving, handleSave]); + + if (isEditing) { + return ( +
+ {editLabel && ( + + {editLabel} + + )} + setEditValue(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={handleBlur} + disabled={isSaving} + placeholder={placeholder} + className={cn( + 'h-auto py-0.5 px-1 text-sm', + isSaving && 'opacity-50', + inputClassName + )} + /> +
+ ); + } + + return ( + + {value || {placeholder}} + + ); +} \ No newline at end of file diff --git a/src/app/[locale]/(protected)/items-management-test/components/PropertiesTab.tsx b/src/app/[locale]/(protected)/items-management-test/components/PropertiesTab.tsx new file mode 100644 index 00000000..6992031e --- /dev/null +++ b/src/app/[locale]/(protected)/items-management-test/components/PropertiesTab.tsx @@ -0,0 +1,598 @@ +'use client'; + +/** + * 속성 νƒ­ μ»΄ν¬λ„ŒνŠΈ (Zustand 버전) + * + * λ‹¨μœ„, 재질, ν‘œλ©΄μ²˜λ¦¬ 관리 + * - λͺ©λ‘ ν‘œμ‹œ (ν…Œμ΄λΈ”) + * - 검색 κΈ°λŠ₯ + * - μΆ”κ°€/μˆ˜μ •/μ‚­μ œ κΈ°λŠ₯ + */ + +import { useState, useMemo } from 'react'; +import { toast } from 'sonner'; +import { useItemMasterStore } from '@/stores/item-master/useItemMasterStore'; +import type { + ItemUnitRef, + ItemMaterialRef, + SurfaceTreatmentRef, + MaterialType, + TreatmentType, +} from '@/stores/item-master/types'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Input } from '@/components/ui/input'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { + Plus, + Edit, + Trash2, + Search, + Ruler, + Palette, + Sparkles, + CheckCircle2, + XCircle, +} from 'lucide-react'; +import { DeleteConfirmDialog } from './dialogs'; +import { PropertyDialog, type PropertyType, type PropertyData } from './dialogs/PropertyDialog'; + +// νƒ­ μ„€μ • +const TAB_CONFIG = { + units: { + id: 'units', + label: 'λ‹¨μœ„', + icon: Ruler, + description: 'ν’ˆλͺ© μˆ˜λŸ‰ λ‹¨μœ„ 관리', + }, + materials: { + id: 'materials', + label: '재질', + icon: Palette, + description: 'ν’ˆλͺ© 재질 관리', + }, + treatments: { + id: 'treatments', + label: 'ν‘œλ©΄μ²˜λ¦¬', + icon: Sparkles, + description: 'ν‘œλ©΄μ²˜λ¦¬ 방법 관리', + }, +} as const; + +// 재질 μœ ν˜• λ ˆμ΄λΈ” +const MATERIAL_TYPE_LABELS: Record = { + STEEL: 'μ² κ°•', + ALUMINUM: 'μ•Œλ£¨λ―ΈλŠ„', + PLASTIC: 'ν”ŒλΌμŠ€ν‹±', + OTHER: '기타', +}; + +// ν‘œλ©΄μ²˜λ¦¬ μœ ν˜• λ ˆμ΄λΈ” +const TREATMENT_TYPE_LABELS: Record = { + PAINTING: '도μž₯', + COATING: 'μ½”νŒ…', + PLATING: 'λ„κΈˆ', + NONE: 'μ—†μŒ', +}; + +// λ‹€μ΄μ–Όλ‘œκ·Έ μƒνƒœ νƒ€μž… +interface DialogState { + type: 'add' | 'edit' | 'delete' | null; + propertyType: PropertyType; + data?: PropertyData; +} + +export function PropertiesTab() { + // === Zustand μŠ€ν† μ–΄ === + const { + references, + addUnit, + updateUnit, + deleteUnit, + addMaterial, + updateMaterial, + deleteMaterial, + addTreatment, + updateTreatment, + deleteTreatment, + } = useItemMasterStore(); + + // === 둜컬 μƒνƒœ === + const [activeTab, setActiveTab] = useState<'units' | 'materials' | 'treatments'>('units'); + const [searchTerm, setSearchTerm] = useState(''); + const [dialog, setDialog] = useState({ type: null, propertyType: 'unit' }); + + // === 검색 필터링 === + const filteredUnits = useMemo(() => { + if (!searchTerm) return references.itemUnits; + const term = searchTerm.toLowerCase(); + return references.itemUnits.filter( + (u) => + u.unitCode.toLowerCase().includes(term) || + u.unitName.toLowerCase().includes(term) || + u.description?.toLowerCase().includes(term) + ); + }, [references.itemUnits, searchTerm]); + + const filteredMaterials = useMemo(() => { + if (!searchTerm) return references.itemMaterials; + const term = searchTerm.toLowerCase(); + return references.itemMaterials.filter( + (m) => + m.materialCode.toLowerCase().includes(term) || + m.materialName.toLowerCase().includes(term) || + m.description?.toLowerCase().includes(term) + ); + }, [references.itemMaterials, searchTerm]); + + const filteredTreatments = useMemo(() => { + if (!searchTerm) return references.surfaceTreatments; + const term = searchTerm.toLowerCase(); + return references.surfaceTreatments.filter( + (t) => + t.treatmentCode.toLowerCase().includes(term) || + t.treatmentName.toLowerCase().includes(term) || + t.description?.toLowerCase().includes(term) + ); + }, [references.surfaceTreatments, searchTerm]); + + // === λ‹€μ΄μ–Όλ‘œκ·Έ ν•Έλ“€λŸ¬ === + const handleAdd = (propertyType: PropertyType) => { + setDialog({ type: 'add', propertyType }); + }; + + const handleEdit = (propertyType: PropertyType, data: PropertyData) => { + setDialog({ type: 'edit', propertyType, data }); + }; + + const handleDelete = (propertyType: PropertyType, data: PropertyData) => { + setDialog({ type: 'delete', propertyType, data }); + }; + + const closeDialog = () => { + setDialog({ type: null, propertyType: 'unit' }); + }; + + // === μ €μž₯ ν•Έλ“€λŸ¬ === + const handleSave = async (data: PropertyData) => { + const isEdit = dialog.type === 'edit'; + const propertyType = dialog.propertyType; + const typeLabel = + propertyType === 'unit' ? 'λ‹¨μœ„' : propertyType === 'material' ? '재질' : 'ν‘œλ©΄μ²˜λ¦¬'; + + try { + if (propertyType === 'unit') { + const unitData = { + unitCode: data.code, + unitName: data.name, + description: data.description, + isActive: data.isActive ?? true, + }; + + if (isEdit && data.id) { + await updateUnit(data.id, unitData); + toast.success(`${typeLabel}κ°€ μˆ˜μ •λ˜μ—ˆμŠ΅λ‹ˆλ‹€.`); + } else { + await addUnit(unitData); + toast.success(`${typeLabel}κ°€ μΆ”κ°€λ˜μ—ˆμŠ΅λ‹ˆλ‹€.`); + } + } else if (propertyType === 'material') { + const materialData = { + materialCode: data.code, + materialName: data.name, + materialType: (data.type as MaterialType) || 'OTHER', + thickness: data.thickness, + description: data.description, + isActive: data.isActive ?? true, + }; + + if (isEdit && data.id) { + await updateMaterial(data.id, materialData); + toast.success(`${typeLabel}이 μˆ˜μ •λ˜μ—ˆμŠ΅λ‹ˆλ‹€.`); + } else { + await addMaterial(materialData); + toast.success(`${typeLabel}이 μΆ”κ°€λ˜μ—ˆμŠ΅λ‹ˆλ‹€.`); + } + } else if (propertyType === 'treatment') { + const treatmentData = { + treatmentCode: data.code, + treatmentName: data.name, + treatmentType: (data.type as TreatmentType) || 'NONE', + description: data.description, + isActive: data.isActive ?? true, + }; + + if (isEdit && data.id) { + await updateTreatment(data.id, treatmentData); + toast.success(`${typeLabel}κ°€ μˆ˜μ •λ˜μ—ˆμŠ΅λ‹ˆλ‹€.`); + } else { + await addTreatment(treatmentData); + toast.success(`${typeLabel}κ°€ μΆ”κ°€λ˜μ—ˆμŠ΅λ‹ˆλ‹€.`); + } + } + + closeDialog(); + } catch (error) { + console.error('[PropertiesTab] Save error:', error); + toast.error(`${typeLabel} ${isEdit ? 'μˆ˜μ •' : 'μΆ”κ°€'}에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.`); + } + }; + + // === μ‚­μ œ ν•Έλ“€λŸ¬ === + const handleConfirmDelete = async () => { + const propertyType = dialog.propertyType; + const typeLabel = + propertyType === 'unit' ? 'λ‹¨μœ„' : propertyType === 'material' ? '재질' : 'ν‘œλ©΄μ²˜λ¦¬'; + const id = dialog.data?.id; + + if (!id) { + toast.error('μ‚­μ œν•  ν•­λͺ©μ΄ μ„ νƒλ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€.'); + closeDialog(); + return; + } + + try { + if (propertyType === 'unit') { + await deleteUnit(id); + } else if (propertyType === 'material') { + await deleteMaterial(id); + } else if (propertyType === 'treatment') { + await deleteTreatment(id); + } + + toast.success(`${typeLabel}κ°€ μ‚­μ œλ˜μ—ˆμŠ΅λ‹ˆλ‹€.`); + closeDialog(); + } catch (error) { + console.error('[PropertiesTab] Delete error:', error); + toast.error(`${typeLabel} μ‚­μ œμ— μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.`); + closeDialog(); + } + }; + + // === λ‹¨μœ„ β†’ PropertyData λ³€ν™˜ === + const unitToPropertyData = (unit: ItemUnitRef): PropertyData => ({ + id: unit.id, + code: unit.unitCode, + name: unit.unitName, + description: unit.description, + isActive: unit.isActive, + }); + + // === 재질 β†’ PropertyData λ³€ν™˜ === + const materialToPropertyData = (material: ItemMaterialRef): PropertyData => ({ + id: material.id, + code: material.materialCode, + name: material.materialName, + type: material.materialType, + thickness: material.thickness, + description: material.description, + isActive: material.isActive, + }); + + // === ν‘œλ©΄μ²˜λ¦¬ β†’ PropertyData λ³€ν™˜ === + const treatmentToPropertyData = (treatment: SurfaceTreatmentRef): PropertyData => ({ + id: treatment.id, + code: treatment.treatmentCode, + name: treatment.treatmentName, + type: treatment.treatmentType, + description: treatment.description, + isActive: treatment.isActive, + }); + + // === ν˜„μž¬ νƒ­ μ„€μ • === + const currentConfig = TAB_CONFIG[activeTab]; + const Icon = currentConfig.icon; + + return ( +
+ {/* νƒ­ 헀더 */} + setActiveTab(v as typeof activeTab)}> +
+ + {Object.values(TAB_CONFIG).map((tab) => { + const TabIcon = tab.icon; + return ( + + + {tab.label} + + ); + })} + + + {/* 검색 & μΆ”κ°€ λ²„νŠΌ */} +
+
+ + setSearchTerm(e.target.value)} + placeholder="검색..." + className="pl-9 w-64" + /> +
+ +
+
+ + {/* λ‹¨μœ„ νƒ­ */} + + + +
+ + + λ‹¨μœ„ λͺ©λ‘ + + {filteredUnits.length}개 +
+
+ + {filteredUnits.length === 0 ? ( +
+ {searchTerm ? '검색 κ²°κ³Όκ°€ μ—†μŠ΅λ‹ˆλ‹€' : 'λ“±λ‘λœ λ‹¨μœ„κ°€ μ—†μŠ΅λ‹ˆλ‹€'} +
+ ) : ( + + + + 번호 + μ½”λ“œ + λ‹¨μœ„λͺ… + μ„€λͺ… + μƒνƒœ + μž‘μ—… + + + + {filteredUnits.map((unit, index) => ( + + {index + 1} + {unit.unitCode} + {unit.unitName} + + {unit.description || '-'} + + + {unit.isActive ? ( + + ) : ( + + )} + + +
+ + +
+
+
+ ))} +
+
+ )} +
+
+
+ + {/* 재질 νƒ­ */} + + + +
+ + + 재질 λͺ©λ‘ + + {filteredMaterials.length}개 +
+
+ + {filteredMaterials.length === 0 ? ( +
+ {searchTerm ? '검색 κ²°κ³Όκ°€ μ—†μŠ΅λ‹ˆλ‹€' : 'λ“±λ‘λœ 재질이 μ—†μŠ΅λ‹ˆλ‹€'} +
+ ) : ( + + + + 번호 + μ½”λ“œ + 재질λͺ… + μœ ν˜• + λ‘κ»˜ + μ„€λͺ… + μƒνƒœ + μž‘μ—… + + + + {filteredMaterials.map((material, index) => ( + + {index + 1} + + {material.materialCode} + + {material.materialName} + + + {MATERIAL_TYPE_LABELS[material.materialType] || material.materialType} + + + + {material.thickness || '-'} + + + {material.description || '-'} + + + {material.isActive ? ( + + ) : ( + + )} + + +
+ + +
+
+
+ ))} +
+
+ )} +
+
+
+ + {/* ν‘œλ©΄μ²˜λ¦¬ νƒ­ */} + + + +
+ + + ν‘œλ©΄μ²˜λ¦¬ λͺ©λ‘ + + {filteredTreatments.length}개 +
+
+ + {filteredTreatments.length === 0 ? ( +
+ {searchTerm ? '검색 κ²°κ³Όκ°€ μ—†μŠ΅λ‹ˆλ‹€' : 'λ“±λ‘λœ ν‘œλ©΄μ²˜λ¦¬κ°€ μ—†μŠ΅λ‹ˆλ‹€'} +
+ ) : ( + + + + 번호 + μ½”λ“œ + 처리λͺ… + μœ ν˜• + μ„€λͺ… + μƒνƒœ + μž‘μ—… + + + + {filteredTreatments.map((treatment, index) => ( + + {index + 1} + + {treatment.treatmentCode} + + {treatment.treatmentName} + + + {TREATMENT_TYPE_LABELS[treatment.treatmentType] || treatment.treatmentType} + + + + {treatment.description || '-'} + + + {treatment.isActive ? ( + + ) : ( + + )} + + +
+ + +
+
+
+ ))} +
+
+ )} +
+
+
+
+ + {/* μΆ”κ°€/μˆ˜μ • λ‹€μ΄μ–Όλ‘œκ·Έ */} + !open && closeDialog()} + mode={dialog.type === 'edit' ? 'edit' : 'add'} + propertyType={dialog.propertyType} + initialData={dialog.data} + onSave={handleSave} + /> + + {/* μ‚­μ œ 확인 λ‹€μ΄μ–Όλ‘œκ·Έ */} + !open && closeDialog()} + type="delete" + title={`${ + dialog.propertyType === 'unit' + ? 'λ‹¨μœ„' + : dialog.propertyType === 'material' + ? '재질' + : 'ν‘œλ©΄μ²˜λ¦¬' + } μ‚­μ œ`} + description={`"${dialog.data?.name || ''}"을(λ₯Ό) μ‚­μ œν•˜μ‹œκ² μŠ΅λ‹ˆκΉŒ? 이 μž‘μ—…μ€ 되돌릴 수 μ—†μŠ΅λ‹ˆλ‹€.`} + onConfirm={handleConfirmDelete} + /> +
+ ); +} \ No newline at end of file diff --git a/src/app/[locale]/(protected)/items-management-test/components/SectionsTab.tsx b/src/app/[locale]/(protected)/items-management-test/components/SectionsTab.tsx new file mode 100644 index 00000000..d1a5f168 --- /dev/null +++ b/src/app/[locale]/(protected)/items-management-test/components/SectionsTab.tsx @@ -0,0 +1,448 @@ +'use client'; + +/** + * μ„Ήμ…˜ νƒ­ μ»΄ν¬λ„ŒνŠΈ (Zustand 버전) + * + * - λͺ¨λ“  μ„Ήμ…˜ ν‘œμ‹œ (μ—°κ²°λœ μ„Ήμ…˜ + 독립 μ„Ήμ…˜) + * - 일반 μ„Ήμ…˜ / BOM μ„Ήμ…˜ 뢄리 + * - μ„Ήμ…˜ μΆ”κ°€/μˆ˜μ •/μ‚­μ œ + * - ν•„λ“œ 관리 연동 + */ + +import { useState, useMemo } from 'react'; +import { useItemMasterStore } from '@/stores/item-master/useItemMasterStore'; +import type { SectionEntity, FieldEntity } from '@/stores/item-master/types'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { + Plus, + Edit, + Trash2, + Folder, + Package, + FileText, + GripVertical, + Copy, + Download, + Unlink, + Link, + ChevronDown, + ChevronRight, +} from 'lucide-react'; +import { SectionDialog, FieldDialog, DeleteConfirmDialog } from './dialogs'; +import { toast } from 'sonner'; + +// μž…λ ₯ νƒ€μž… μ˜΅μ…˜ +const INPUT_TYPE_OPTIONS = [ + { value: 'textbox', label: 'ν…μŠ€νŠΈλ°•μŠ€' }, + { value: 'dropdown', label: 'λ“œλ‘­λ‹€μš΄' }, + { value: 'checkbox', label: 'μ²΄ν¬λ°•μŠ€' }, + { value: 'number', label: '숫자' }, + { value: 'date', label: 'λ‚ μ§œ' }, + { value: 'textarea', label: 'ν…μŠ€νŠΈμ˜μ—­' }, +]; + +// λ‹€μ΄μ–Όλ‘œκ·Έ μƒνƒœ νƒ€μž… +interface DialogState { + type: + | 'section-add' + | 'section-edit' + | 'section-delete' + | 'field-add' + | 'field-edit' + | 'field-delete' + | 'field-unlink' + | null; + sectionId?: number; + fieldId?: number; +} + +export function SectionsTab() { + // === Zustand μŠ€ν† μ–΄ === + const { entities, ids, deleteSection, deleteField, unlinkFieldFromSection, cloneSection } = useItemMasterStore(); + + // === 둜컬 μƒνƒœ === + const [expandedSections, setExpandedSections] = useState>({}); + const [dialog, setDialog] = useState({ type: null }); + + // === νŒŒμƒ μƒνƒœ: λͺ¨λ“  μ„Ήμ…˜ λͺ©λ‘ === + const allSections = useMemo(() => { + return Object.values(entities.sections); + }, [entities.sections]); + + // 일반 μ„Ήμ…˜ (BOM이 μ•„λ‹Œ μ„Ήμ…˜) + const generalSections = useMemo(() => { + return allSections.filter((s) => s.section_type !== 'BOM'); + }, [allSections]); + + // BOM μ„Ήμ…˜ + const bomSections = useMemo(() => { + return allSections.filter((s) => s.section_type === 'BOM'); + }, [allSections]); + + // === μ„Ήμ…˜ ν™•μž₯/μΆ•μ†Œ ν† κΈ€ === + const toggleSection = (sectionId: number) => { + setExpandedSections((prev) => ({ + ...prev, + [sectionId]: !prev[sectionId], + })); + }; + + // === ν•„λ“œ κ°€μ Έμ˜€κΈ° 헬퍼 === + const getFieldsForSection = (section: SectionEntity): FieldEntity[] => { + return section.fieldIds.map((fId) => entities.fields[fId]).filter(Boolean); + }; + + // === νŽ˜μ΄μ§€ μ—°κ²° μƒνƒœ 확인 === + const getPageName = (section: SectionEntity): string | null => { + if (section.page_id === null) return null; + const page = entities.pages[section.page_id]; + return page?.page_name || null; + }; + + // === λ‹€μ΄μ–Όλ‘œκ·Έ ν•Έλ“€λŸ¬ === + const handleAddSection = () => { + setDialog({ type: 'section-add' }); + }; + + const handleEditSection = (sectionId: number) => { + setDialog({ type: 'section-edit', sectionId }); + }; + + const handleDeleteSection = (sectionId: number) => { + setDialog({ type: 'section-delete', sectionId }); + }; + + const handleAddField = (sectionId: number) => { + setDialog({ type: 'field-add', sectionId }); + }; + + const handleEditField = (sectionId: number, fieldId: number) => { + setDialog({ type: 'field-edit', sectionId, fieldId }); + }; + + const handleUnlinkField = (sectionId: number, fieldId: number) => { + setDialog({ type: 'field-unlink', sectionId, fieldId }); + }; + + const closeDialog = () => { + setDialog({ type: null }); + }; + + // === μ‚­μ œ/μ—°κ²°ν•΄μ œ μ‹€ν–‰ === + const handleConfirmDelete = async () => { + if (dialog.type === 'section-delete' && dialog.sectionId) { + await deleteSection(dialog.sectionId); + } else if (dialog.type === 'field-delete' && dialog.fieldId) { + await deleteField(dialog.fieldId); + } else if (dialog.type === 'field-unlink' && dialog.fieldId) { + await unlinkFieldFromSection(dialog.fieldId); + } + closeDialog(); + }; + + // === μ„Ήμ…˜ 볡제 ν•Έλ“€λŸ¬ === + const handleCloneSection = async (sectionId: number) => { + try { + const section = entities.sections[sectionId]; + const clonedSection = await cloneSection(sectionId); + toast.success(`"${section?.title}" μ„Ήμ…˜μ„ λ³΅μ œν–ˆμŠ΅λ‹ˆλ‹€.`); + console.log('[SectionsTab] Clone section completed:', clonedSection); + } catch (error) { + console.error('[SectionsTab] Clone section failed:', error); + toast.error('μ„Ήμ…˜ λ³΅μ œμ— μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.'); + } + }; + + // === μ„Ήμ…˜ μΉ΄λ“œ λ Œλ”λ§ === + const renderSectionCard = (section: SectionEntity, isModule: boolean = false) => { + const isExpanded = expandedSections[section.id] ?? false; + const fields = getFieldsForSection(section); + const pageName = getPageName(section); + const Icon = isModule ? Package : Folder; + const iconColor = isModule ? 'text-green-500' : 'text-blue-500'; + + return ( + + +
+
+ +
+
+ {section.title} + {pageName && ( + + + {pageName} + + )} + {section.page_id === null && ( + + 독립 μ„Ήμ…˜ + + )} +
+ {section.description && ( + {section.description} + )} +
+
+
+ + + +
+
+
+ + {/* ν•„λ“œ λͺ©λ‘ (일반 μ„Ήμ…˜λ§Œ) */} + {!isModule && ( + +
+
+ + + ν•„λ“œ {fields.length}개 + +
+ +
+ + {isExpanded && ( + <> + {fields.length === 0 ? ( +
+
+ +

λ“±λ‘λœ ν•„λ“œκ°€ μ—†μŠ΅λ‹ˆλ‹€

+

ν•„λ“œ μΆ”κ°€ λ²„νŠΌμ„ ν΄λ¦­ν•˜μ„Έμš”

+
+
+ ) : ( +
+ {fields.map((field, index) => ( +
+
+
+ + {field.field_name} + + {INPUT_TYPE_OPTIONS.find((t) => t.value === field.field_type)?.label || + field.field_type} + + {field.is_required && ( + + ν•„μˆ˜ + + )} +
+
+ ν•„λ“œν‚€: {field.field_key || 'N/A'} + {field.placeholder && β€’ {field.placeholder}} +
+
+
+ + +
+
+ ))} +
+ )} + + )} +
+ )} + + {/* BOM ν•­λͺ© (λͺ¨λ“ˆ μ„Ήμ…˜λ§Œ) */} + {isModule && ( + +
+ BOM 관리 UI (μΆ”ν›„ κ΅¬ν˜„) +
+
+ )} +
+ ); + }; + + // === ν˜„μž¬ νŽΈμ§‘ 쀑인 μ„Ήμ…˜/ν•„λ“œ κ°€μ Έμ˜€κΈ° === + const currentSection = dialog.sectionId ? entities.sections[dialog.sectionId] : undefined; + const currentField = dialog.fieldId ? entities.fields[dialog.fieldId] : undefined; + + return ( + + +
+
+ μ„Ήμ…˜ 관리 + + μž¬μ‚¬μš© κ°€λŠ₯ν•œ μ„Ήμ…˜μ„ κ΄€λ¦¬ν•©λ‹ˆλ‹€. 총 {allSections.length}개 μ„Ήμ…˜ + +
+ +
+
+ + + + + + 일반 μ„Ήμ…˜ ({generalSections.length}) + + + + λͺ¨λ“ˆ μ„Ήμ…˜ ({bomSections.length}) + + + + {/* 일반 μ„Ήμ…˜ νƒ­ */} + + {generalSections.length === 0 ? ( +
+ +

λ“±λ‘λœ 일반 μ„Ήμ…˜μ΄ μ—†μŠ΅λ‹ˆλ‹€

+

+ μ„Ήμ…˜μΆ”κ°€ λ²„νŠΌμ„ 눌러 μž¬μ‚¬μš© κ°€λŠ₯ν•œ μ„Ήμ…˜μ„ λ“±λ‘ν•˜μ„Έμš”. +

+
+ ) : ( +
+ {generalSections.map((section) => renderSectionCard(section, false))} +
+ )} +
+ + {/* λͺ¨λ“ˆ μ„Ήμ…˜ (BOM) νƒ­ */} + + {bomSections.length === 0 ? ( +
+ +

λ“±λ‘λœ λͺ¨λ“ˆ μ„Ήμ…˜μ΄ μ—†μŠ΅λ‹ˆλ‹€

+

+ μ„Ήμ…˜μΆ”κ°€ λ²„νŠΌμ„ 눌러 BOM λͺ¨λ“ˆ μ„Ήμ…˜μ„ λ“±λ‘ν•˜μ„Έμš”. +

+
+ ) : ( +
+ {bomSections.map((section) => renderSectionCard(section, true))} +
+ )} +
+
+
+ + {/* === λ‹€μ΄μ–Όλ‘œκ·Έ === */} + + {/* μ„Ήμ…˜ μΆ”κ°€ λ‹€μ΄μ–Όλ‘œκ·Έ */} + !open && closeDialog()} + mode="add" + pageId={null} + /> + + {/* μ„Ήμ…˜ μˆ˜μ • λ‹€μ΄μ–Όλ‘œκ·Έ */} + {currentSection && ( + !open && closeDialog()} + mode="edit" + pageId={currentSection.page_id} + section={currentSection} + /> + )} + + {/* μ„Ήμ…˜ μ‚­μ œ 확인 */} + !open && closeDialog()} + onConfirm={handleConfirmDelete} + title="μ„Ήμ…˜ μ‚­μ œ" + description="이 μ„Ήμ…˜μ„ μ‚­μ œν•˜μ‹œκ² μŠ΅λ‹ˆκΉŒ? μ„Ήμ…˜μ— μ—°κ²°λœ ν•„λ“œλ“€μ€ 독립 ν•„λ“œλ‘œ λ³€κ²½λ©λ‹ˆλ‹€." + itemName={currentSection?.title} + /> + + {/* ν•„λ“œ μΆ”κ°€ λ‹€μ΄μ–Όλ‘œκ·Έ */} + {dialog.sectionId && ( + !open && closeDialog()} + mode="add" + sectionId={dialog.sectionId} + /> + )} + + {/* ν•„λ“œ μˆ˜μ • λ‹€μ΄μ–Όλ‘œκ·Έ */} + {currentField && dialog.sectionId && ( + !open && closeDialog()} + mode="edit" + sectionId={dialog.sectionId} + field={currentField} + /> + )} + + {/* ν•„λ“œ μ—°κ²° ν•΄μ œ 확인 */} + !open && closeDialog()} + onConfirm={handleConfirmDelete} + title="ν•„λ“œ μ—°κ²° ν•΄μ œ" + description="이 ν•„λ“œλ₯Ό μ„Ήμ…˜μ—μ„œ μ—°κ²° ν•΄μ œν•˜μ‹œκ² μŠ΅λ‹ˆκΉŒ? ν•„λ“œλŠ” μ‚­μ œλ˜μ§€ μ•Šκ³  독립 ν•„λ“œλ‘œ λ³€κ²½λ©λ‹ˆλ‹€." + itemName={currentField?.field_name} + confirmText="μ—°κ²° ν•΄μ œ" + variant="warning" + /> +
+ ); +} \ No newline at end of file diff --git a/src/app/[locale]/(protected)/items-management-test/components/dialogs/BOMDialog.tsx b/src/app/[locale]/(protected)/items-management-test/components/dialogs/BOMDialog.tsx new file mode 100644 index 00000000..e2d413cb --- /dev/null +++ b/src/app/[locale]/(protected)/items-management-test/components/dialogs/BOMDialog.tsx @@ -0,0 +1,282 @@ +'use client'; + +/** + * BOM ν•­λͺ© μΆ”κ°€/μˆ˜μ • λ‹€μ΄μ–Όλ‘œκ·Έ + * + * BOM CRUD λ‹€μ΄μ–Όλ‘œκ·Έ - Phase B-4 + */ + +import { useState, useEffect } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { Loader2 } from 'lucide-react'; +import type { BOMItemEntity } from '@/stores/item-master/types'; + +interface BOMFormData { + item_code: string; + item_name: string; + quantity: number; + unit: string; + unit_price: number; + spec: string; + note: string; +} + +const initialFormData: BOMFormData = { + item_code: '', + item_name: '', + quantity: 1, + unit: 'EA', + unit_price: 0, + spec: '', + note: '', +}; + +interface BOMDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + mode: 'create' | 'edit'; + bomItem?: BOMItemEntity | null; + sectionId: number; + onSave: (data: BOMFormData, sectionId: number) => Promise; +} + +export function BOMDialog({ + open, + onOpenChange, + mode, + bomItem, + sectionId, + onSave, +}: BOMDialogProps) { + const [formData, setFormData] = useState(initialFormData); + const [isLoading, setIsLoading] = useState(false); + const [errors, setErrors] = useState>>({}); + + // λͺ¨λ“œλ³„ 타이틀 + const title = mode === 'create' ? 'BOM ν•­λͺ© μΆ”κ°€' : 'BOM ν•­λͺ© μˆ˜μ •'; + + // 총 κΈˆμ•‘ 계산 + const totalPrice = formData.quantity * formData.unit_price; + + // 데이터 μ΄ˆκΈ°ν™” + useEffect(() => { + if (open) { + if (mode === 'edit' && bomItem) { + setFormData({ + item_code: bomItem.item_code || '', + item_name: bomItem.item_name, + quantity: bomItem.quantity, + unit: bomItem.unit || 'EA', + unit_price: bomItem.unit_price || 0, + spec: bomItem.spec || '', + note: bomItem.note || '', + }); + } else { + setFormData(initialFormData); + } + setErrors({}); + } + }, [open, mode, bomItem]); + + // ν•„λ“œ λ³€κ²½ ν•Έλ“€λŸ¬ + const handleChange = (field: keyof BOMFormData, value: string | number) => { + setFormData((prev) => ({ ...prev, [field]: value })); + if (errors[field]) { + setErrors((prev) => ({ ...prev, [field]: undefined })); + } + }; + + // 숫자 ν•„λ“œ λ³€κ²½ ν•Έλ“€λŸ¬ + const handleNumberChange = (field: 'quantity' | 'unit_price', value: string) => { + const numValue = parseFloat(value) || 0; + handleChange(field, numValue); + }; + + // μœ νš¨μ„± 검사 + const validate = (): boolean => { + const newErrors: Partial> = {}; + + if (!formData.item_name.trim()) { + newErrors.item_name = 'ν’ˆλͺ©λͺ…을 μž…λ ₯ν•˜μ„Έμš”'; + } + + if (formData.quantity <= 0) { + newErrors.quantity = 'μˆ˜λŸ‰μ€ 0보닀 컀야 ν•©λ‹ˆλ‹€'; + } + + if (!formData.unit.trim()) { + newErrors.unit = 'λ‹¨μœ„λ₯Ό μž…λ ₯ν•˜μ„Έμš”'; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + // μ €μž₯ ν•Έλ“€λŸ¬ + const handleSave = async () => { + if (!validate()) return; + + setIsLoading(true); + try { + await onSave(formData, sectionId); + onOpenChange(false); + } catch (error) { + console.error('BOM ν•­λͺ© μ €μž₯ μ‹€νŒ¨:', error); + } finally { + setIsLoading(false); + } + }; + + return ( + + + + {title} + + {mode === 'create' + ? 'BOM(Bill of Materials) ν•­λͺ©μ„ μΆ”κ°€ν•©λ‹ˆλ‹€.' + : 'BOM ν•­λͺ© 정보λ₯Ό μˆ˜μ •ν•©λ‹ˆλ‹€.'} + + + +
+ {/* ν’ˆλͺ©μ½”λ“œ, ν’ˆλͺ©λͺ… */} +
+
+ + handleChange('item_code', e.target.value)} + placeholder="예: PT-001" + className="font-mono" + /> +
+
+ + handleChange('item_name', e.target.value)} + placeholder="예: 볼트 M10x30" + className={errors.item_name ? 'border-red-500' : ''} + /> + {errors.item_name && ( +

{errors.item_name}

+ )} +
+
+ + {/* μˆ˜λŸ‰, λ‹¨μœ„ */} +
+
+ + handleNumberChange('quantity', e.target.value)} + className={errors.quantity ? 'border-red-500' : ''} + /> + {errors.quantity && ( +

{errors.quantity}

+ )} +
+
+ + handleChange('unit', e.target.value)} + placeholder="예: EA, KG, M" + className={errors.unit ? 'border-red-500' : ''} + /> + {errors.unit && ( +

{errors.unit}

+ )} +
+
+ + {/* 단가, κΈˆμ•‘ */} +
+
+ + handleNumberChange('unit_price', e.target.value)} + /> +
+
+ +
+ {totalPrice.toLocaleString()} 원 +
+
+
+ + {/* 규격 */} +
+ + handleChange('spec', e.target.value)} + placeholder="예: SUS304, 길이 100mm" + /> +
+ + {/* λΉ„κ³  */} +
+ +