/** * 선택 개소 상세 정보 패널 * * - 제품 정보 (제품명, 오픈사이즈, 제작사이즈, 산출중량, 산출면적, 수량) * - 필수 설정 (가이드레일, 전원, 제어기) * - 탭: BOM subtotals 기반 동적 생성 (주자재, 모터, 제어기, 절곡품, 부자재, 검사비, 기타) * - 탭별 품목 테이블 (grouped_items 기반) */ "use client"; import { useState, useMemo, useEffect } from "react"; import { Package, Plus, Trash2, Loader2, Calculator, Save } from "lucide-react"; import { fetchItemPrices } from "@/lib/api/items"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { NumberInput } from "../ui/number-input"; import { QuantityInput } from "../ui/quantity-input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "../ui/select"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "../ui/table"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"; import { ItemSearchModal } from "./ItemSearchModal"; import type { LocationItem } from "./QuoteRegistration"; import type { FinishedGoods } from "./actions"; import type { BomCalculationResultItem } from "./types"; // 납품길이 옵션 const DELIVERY_LENGTH_OPTIONS = [ { value: "3000", label: "3000" }, { value: "4000", label: "4000" }, { value: "5000", label: "5000" }, { value: "6000", label: "6000" }, ]; // 빈 BOM 아이템 생성 함수 (동적 탭에 맞게) function createEmptyBomItems(tabs: TabDefinition[]): Record { const result: Record = {}; tabs.forEach((tab) => { result[tab.value] = []; }); return result; } // ============================================================================= // 상수 // ============================================================================= // 층 옵션 (지하3층 ~ 지상10층) const FLOOR_OPTIONS = [ "B3", "B2", "B1", "1F", "2F", "3F", "4F", "5F", "6F", "7F", "8F", "9F", "10F", "R", ]; // 가이드레일 설치 유형 const GUIDE_RAIL_TYPES = [ { value: "wall", label: "벽면형" }, { value: "floor", label: "측면형" }, { value: "mixed", label: "혼합형" }, ]; // 모터 전원 const MOTOR_POWERS = [ { value: "single", label: "단상(220V)" }, { value: "three", label: "삼상(380V)" }, ]; // 연동제어기 const CONTROLLERS = [ { value: "basic", label: "단독" }, { value: "smart", label: "연동" }, { value: "premium", label: "매립형-뒷박스포함" }, ]; // 탭 인터페이스 정의 interface TabDefinition { value: string; // subtotals 키 (예: material, motor, steel) label: string; // 표시 이름 } // 기본 탭 정의 (BOM 계산 전 fallback) const DEFAULT_TABS: TabDefinition[] = [ { value: "material", label: "주자재" }, { value: "motor", label: "모터" }, { value: "controller", label: "제어기" }, { value: "steel", label: "절곡품" }, { value: "parts", label: "부자재" }, { value: "inspection", label: "검사비" }, { value: "etc", label: "기타" }, ]; // ============================================================================= // Props // ============================================================================= interface LocationDetailPanelProps { location: LocationItem | null; onUpdateLocation: (locationId: string, updates: Partial) => void; onDeleteLocation?: (locationId: string) => void; onCalculateLocation?: (locationId: string) => Promise; onSaveItems?: () => void; finishedGoods: FinishedGoods[]; locationCodes?: string[]; disabled?: boolean; isCalculating?: boolean; } // ============================================================================= // 컴포넌트 // ============================================================================= export function LocationDetailPanel({ location, onUpdateLocation, onDeleteLocation, onCalculateLocation, onSaveItems, finishedGoods, locationCodes = [], disabled = false, isCalculating = false, }: LocationDetailPanelProps) { // --------------------------------------------------------------------------- // 상태 // --------------------------------------------------------------------------- const [activeTab, setActiveTab] = useState("material"); const [itemSearchOpen, setItemSearchOpen] = useState(false); // --------------------------------------------------------------------------- // 탭 정의 (bomResult.subtotals 기반 동적 생성 + 기타 탭) // --------------------------------------------------------------------------- const detailTabs = useMemo((): TabDefinition[] => { if (!location?.bomResult?.subtotals) { return DEFAULT_TABS; } const subtotals = location.bomResult.subtotals; const tabs: TabDefinition[] = []; Object.entries(subtotals).forEach(([key, value]) => { if (typeof value === "object" && value !== null) { const obj = value as { name?: string }; tabs.push({ value: key, label: obj.name || key, }); } }); // 기타 탭 추가 tabs.push({ value: "etc", label: "기타" }); return tabs; }, [location?.bomResult?.subtotals]); // location 변경 시 activeTab이 유효한 탭인지 확인하고 리셋 useEffect(() => { if (detailTabs.length > 0 && !detailTabs.some((t) => t.value === activeTab)) { setActiveTab(detailTabs[0].value); } }, [location?.id, detailTabs]); // --------------------------------------------------------------------------- // 계산된 값 // --------------------------------------------------------------------------- // 제품 정보 const product = useMemo(() => { if (!location?.productCode) return null; return finishedGoods.find((fg) => fg.item_code === location.productCode); }, [location?.productCode, finishedGoods]); // BOM 아이템을 탭별로 분류 (grouped_items 기반) const bomItemsByTab = useMemo(() => { if (!location?.bomResult?.grouped_items) { return createEmptyBomItems(detailTabs); } const groupedItems = location.bomResult.grouped_items; const result: Record = {}; // 탭별 빈 배열 초기화 detailTabs.forEach((tab) => { result[tab.value] = []; }); // grouped_items에서 각 카테고리의 items를 탭에 매핑 Object.entries(groupedItems).forEach(([key, group]) => { const items = (group as { items?: unknown[] })?.items || []; if (result[key]) { result[key] = items as BomCalculationResultItem[]; } else { // subtotals에 없는 카테고리 → 기타에 추가 result["etc"] = [ ...(result["etc"] || []), ...(items as BomCalculationResultItem[]), ]; } }); // 수동 추가 품목 (is_manual) 중 grouped_items에 없는 것 → 해당 탭 or 기타 const allItems = location.bomResult.items || []; const manualItems = allItems.filter( (item: BomCalculationResultItem & { is_manual?: boolean }) => item.is_manual === true ); manualItems.forEach((item: BomCalculationResultItem & { category_code?: string }) => { const tabKey = item.category_code || "etc"; if (result[tabKey]) { // 이미 grouped_items에서 추가되었는지 확인 const exists = result[tabKey].some( (existing) => existing.item_code === item.item_code && existing.item_name === item.item_name ); if (!exists) { result[tabKey].push(item); } } else { result["etc"] = [...(result["etc"] || []), item]; } }); return result; }, [location?.bomResult?.grouped_items, location?.bomResult?.items, detailTabs]); // 탭별 소계 const tabSubtotals = useMemo(() => { const result: Record = {}; Object.entries(bomItemsByTab).forEach(([tab, items]) => { result[tab] = items.reduce((sum: number, item: { total_price?: number }) => sum + (item.total_price || 0), 0); }); return result; }, [bomItemsByTab]); // --------------------------------------------------------------------------- // 핸들러 // --------------------------------------------------------------------------- const handleFieldChange = (field: keyof LocationItem, value: string | number) => { if (!location || disabled) return; onUpdateLocation(location.id, { [field]: value }); }; // --------------------------------------------------------------------------- // 렌더링: 빈 상태 // --------------------------------------------------------------------------- if (!location) { return (

