diff --git a/src/components/quotes/LocationDetailPanel.tsx b/src/components/quotes/LocationDetailPanel.tsx index 47bcfd43..1761bc7e 100644 --- a/src/components/quotes/LocationDetailPanel.tsx +++ b/src/components/quotes/LocationDetailPanel.tsx @@ -9,8 +9,9 @@ "use client"; -import { useState, useMemo } from "react"; -import { Package, Settings, Plus, Trash2 } from "lucide-react"; +import { useState, useMemo, useEffect } from "react"; +import { Package, Settings, Plus, Trash2, Loader2 } from "lucide-react"; +import { getItemCategoryTree, type ItemCategoryNode } from "./actions"; import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; @@ -48,15 +49,14 @@ const DELIVERY_LENGTH_OPTIONS = [ { value: "6000", label: "6000" }, ]; -// 빈 BOM 아이템 (bomResult 없을 때 사용) -const EMPTY_BOM_ITEMS: Record = { - body: [], - "guide-rail": [], - case: [], - bottom: [], - motor: [], - accessory: [], -}; +// 빈 BOM 아이템 생성 함수 (동적 탭에 맞게) +function createEmptyBomItems(tabs: TabDefinition[]): Record { + const result: Record = {}; + tabs.forEach((tab) => { + result[tab.value] = []; + }); + return result; +} // ============================================================================= // 상수 @@ -81,14 +81,54 @@ const CONTROLLERS = [ { value: "premium", label: "매립형-뒷박스포함" }, ]; -// 탭 정의 (6개) -const DETAIL_TABS = [ - { value: "body", label: "본체 (스크린/슬랫)" }, - { value: "guide-rail", label: "절곡품 - 가이드레일" }, - { value: "case", label: "절곡품 - 케이스" }, - { value: "bottom", label: "절곡품 - 하단마감재" }, - { value: "motor", label: "모터 & 제어기" }, - { value: "accessory", label: "부자재" }, +// 탭 인터페이스 정의 +interface TabDefinition { + value: string; // 카테고리 code (예: BODY, BENDING_GUIDE) + label: string; // 표시 이름 + categoryId: number; // 카테고리 ID + parentCode?: string; // 상위 카테고리 코드 +} + +/** + * 카테고리 트리를 탭 배열로 변환 + * - 본체, 모터&제어기, 부자재 → 1depth 탭 + * - 절곡품 → 하위 카테고리를 각각 탭으로 (가이드레일, 케이스, 하단마감재) + */ +function convertCategoryTreeToTabs(categories: ItemCategoryNode[]): TabDefinition[] { + const tabs: TabDefinition[] = []; + + categories.forEach((category) => { + if (category.code === "BENDING") { + // 절곡품: 하위 카테고리를 탭으로 변환 + category.children.forEach((subCategory) => { + tabs.push({ + value: subCategory.code, + label: `절곡품 - ${subCategory.name}`, + categoryId: subCategory.id, + parentCode: category.code, + }); + }); + } else { + // 본체, 모터&제어기, 부자재: 1depth 탭 + tabs.push({ + value: category.code, + label: category.name, + categoryId: category.id, + }); + } + }); + + return tabs; +} + +// 기본 탭 정의 (API 로딩 전 또는 실패 시 fallback) +const DEFAULT_TABS: TabDefinition[] = [ + { value: "BODY", label: "본체", categoryId: 0 }, + { value: "BENDING_GUIDE", label: "절곡품 - 가이드레일", categoryId: 0, parentCode: "BENDING" }, + { value: "BENDING_CASE", label: "절곡품 - 케이스", categoryId: 0, parentCode: "BENDING" }, + { value: "BENDING_BOTTOM", label: "절곡품 - 하단마감재", categoryId: 0, parentCode: "BENDING" }, + { value: "MOTOR_CTRL", label: "모터 & 제어기", categoryId: 0 }, + { value: "ACCESSORY", label: "부자재", categoryId: 0 }, ]; // ============================================================================= @@ -116,9 +156,45 @@ export function LocationDetailPanel({ // 상태 // --------------------------------------------------------------------------- - const [activeTab, setActiveTab] = useState("body"); + const [activeTab, setActiveTab] = useState("BODY"); const [itemSearchOpen, setItemSearchOpen] = useState(false); const [editModalOpen, setEditModalOpen] = useState(false); + const [itemCategories, setItemCategories] = useState([]); + const [categoriesLoading, setCategoriesLoading] = useState(true); + + // --------------------------------------------------------------------------- + // 카테고리 로드 + // --------------------------------------------------------------------------- + + useEffect(() => { + async function loadCategories() { + try { + setCategoriesLoading(true); + const result = await getItemCategoryTree(); + if (result.success && result.data) { + setItemCategories(result.data); + } else { + console.error("[카테고리 로드 실패]", result.error); + } + } catch (error) { + console.error("[카테고리 로드 에러]", error); + } finally { + setCategoriesLoading(false); + } + } + loadCategories(); + }, []); + + // --------------------------------------------------------------------------- + // 탭 정의 (카테고리 기반 동적 생성) + // --------------------------------------------------------------------------- + + const detailTabs = useMemo(() => { + if (itemCategories.length === 0) { + return DEFAULT_TABS; + } + return convertCategoryTreeToTabs(itemCategories); + }, [itemCategories]); // --------------------------------------------------------------------------- // 계산된 값 @@ -130,56 +206,97 @@ export function LocationDetailPanel({ return finishedGoods.find((fg) => fg.item_code === location.productCode); }, [location?.productCode, finishedGoods]); - // BOM 아이템을 탭별로 분류 + // BOM 아이템을 탭별로 분류 (카테고리 코드 기반) const bomItemsByTab = useMemo(() => { // bomResult가 없으면 빈 배열 반환 if (!location?.bomResult?.items) { - return EMPTY_BOM_ITEMS; + return createEmptyBomItems(detailTabs); } const items = location.bomResult.items; - const result: Record = { - body: [], - "guide-rail": [], - case: [], - bottom: [], - }; + const result: Record = {}; + + // 탭별 빈 배열 초기화 + detailTabs.forEach((tab) => { + result[tab.value] = []; + }); + + // 카테고리 코드 → 탭 value 매핑 생성 + // (절곡품 하위 카테고리 매핑 포함) + const categoryCodeToTab: Record = {}; + itemCategories.forEach((category) => { + if (category.code === "BENDING") { + // 절곡품: 하위 카테고리별로 탭 매핑 + category.children.forEach((subCategory) => { + categoryCodeToTab[subCategory.code] = subCategory.code; + // 하위의 세부 카테고리도 매핑 + subCategory.children?.forEach((detailCategory) => { + categoryCodeToTab[detailCategory.code] = subCategory.code; + }); + }); + } else { + // 일반 카테고리 + categoryCodeToTab[category.code] = category.code; + // 하위 카테고리도 상위 탭에 매핑 + category.children?.forEach((child) => { + categoryCodeToTab[child.code] = category.code; + }); + } + }); items.forEach((item) => { - // process_group_key (API 그룹 키) 또는 process_group (한글명) 사용 - const processGroupKey = (item as { process_group_key?: string }).process_group_key?.toLowerCase() || ""; + // 1. category_code로 분류 (우선) + const categoryCode = (item as { category_code?: string }).category_code?.toUpperCase() || ""; + const mappedTab = categoryCodeToTab[categoryCode]; + + if (mappedTab && result[mappedTab]) { + result[mappedTab].push(item); + return; + } + + // 2. process_group_key로 분류 (legacy fallback) + const processGroupKey = (item as { process_group_key?: string }).process_group_key?.toUpperCase() || ""; + + // 기존 process_group_key → 새 카테고리 코드 매핑 + const legacyMapping: Record = { + "SCREEN": "BODY", + "ASSEMBLY": "BODY", + "BENDING": "BENDING_GUIDE", // 기본적으로 가이드레일에 배치 + "STEEL": "BENDING_CASE", + "ELECTRIC": "BENDING_BOTTOM", + "MOTOR": "MOTOR_CTRL", + "ACCESSORY": "ACCESSORY", + }; + + const legacyTab = legacyMapping[processGroupKey]; + if (legacyTab && result[legacyTab]) { + result[legacyTab].push(item); + return; + } + + // 3. process_group (한글명) 기반 분류 (최종 fallback) const processGroup = item.process_group?.toLowerCase() || ""; - // API 그룹 키 기반 분류 (우선) - if (processGroupKey === "screen" || processGroupKey === "assembly") { - result.body.push(item); - } else if (processGroupKey === "bending") { - result["guide-rail"].push(item); - } else if (processGroupKey === "steel") { - result.case.push(item); - } else if (processGroupKey === "electric") { - result.bottom.push(item); - } else if (processGroupKey) { - // 기타 그룹키는 본체에 포함 - result.body.push(item); - } - // 한글명 기반 분류 (fallback) - else if (processGroup.includes("본체") || processGroup.includes("스크린") || processGroup.includes("슬랫") || processGroup.includes("조립")) { - result.body.push(item); - } else if (processGroup.includes("가이드") || processGroup.includes("레일") || processGroup.includes("절곡")) { - result["guide-rail"].push(item); + if (processGroup.includes("본체") || processGroup.includes("스크린") || processGroup.includes("슬랫") || processGroup.includes("조립")) { + result["BODY"]?.push(item); + } else if (processGroup.includes("가이드") || processGroup.includes("레일")) { + result["BENDING_GUIDE"]?.push(item); } else if (processGroup.includes("케이스") || processGroup.includes("철재")) { - result.case.push(item); - } else if (processGroup.includes("하단") || processGroup.includes("마감") || processGroup.includes("전기")) { - result.bottom.push(item); + result["BENDING_CASE"]?.push(item); + } else if (processGroup.includes("하단") || processGroup.includes("마감")) { + result["BENDING_BOTTOM"]?.push(item); + } else if (processGroup.includes("모터") || processGroup.includes("제어기")) { + result["MOTOR_CTRL"]?.push(item); + } else if (processGroup.includes("부자재")) { + result["ACCESSORY"]?.push(item); } else { // 기타 항목은 본체에 포함 - result.body.push(item); + result["BODY"]?.push(item); } }); return result; - }, [location?.bomResult?.items]); + }, [location?.bomResult?.items, detailTabs, itemCategories]); // 탭별 소계 const tabSubtotals = useMemo(() => { @@ -339,270 +456,161 @@ export function LocationDetailPanel({ {/* 탭 목록 - 스크롤 가능 */}
- - {DETAIL_TABS.map((tab) => ( - - {tab.label} - - ))} - + {categoriesLoading ? ( +
+ + 카테고리 로딩 중... +
+ ) : ( + + {detailTabs.map((tab) => ( + + {tab.label} + + ))} + + )}
- {/* 본체 (스크린/슬랫) 탭 */} - -
- - - - 품목명 - 제작사이즈 - 수량 - 작업 - - - - {bomItemsByTab.body.map((item: any, index: number) => ( - - {item.item_name} - {item.manufacture_size || "-"} - - {}} - className="w-16 h-8 text-center" - min={1} - disabled={disabled} - /> - - - - - - ))} - -
+ {/* 동적 탭 콘텐츠 렌더링 */} + {detailTabs.map((tab) => { + const items = bomItemsByTab[tab.value] || []; + const isBendingTab = tab.parentCode === "BENDING"; + const isMotorTab = tab.value === "MOTOR_CTRL"; + const isAccessoryTab = tab.value === "ACCESSORY"; - {/* 품목 추가 버튼 + 안내 */} -
- - - 💡 금액은 아래 견적금액요약에서 확인하세요 - -
-
-
- - {/* 절곡품 - 가이드레일, 케이스, 하단마감재 탭 */} - {["guide-rail", "case", "bottom"].map((tabValue) => ( - -
- - - - 품목명 - 재질 - 규격 - 납품길이 - 수량 - 작업 - - - - {bomItemsByTab[tabValue]?.map((item: any, index: number) => ( - - {item.item_name} - {item.material || "-"} - {item.spec || "-"} - - - - - {}} - className="w-16 h-8 text-center" - min={1} - disabled={disabled} - /> - - - - + return ( + +
+
+ + + 품목명 + {/* 본체: 제작사이즈 */} + {!isBendingTab && !isMotorTab && !isAccessoryTab && ( + 제작사이즈 + )} + {/* 절곡품: 재질, 규격, 납품길이 */} + {isBendingTab && ( + <> + 재질 + 규격 + 납품길이 + + )} + {/* 모터: 유형, 사양 */} + {isMotorTab && ( + <> + 유형 + 사양 + + )} + {/* 부자재: 규격, 납품길이 */} + {isAccessoryTab && ( + <> + 규격 + 납품길이 + + )} + 수량 + 작업 - ))} - -
+ + + {items.map((item: any, index: number) => ( + + {item.item_name} + {/* 본체: 제작사이즈 */} + {!isBendingTab && !isMotorTab && !isAccessoryTab && ( + {item.manufacture_size || "-"} + )} + {/* 절곡품: 재질, 규격, 납품길이 */} + {isBendingTab && ( + <> + {item.material || "-"} + {item.spec || "-"} + + + + + )} + {/* 모터: 유형, 사양 */} + {isMotorTab && ( + <> + {item.type || "-"} + {item.spec || "-"} + + )} + {/* 부자재: 규격, 납품길이 */} + {isAccessoryTab && ( + <> + {item.spec || "-"} + + + + + )} + + {}} + className="w-16 h-8 text-center" + min={1} + disabled={disabled} + /> + + + + + + ))} + + - {/* 품목 추가 버튼 + 안내 */} -
- - - 💡 금액은 아래 견적금액요약에서 확인하세요 - + {/* 품목 추가 버튼 + 안내 */} +
+ + + 💡 금액은 아래 견적금액요약에서 확인하세요 + +
-
-
- ))} - - {/* 모터 & 제어기 탭 */} - -
- - - - 품목명 - 유형 - 사양 - 수량 - 작업 - - - - {bomItemsByTab.motor?.map((item: any, index: number) => ( - - {item.item_name} - {item.type || "-"} - {item.spec || "-"} - - {}} - className="w-16 h-8 text-center" - min={1} - disabled={disabled} - /> - - - - - - ))} - -
- - {/* 품목 추가 버튼 + 안내 */} -
- - - 💡 금액은 아래 견적금액요약에서 확인하세요 - -
-
-
- - {/* 부자재 탭 */} - -
- - - - 품목명 - 규격 - 납품길이 - 수량 - 작업 - - - - {bomItemsByTab.accessory?.map((item: any, index: number) => ( - - {item.item_name} - {item.spec || "-"} - - - - - {}} - className="w-16 h-8 text-center" - min={1} - disabled={disabled} - /> - - - - - - ))} - -
- - {/* 품목 추가 버튼 + 안내 */} -
- - - 💡 금액은 아래 견적금액요약에서 확인하세요 - -
-
-
+ + ); + })}
@@ -620,21 +628,13 @@ export function LocationDetailPanel({ onSelectItem={(item) => { if (!location) return; - // 탭 → process_group_key 매핑 - const tabToProcessGroup: Record = { - body: "screen", - "guide-rail": "bending", - case: "steel", - bottom: "electric", - motor: "motor", - accessory: "accessory", - }; + // 현재 탭 정보 가져오기 + const currentTab = detailTabs.find((t) => t.value === activeTab); + const categoryCode = activeTab; // 카테고리 코드를 직접 사용 + const categoryLabel = currentTab?.label || activeTab; - const processGroupKey = tabToProcessGroup[activeTab] || "screen"; - const processGroupLabel = DETAIL_TABS.find((t) => t.value === activeTab)?.label || activeTab; - - // 새 품목 생성 (수동 추가 플래그 포함) - const newItem: BomCalculationResultItem & { process_group_key?: string; is_manual?: boolean } = { + // 새 품목 생성 (카테고리 코드 포함) + const newItem: BomCalculationResultItem & { category_code?: string; is_manual?: boolean } = { item_code: item.code, item_name: item.name, specification: item.specification || "", @@ -642,8 +642,8 @@ export function LocationDetailPanel({ quantity: 1, unit_price: 0, total_price: 0, - process_group: processGroupLabel, - process_group_key: processGroupKey, + process_group: categoryLabel, + category_code: categoryCode, // 새 카테고리 코드 사용 is_manual: true, // 수동 추가 품목 표시 }; @@ -662,16 +662,14 @@ export function LocationDetailPanel({ // subtotals 업데이트 (해당 카테고리의 count, subtotal 증가) const existingSubtotals = existingBomResult.subtotals || {}; - const categorySubtotal = existingSubtotals[processGroupKey] || { - name: processGroupLabel, - count: 0, - subtotal: 0, - }; + const rawCategorySubtotal = existingSubtotals[categoryCode]; + const categorySubtotal = (typeof rawCategorySubtotal === 'object' && rawCategorySubtotal !== null) + ? rawCategorySubtotal + : { name: categoryLabel, count: 0, subtotal: 0 }; const updatedSubtotals = { ...existingSubtotals, - [processGroupKey]: { - ...categorySubtotal, - name: processGroupLabel, + [categoryCode]: { + name: categoryLabel, count: (categorySubtotal.count || 0) + 1, subtotal: (categorySubtotal.subtotal || 0) + (newItem.total_price || 0), }, @@ -679,10 +677,10 @@ export function LocationDetailPanel({ // grouped_items 업데이트 (해당 카테고리의 items 배열에 추가) const existingGroupedItems = existingBomResult.grouped_items || {}; - const categoryGroupedItems = existingGroupedItems[processGroupKey] || { items: [] }; + const categoryGroupedItems = existingGroupedItems[categoryCode] || { items: [] }; const updatedGroupedItems = { ...existingGroupedItems, - [processGroupKey]: { + [categoryCode]: { ...categoryGroupedItems, items: [...(categoryGroupedItems.items || []), newItem], }, @@ -702,9 +700,9 @@ export function LocationDetailPanel({ // location 업데이트 onUpdateLocation(location.id, { bomResult: updatedBomResult }); - console.log(`[품목 추가] ${item.code} - ${item.name} → ${processGroupLabel} (${processGroupKey})`); + console.log(`[품목 추가] ${item.code} - ${item.name} → ${categoryLabel} (${categoryCode})`); }} - tabLabel={DETAIL_TABS.find((t) => t.value === activeTab)?.label} + tabLabel={detailTabs.find((t) => t.value === activeTab)?.label} /> {/* 개소 정보 수정 모달 */} diff --git a/src/components/quotes/actions.ts b/src/components/quotes/actions.ts index 57c326a0..3d4630a0 100644 --- a/src/components/quotes/actions.ts +++ b/src/components/quotes/actions.ts @@ -1129,4 +1129,46 @@ export async function getSiteNames(): Promise<{ error: '서버 오류가 발생했습니다.', }; } +} + +// ===== 품목 카테고리 트리 조회 ===== +export interface ItemCategoryNode { + id: number; + code: string; + name: string; + is_active: number; + sort_order: number; + children: ItemCategoryNode[]; +} + +export async function getItemCategoryTree(): Promise<{ + success: boolean; + data: ItemCategoryNode[]; + error?: string; + __authError?: boolean; +}> { + try { + const response = await serverFetch('/api/v1/categories/tree?code_group=item_category&only_active=true'); + + if (!response.ok) { + if (response.status === 401) { + return { success: false, data: [], error: '인증이 필요합니다.', __authError: true }; + } + return { success: false, data: [], error: '카테고리 조회 실패' }; + } + + const result = await response.json(); + return { + success: true, + data: result.data || [], + }; + } catch (error) { + if (isNextRedirectError(error)) throw error; + console.error('[QuoteActions] getItemCategoryTree error:', error); + return { + success: false, + data: [], + error: '서버 오류가 발생했습니다.', + }; + } } \ No newline at end of file diff --git a/src/components/quotes/types.ts b/src/components/quotes/types.ts index 2024a58b..15fdf81b 100644 --- a/src/components/quotes/types.ts +++ b/src/components/quotes/types.ts @@ -406,6 +406,9 @@ export interface BomCalculationResultItem { unit_price: number; total_price: number; process_group?: string; + process_group_key?: string; // Legacy 공정 그룹 키 + category_code?: string; // 아이템 카테고리 코드 (동적 카테고리 시스템) + is_manual?: boolean; // 수동 추가 품목 여부 } // BOM 계산 결과 타입