Files
sam-react-prod/src/components/quotes/LocationDetailPanel.tsx

816 lines
32 KiB
TypeScript
Raw Normal View History

/**
*
*
* - (, , , , , )
* - (, , )
* - : 본체(/), -, -, -, &,
* - ( )
*/
"use client";
import { useState, useMemo, useEffect } from "react";
import { Package, Settings, Plus, Trash2, Loader2, Calculator, Save } from "lucide-react";
import { getItemCategoryTree, type ItemCategoryNode } from "./actions";
import { fetchItemPrices } from "@/lib/api/items";
import { Badge } from "../ui/badge";
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 "./QuoteRegistrationV2";
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<string, BomCalculationResultItem[]> {
const result: Record<string, BomCalculationResultItem[]> = {};
tabs.forEach((tab) => {
result[tab.value] = [];
});
return result;
}
// =============================================================================
// 상수
// =============================================================================
// 가이드레일 설치 유형
const GUIDE_RAIL_TYPES = [
{ value: "wall", label: "벽면형" },
{ value: "floor", label: "측면형" },
];
// 모터 전원
const MOTOR_POWERS = [
{ value: "single", label: "단상(220V)" },
{ value: "three", label: "삼상(380V)" },
];
// 연동제어기
const CONTROLLERS = [
{ value: "basic", label: "단독" },
{ value: "smart", label: "연동" },
{ value: "premium", label: "매립형-뒷박스포함" },
];
// 탭 인터페이스 정의
interface 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 },
];
// =============================================================================
// Props
// =============================================================================
interface LocationDetailPanelProps {
location: LocationItem | null;
onUpdateLocation: (locationId: string, updates: Partial<LocationItem>) => void;
onDeleteLocation?: (locationId: string) => void;
onCalculateLocation?: (locationId: string) => Promise<void>;
onSaveItems?: () => void;
finishedGoods: FinishedGoods[];
disabled?: boolean;
isCalculating?: boolean;
}
// =============================================================================
// 컴포넌트
// =============================================================================
export function LocationDetailPanel({
location,
onUpdateLocation,
onDeleteLocation,
onCalculateLocation,
onSaveItems,
finishedGoods,
disabled = false,
isCalculating = false,
}: LocationDetailPanelProps) {
// ---------------------------------------------------------------------------
// 상태
// ---------------------------------------------------------------------------
const [activeTab, setActiveTab] = useState("BODY");
const [itemSearchOpen, setItemSearchOpen] = useState(false);
const [itemCategories, setItemCategories] = useState<ItemCategoryNode[]>([]);
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]);
// ---------------------------------------------------------------------------
// 계산된 값
// ---------------------------------------------------------------------------
// 제품 정보
const product = useMemo(() => {
if (!location?.productCode) return null;
return finishedGoods.find((fg) => fg.item_code === location.productCode);
}, [location?.productCode, finishedGoods]);
// BOM 아이템을 탭별로 분류 (카테고리 코드 기반)
const bomItemsByTab = useMemo(() => {
// bomResult가 없으면 빈 배열 반환
if (!location?.bomResult?.items) {
return createEmptyBomItems(detailTabs);
}
const items = location.bomResult.items;
const result: Record<string, typeof items> = {};
// 탭별 빈 배열 초기화
detailTabs.forEach((tab) => {
result[tab.value] = [];
});
// 카테고리 코드 → 탭 value 매핑 생성
// (절곡품 하위 카테고리 매핑 포함)
const categoryCodeToTab: Record<string, string> = {};
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) => {
// 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<string, string> = {
"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() || "";
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["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);
}
});
return result;
}, [location?.bomResult?.items, detailTabs, itemCategories]);
// 탭별 소계
const tabSubtotals = useMemo(() => {
const result: Record<string, number> = {};
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 (
<div className="flex flex-col items-center justify-center h-full bg-gray-50 text-gray-500">
<Package className="h-12 w-12 mb-4 text-gray-300" />
<p className="text-lg font-medium"> </p>
<p className="text-sm"> </p>
</div>
);
}
// ---------------------------------------------------------------------------
// 렌더링: 상세 정보
// ---------------------------------------------------------------------------
return (
<div className="flex flex-col h-full overflow-hidden">
{/* ②-1 개소 정보 영역 */}
<div className="bg-gray-50 border-b">
{/* 1행: 층, 부호, 가로, 세로, 제품코드 */}
<div className="px-4 py-3 space-y-3">
<div className="grid grid-cols-5 gap-3">
<div>
<label className="text-xs text-gray-600"></label>
<Input
value={location.floor}
onChange={(e) => handleFieldChange("floor", e.target.value)}
disabled={disabled}
className="h-8 text-sm"
/>
</div>
<div>
<label className="text-xs text-gray-600"></label>
<Input
value={location.code}
onChange={(e) => handleFieldChange("code", e.target.value)}
disabled={disabled}
className="h-8 text-sm"
/>
</div>
<div>
<label className="text-xs text-gray-600"></label>
<NumberInput
value={location.openWidth}
onChange={(value) => handleFieldChange("openWidth", value ?? 0)}
disabled={disabled}
className="h-8 text-sm"
/>
</div>
<div>
<label className="text-xs text-gray-600"></label>
<NumberInput
value={location.openHeight}
onChange={(value) => handleFieldChange("openHeight", value ?? 0)}
disabled={disabled}
className="h-8 text-sm"
/>
</div>
<div>
<label className="text-xs text-gray-600"></label>
<Select
value={location.productCode}
onValueChange={(value) => {
const product = finishedGoods.find((fg) => fg.item_code === value);
onUpdateLocation(location.id, {
productCode: value,
productName: product?.item_name || value,
});
}}
disabled={disabled}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{finishedGoods.map((fg) => (
<SelectItem key={fg.item_code} value={fg.item_code}>
{fg.item_code} {fg.item_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 2행: 가이드레일, 전원, 제어기 */}
<div className="grid grid-cols-3 gap-3">
<div>
<label className="text-xs text-gray-600 flex items-center gap-1">
🔧
</label>
<Select
value={location.guideRailType}
onValueChange={(value) => handleFieldChange("guideRailType", value)}
disabled={disabled}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{GUIDE_RAIL_TYPES.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="text-xs text-gray-600 flex items-center gap-1">
</label>
<Select
value={location.motorPower}
onValueChange={(value) => handleFieldChange("motorPower", value)}
disabled={disabled}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{MOTOR_POWERS.map((power) => (
<SelectItem key={power.value} value={power.value}>
{power.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="text-xs text-gray-600 flex items-center gap-1">
📦
</label>
<Select
value={location.controller}
onValueChange={(value) => handleFieldChange("controller", value)}
disabled={disabled}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CONTROLLERS.map((ctrl) => (
<SelectItem key={ctrl.value} value={ctrl.value}>
{ctrl.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 3행: 제작사이즈, 산출중량, 산출면적, 수량, 산출하기 */}
<div className="grid grid-cols-5 gap-3 text-sm pt-2 border-t border-gray-200">
<div>
<span className="text-xs text-gray-500"></span>
<p className="font-semibold">
{location.bomResult?.variables?.W1 || location.manufactureWidth || "-"}
X
{location.bomResult?.variables?.H1 || location.manufactureHeight || "-"}
</p>
</div>
<div>
<span className="text-xs text-gray-500"></span>
<p className="font-semibold">
{location.bomResult?.variables?.K
? `${Number(location.bomResult.variables.K).toFixed(2)} kg`
: "-"}
</p>
</div>
<div>
<span className="text-xs text-gray-500"></span>
<p className="font-semibold">
{location.bomResult?.variables?.M
? `${Number(location.bomResult.variables.M).toFixed(2)}`
: "-"}
</p>
</div>
<div>
<span className="text-xs text-gray-500"> (QTY)</span>
<QuantityInput
value={location.quantity}
onChange={(newQty) => {
if (!location || disabled) 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}
/>
</div>
<div className="flex items-end">
<Button
onClick={() => onCalculateLocation?.(location.id)}
disabled={disabled || isCalculating}
className="w-full h-8 bg-orange-500 hover:bg-orange-600 text-white"
>
{isCalculating ? (
<>
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
...
</>
) : (
<>
<Calculator className="h-4 w-4 mr-1" />
</>
)}
</Button>
</div>
</div>
</div>
</div>
{/* ②-2 품목 상세 영역 */}
<div className="flex-1 overflow-hidden flex flex-col">
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col h-full">
{/* 탭 목록 */}
<div className="border-b bg-white overflow-x-auto">
{categoriesLoading ? (
<div className="flex items-center justify-center py-2 px-4 text-gray-500">
<Loader2 className="h-4 w-4 animate-spin mr-2" />
<span className="text-sm"> ...</span>
</div>
) : (
<TabsList className="w-max min-w-full justify-start rounded-none bg-transparent h-auto p-0">
{detailTabs.map((tab) => (
<TabsTrigger
key={tab.value}
value={tab.value}
className="rounded-none border-b-2 border-transparent data-[state=active]:border-orange-500 data-[state=active]:bg-orange-50 data-[state=active]:text-orange-700 px-4 py-2 text-sm whitespace-nowrap"
>
{tab.label}
</TabsTrigger>
))}
</TabsList>
)}
</div>
{/* 탭 콘텐츠 */}
{detailTabs.map((tab) => {
const items = bomItemsByTab[tab.value] || [];
const isBendingTab = tab.parentCode === "BENDING";
return (
<TabsContent key={tab.value} value={tab.value} className="flex-1 overflow-auto m-0 p-0">
<div className="bg-amber-50 border border-amber-200 rounded-lg m-3">
<Table>
<TableHeader>
<TableRow className="bg-amber-100/50">
<TableHead className="font-semibold"></TableHead>
<TableHead className="text-center font-semibold"></TableHead>
{isBendingTab && (
<TableHead className="text-center font-semibold w-24"></TableHead>
)}
<TableHead className="text-center font-semibold w-20"></TableHead>
<TableHead className="text-center font-semibold w-12"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.length === 0 ? (
<TableRow>
<TableCell colSpan={isBendingTab ? 5 : 4} className="text-center text-gray-400 py-6">
</TableCell>
</TableRow>
) : (
items.map((item: any, index: number) => (
<TableRow key={item.id || `${tab.value}-${index}`} className="bg-white">
<TableCell className="font-medium">{item.item_name}</TableCell>
<TableCell className="text-center text-gray-600">{item.spec || item.specification || "-"}</TableCell>
{isBendingTab && (
<TableCell className="text-center">
<Select defaultValue={item.delivery_length || "4000"} disabled={disabled}>
<SelectTrigger className="w-20 h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DELIVERY_LENGTH_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
)}
<TableCell className="text-center">
<QuantityInput
value={item.quantity}
onChange={(newQty) => {
if (!location || disabled) return;
const existingBomResult = location.bomResult;
if (!existingBomResult) return;
// 해당 아이템 찾아서 수량 및 금액 업데이트
const updatedItems = (existingBomResult.items || []).map((bomItem: any, i: number) => {
if (bomItemsByTab[tab.value]?.[index] === bomItem) {
const newTotalPrice = (bomItem.unit_price || 0) * newQty;
return {
...bomItem,
quantity: newQty,
total_price: newTotalPrice,
};
}
return bomItem;
});
// grand_total 재계산
const newGrandTotal = updatedItems.reduce(
(sum: number, item: any) => sum + (item.total_price || 0),
0
);
// location 업데이트 (unitPrice, totalPrice 포함)
onUpdateLocation(location.id, {
unitPrice: newGrandTotal,
totalPrice: newGrandTotal * location.quantity,
bomResult: {
...existingBomResult,
items: updatedItems,
grand_total: newGrandTotal,
},
});
}}
className="w-14 h-7 text-center text-xs"
min={1}
disabled={disabled}
/>
</TableCell>
<TableCell className="text-center">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-gray-400 hover:text-red-500 hover:bg-red-50"
disabled={disabled}
onClick={() => {
if (!location) return;
// 품목 삭제 로직
const existingBomResult = location.bomResult;
if (!existingBomResult) return;
const updatedItems = (existingBomResult.items || []).filter(
(_: any, i: number) => !(bomItemsByTab[tab.value]?.[index] === _)
);
onUpdateLocation(location.id, {
bomResult: {
...existingBomResult,
items: updatedItems,
},
});
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
{/* 품목 추가 + 저장 버튼 */}
<div className="p-3 flex items-center justify-between border-t border-amber-200">
<Button
variant="outline"
size="sm"
className="bg-blue-100 border-blue-300 text-blue-700 hover:bg-blue-200"
onClick={() => setItemSearchOpen(true)}
disabled={disabled}
>
<Plus className="h-4 w-4 mr-1" />
</Button>
<Button
size="sm"
className="bg-blue-600 hover:bg-blue-700 text-white"
onClick={onSaveItems}
disabled={disabled}
>
<Save className="h-4 w-4 mr-1" />
</Button>
</div>
</div>
</TabsContent>
);
})}
</Tabs>
</div>
{/* 품목 검색 모달 */}
<ItemSearchModal
open={itemSearchOpen}
onOpenChange={setItemSearchOpen}
onSelectItem={async (item) => {
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,
});
console.log(`[품목 추가] ${item.code} - ${item.name}${categoryLabel} (${categoryCode}), 단가: ${unitPrice}`);
}}
tabLabel={detailTabs.find((t) => t.value === activeTab)?.label}
/>
</div>
);
}