개소를 선택해주세요

왼쪽 목록에서 개소를 선택하면 상세 정보가 표시됩니다

); } // --------------------------------------------------------------------------- // 렌더링: 상세 정보 // --------------------------------------------------------------------------- return (
{/* ②-1 개소 정보 영역 */}
{/* 1행: 층, 부호, 가로, 세로, 제품코드 */}
handleFieldChange("floor", e.target.value)} disabled={disabled} className="h-8 text-sm" /> {FLOOR_OPTIONS.map((f) => (
handleFieldChange("code", e.target.value)} disabled={disabled} className="h-8 text-sm" /> {locationCodes.map((code) => (
handleFieldChange("openWidth", value ?? 0)} disabled={disabled} className="h-8 text-sm" />
handleFieldChange("openHeight", value ?? 0)} disabled={disabled} className="h-8 text-sm" />
{/* 2행: 가이드레일, 전원, 제어기 */}
{/* 3행: 제작사이즈, 산출중량, 산출면적, 수량, 산출하기 */}
제작사이즈

{String(location.bomResult?.variables?.W1 ?? location.manufactureWidth ?? "-")} X {String(location.bomResult?.variables?.H1 ?? location.manufactureHeight ?? "-")}

산출중량

{(location.bomResult?.variables?.K ?? location.bomResult?.variables?.WEIGHT) ? `${Number(location.bomResult?.variables?.K ?? location.bomResult?.variables?.WEIGHT).toFixed(2)} kg` : "-"}

산출면적

{(location.bomResult?.variables?.M ?? location.bomResult?.variables?.AREA) ? `${Number(location.bomResult?.variables?.M ?? location.bomResult?.variables?.AREA).toFixed(2)} m²` : "-"}

수량 (QTY) { if (!location || disabled || newQty === undefined) return; // 수량 변경 시 totalPrice 재계산 const unitPrice = location.unitPrice || 0; onUpdateLocation(location.id, { quantity: newQty, totalPrice: unitPrice * newQty, }); }} className="h-8 text-sm font-semibold" min={1} disabled={disabled} />
{/* ②-2 품목 상세 영역 */}
{/* 탭 목록 */}
{detailTabs.map((tab) => ( {tab.label} ))}
{/* 탭 콘텐츠 */} {detailTabs.map((tab) => { const items = bomItemsByTab[tab.value] || []; const isBendingTab = tab.value === "steel"; return (
품목명 규격 {isBendingTab && ( 납품길이 )} 수량 {items.length === 0 ? ( 산출된 품목이 없습니다 ) : ( items.map((item: any, index: number) => ( {item.item_name} {item.spec || item.specification || "-"} {isBendingTab && ( )} { if (!location || disabled || newQty === undefined) return; const existingBomResult = location.bomResult; if (!existingBomResult) return; const targetItem = item; const targetCode = targetItem.item_code; const targetName = targetItem.item_name; // grouped_items 내 해당 탭에서 index로 매칭하여 items에서 찾기 const groupItems = existingBomResult.grouped_items?.[tab.value]?.items || []; const matchedItem = groupItems[index]; if (!matchedItem) return; // items 배열에서 같은 item_code + item_name 매칭 (동일 코드 복수 가능하므로 순서 기반) let matchCount = 0; const updatedItems = (existingBomResult.items || []).map((bomItem: any) => { if (bomItem.item_code === targetCode && bomItem.item_name === targetName) { matchCount++; // grouped_items 내 순서와 items 내 순서가 같은 아이템 찾기 if (bomItem.quantity === targetItem.quantity && bomItem.unit_price === targetItem.unit_price) { const newTotalPrice = (bomItem.unit_price || 0) * newQty; return { ...bomItem, quantity: newQty, total_price: newTotalPrice }; } } return bomItem; }); // grouped_items도 업데이트 const updatedGroupedItems: Record = { ...existingBomResult.grouped_items }; if (updatedGroupedItems[tab.value]) { const updatedTabItems = [...(updatedGroupedItems[tab.value].items || [])]; if (updatedTabItems[index]) { const newTotalPrice = (updatedTabItems[index].unit_price || 0) * newQty; updatedTabItems[index] = { ...updatedTabItems[index], quantity: newQty, total_price: newTotalPrice }; } updatedGroupedItems[tab.value] = { ...updatedGroupedItems[tab.value], items: updatedTabItems }; } // grand_total 재계산 const newGrandTotal = updatedItems.reduce( (sum: number, bi: any) => sum + (bi.total_price || 0), 0 ); // subtotals 재계산 const updatedSubtotals = { ...existingBomResult.subtotals }; Object.entries(updatedGroupedItems).forEach(([key, group]) => { const groupItemsList = (group as any)?.items || []; const subtotal = groupItemsList.reduce((s: number, gi: any) => s + (gi.total_price || 0), 0); const existing = updatedSubtotals[key]; if (typeof existing === 'object' && existing !== null) { updatedSubtotals[key] = { ...existing, count: groupItemsList.length, subtotal }; } }); onUpdateLocation(location.id, { unitPrice: newGrandTotal, totalPrice: newGrandTotal * location.quantity, bomResult: { ...existingBomResult, items: updatedItems, grouped_items: updatedGroupedItems, subtotals: updatedSubtotals, grand_total: newGrandTotal, }, }); }} className="w-14 h-7 text-center text-xs" min={1} disabled={disabled} /> )) )}
{/* 품목 추가 + 저장 버튼 */}
); })}
{/* 품목 검색 모달 */} { if (!location) return; const currentTab = detailTabs.find((t) => t.value === activeTab); const categoryCode = activeTab; const categoryLabel = currentTab?.label || activeTab; // 단가 조회 (클라이언트 API 호출) let unitPrice = 0; try { const priceResult = await fetchItemPrices([item.code]); unitPrice = priceResult[item.code]?.unit_price || 0; } catch (error) { console.error('[품목 추가] 단가 조회 실패:', error); } const totalPrice = unitPrice * 1; // quantity = 1 const newItem: BomCalculationResultItem & { category_code?: string; is_manual?: boolean } = { item_code: item.code, item_name: item.name, specification: item.specification || "", unit: "EA", quantity: 1, unit_price: unitPrice, total_price: totalPrice, process_group: categoryLabel, category_code: categoryCode, is_manual: true, }; const existingBomResult = location.bomResult || { finished_goods: { code: location.productCode || "", name: location.productName || "" }, subtotals: {}, grouped_items: {}, grand_total: 0, items: [], }; const existingItems = existingBomResult.items || []; const updatedItems = [...existingItems, newItem]; const existingSubtotals = existingBomResult.subtotals || {}; const rawCategorySubtotal = existingSubtotals[categoryCode]; const categorySubtotal = (typeof rawCategorySubtotal === 'object' && rawCategorySubtotal !== null) ? rawCategorySubtotal : { name: categoryLabel, count: 0, subtotal: 0 }; const updatedSubtotals = { ...existingSubtotals, [categoryCode]: { name: categoryLabel, count: (categorySubtotal.count || 0) + 1, subtotal: (categorySubtotal.subtotal || 0) + (newItem.total_price || 0), }, }; const existingGroupedItems = existingBomResult.grouped_items || {}; const categoryGroupedItems = existingGroupedItems[categoryCode] || { items: [] }; const updatedGroupedItems = { ...existingGroupedItems, [categoryCode]: { ...categoryGroupedItems, items: [...(categoryGroupedItems.items || []), newItem], }, }; const updatedGrandTotal = (existingBomResult.grand_total || 0) + (newItem.total_price || 0); const updatedBomResult = { ...existingBomResult, items: updatedItems, subtotals: updatedSubtotals, grouped_items: updatedGroupedItems, grand_total: updatedGrandTotal, }; // unitPrice, totalPrice도 함께 업데이트 onUpdateLocation(location.id, { bomResult: updatedBomResult, unitPrice: updatedGrandTotal, totalPrice: updatedGrandTotal * location.quantity, }); }} tabLabel={detailTabs.find((t) => t.value === activeTab)?.label} />
); }