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

721 lines
30 KiB
TypeScript
Raw Normal View History

/**
*
*
* - (, , , , , )
* - (, , )
* - : 본체(/), -, -, -, &,
* - ( )
*/
"use client";
import { useState, useMemo } from "react";
import { Package, Settings, Plus, Trash2 } from "lucide-react";
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 { LocationEditModal } from "./LocationEditModal";
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 아이템 (bomResult 없을 때 사용)
const EMPTY_BOM_ITEMS: Record<string, BomCalculationResultItem[]> = {
body: [],
"guide-rail": [],
case: [],
bottom: [],
motor: [],
accessory: [],
};
// =============================================================================
// 상수
// =============================================================================
// 가이드레일 설치 유형
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: "매립형-뒷박스포함" },
];
// 탭 정의 (6개)
const DETAIL_TABS = [
{ value: "body", label: "본체 (스크린/슬랫)" },
{ value: "guide-rail", label: "절곡품 - 가이드레일" },
{ value: "case", label: "절곡품 - 케이스" },
{ value: "bottom", label: "절곡품 - 하단마감재" },
{ value: "motor", label: "모터 & 제어기" },
{ value: "accessory", label: "부자재" },
];
// =============================================================================
// Props
// =============================================================================
interface LocationDetailPanelProps {
location: LocationItem | null;
onUpdateLocation: (locationId: string, updates: Partial<LocationItem>) => void;
finishedGoods: FinishedGoods[];
disabled?: boolean;
}
// =============================================================================
// 컴포넌트
// =============================================================================
export function LocationDetailPanel({
location,
onUpdateLocation,
finishedGoods,
disabled = false,
}: LocationDetailPanelProps) {
// ---------------------------------------------------------------------------
// 상태
// ---------------------------------------------------------------------------
const [activeTab, setActiveTab] = useState("body");
const [itemSearchOpen, setItemSearchOpen] = useState(false);
const [editModalOpen, setEditModalOpen] = useState(false);
// ---------------------------------------------------------------------------
// 계산된 값
// ---------------------------------------------------------------------------
// 제품 정보
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 EMPTY_BOM_ITEMS;
}
const items = location.bomResult.items;
const result: Record<string, typeof items> = {
body: [],
"guide-rail": [],
case: [],
bottom: [],
};
items.forEach((item) => {
// process_group_key (API 그룹 키) 또는 process_group (한글명) 사용
const processGroupKey = (item as { process_group_key?: string }).process_group_key?.toLowerCase() || "";
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);
} else if (processGroup.includes("케이스") || processGroup.includes("철재")) {
result.case.push(item);
} else if (processGroup.includes("하단") || processGroup.includes("마감") || processGroup.includes("전기")) {
result.bottom.push(item);
} else {
// 기타 항목은 본체에 포함
result.body.push(item);
}
});
return result;
}, [location?.bomResult?.items]);
// 탭별 소계
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">
{/* 헤더 */}
<div className="bg-white px-4 py-3 border-b">
<div className="flex items-center justify-between">
<h3 className="text-lg font-bold">
{location.floor} / {location.code}
</h3>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500">:</span>
<Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-200">
{location.productCode}
</Badge>
{location.bomResult && (
<Badge variant="default" className="bg-green-600">
</Badge>
)}
</div>
</div>
</div>
{/* 제품 정보 */}
<div className="bg-gray-50 px-4 py-3 border-b space-y-3">
{/* 오픈사이즈 */}
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600 w-20"></span>
<div className="flex items-center gap-2">
<NumberInput
value={location.openWidth}
onChange={(value) => handleFieldChange("openWidth", value ?? 0)}
disabled={disabled}
className="w-24 h-8 text-center font-bold"
/>
<span className="text-gray-400">×</span>
<NumberInput
value={location.openHeight}
onChange={(value) => handleFieldChange("openHeight", value ?? 0)}
disabled={disabled}
className="w-24 h-8 text-center font-bold"
/>
{!disabled && (
<Button
variant="secondary"
size="sm"
className="text-xs h-7"
onClick={() => setEditModalOpen(true)}
>
</Button>
)}
</div>
</div>
{/* 제작사이즈, 산출중량, 산출면적, 수량 */}
<div className="grid grid-cols-4 gap-4 text-sm">
<div>
<span className="text-gray-500"></span>
<p className="font-semibold">
{location.manufactureWidth || location.openWidth + 280} × {location.manufactureHeight || location.openHeight + 280}
</p>
</div>
<div>
<span className="text-gray-500"></span>
<p className="font-semibold">{location.weight?.toFixed(1) || "-"} <span className="text-xs text-gray-400">kg</span></p>
</div>
<div>
<span className="text-gray-500"></span>
<p className="font-semibold">{location.area?.toFixed(1) || "-"} <span className="text-xs text-gray-400">m²</span></p>
</div>
<div>
<span className="text-gray-500"></span>
<QuantityInput
value={location.quantity}
onChange={(value) => handleFieldChange("quantity", value ?? 1)}
disabled={disabled}
className="w-24 h-7 text-center font-semibold"
min={1}
/>
</div>
</div>
</div>
{/* 필수 설정 (읽기 전용) */}
<div className="bg-white px-4 py-3 border-b">
<h4 className="text-sm font-semibold text-gray-700 mb-2 flex items-center gap-1">
<Settings className="h-4 w-4" />
</h4>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="text-xs text-gray-500 flex items-center gap-1 mb-1">
🔧
</label>
<Badge variant="outline" className="bg-gray-50 text-gray-700 border-gray-300 px-3 py-1.5">
{GUIDE_RAIL_TYPES.find(t => t.value === location.guideRailType)?.label || location.guideRailType}
</Badge>
</div>
<div>
<label className="text-xs text-gray-500 flex items-center gap-1 mb-1">
</label>
<Badge variant="outline" className="bg-gray-50 text-gray-700 border-gray-300 px-3 py-1.5">
{MOTOR_POWERS.find(p => p.value === location.motorPower)?.label || location.motorPower}
</Badge>
</div>
<div>
<label className="text-xs text-gray-500 flex items-center gap-1 mb-1">
📦
</label>
<Badge variant="outline" className="bg-gray-50 text-gray-700 border-gray-300 px-3 py-1.5">
{CONTROLLERS.find(c => c.value === location.controller)?.label || location.controller}
</Badge>
</div>
</div>
</div>
{/* 탭 및 품목 테이블 */}
<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">
<TabsList className="w-max min-w-full justify-start rounded-none bg-transparent h-auto p-0">
{DETAIL_TABS.map((tab) => (
<TabsTrigger
key={tab.value}
value={tab.value}
className="rounded-none border-b-2 border-transparent data-[state=active]:border-blue-500 data-[state=active]:bg-blue-50 data-[state=active]:text-blue-700 px-4 py-2 text-sm whitespace-nowrap"
>
{tab.label}
</TabsTrigger>
))}
</TabsList>
</div>
{/* 본체 (스크린/슬랫) 탭 */}
<TabsContent value="body" className="flex-1 overflow-auto m-0 p-0">
<div className="bg-amber-50 border border-amber-200 rounded-lg m-4">
<Table>
<TableHeader>
<TableRow className="bg-amber-100/50">
<TableHead className="font-semibold"></TableHead>
<TableHead className="text-center font-semibold"></TableHead>
<TableHead className="text-center font-semibold w-24"></TableHead>
<TableHead className="text-center font-semibold w-20"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{bomItemsByTab.body.map((item: any, index: number) => (
<TableRow key={item.id || `body-${index}`} className="bg-white">
<TableCell className="font-medium">{item.item_name}</TableCell>
<TableCell className="text-center text-gray-600">{item.manufacture_size || "-"}</TableCell>
<TableCell className="text-center">
<QuantityInput
value={item.quantity}
onChange={() => {}}
className="w-16 h-8 text-center"
min={1}
disabled={disabled}
/>
</TableCell>
<TableCell className="text-center">
<Button variant="ghost" size="sm" className="text-red-500 hover:text-red-700 hover:bg-red-50 p-1" disabled={disabled}>
<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>
<span className="text-sm text-gray-500 flex items-center gap-1">
💡
</span>
</div>
</div>
</TabsContent>
{/* 절곡품 - 가이드레일, 케이스, 하단마감재 탭 */}
{["guide-rail", "case", "bottom"].map((tabValue) => (
<TabsContent key={tabValue} value={tabValue} className="flex-1 overflow-auto m-0 p-0">
<div className="bg-amber-50 border border-amber-200 rounded-lg m-4">
<Table>
<TableHeader>
<TableRow className="bg-amber-100/50">
<TableHead className="font-semibold"></TableHead>
<TableHead className="text-center font-semibold"></TableHead>
<TableHead className="text-center font-semibold"></TableHead>
<TableHead className="text-center font-semibold w-28"></TableHead>
<TableHead className="text-center font-semibold w-24"></TableHead>
<TableHead className="text-center font-semibold w-20"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{bomItemsByTab[tabValue]?.map((item: any, index: number) => (
<TableRow key={item.id || `${tabValue}-${index}`} className="bg-white">
<TableCell className="font-medium">{item.item_name}</TableCell>
<TableCell className="text-center text-gray-600">{item.material || "-"}</TableCell>
<TableCell className="text-center text-gray-600">{item.spec || "-"}</TableCell>
<TableCell className="text-center">
<Select defaultValue={item.delivery_length} disabled={disabled}>
<SelectTrigger className="w-24 h-8">
<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={() => {}}
className="w-16 h-8 text-center"
min={1}
disabled={disabled}
/>
</TableCell>
<TableCell className="text-center">
<Button variant="ghost" size="sm" className="text-red-500 hover:text-red-700 hover:bg-red-50 p-1" disabled={disabled}>
<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>
<span className="text-sm text-gray-500 flex items-center gap-1">
💡
</span>
</div>
</div>
</TabsContent>
))}
{/* 모터 & 제어기 탭 */}
<TabsContent value="motor" className="flex-1 overflow-auto m-0 p-0">
<div className="bg-amber-50 border border-amber-200 rounded-lg m-4">
<Table>
<TableHeader>
<TableRow className="bg-amber-100/50">
<TableHead className="font-semibold"></TableHead>
<TableHead className="text-center font-semibold"></TableHead>
<TableHead className="text-center font-semibold"></TableHead>
<TableHead className="text-center font-semibold w-24"></TableHead>
<TableHead className="text-center font-semibold w-20"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{bomItemsByTab.motor?.map((item: any, index: number) => (
<TableRow key={item.id || `motor-${index}`} className="bg-white">
<TableCell className="font-medium">{item.item_name}</TableCell>
<TableCell className="text-center text-gray-600">{item.type || "-"}</TableCell>
<TableCell className="text-center text-gray-600">{item.spec || "-"}</TableCell>
<TableCell className="text-center">
<QuantityInput
value={item.quantity}
onChange={() => {}}
className="w-16 h-8 text-center"
min={1}
disabled={disabled}
/>
</TableCell>
<TableCell className="text-center">
<Button variant="ghost" size="sm" className="text-red-500 hover:text-red-700 hover:bg-red-50 p-1" disabled={disabled}>
<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>
<span className="text-sm text-gray-500 flex items-center gap-1">
💡
</span>
</div>
</div>
</TabsContent>
{/* 부자재 탭 */}
<TabsContent value="accessory" className="flex-1 overflow-auto m-0 p-0">
<div className="bg-amber-50 border border-amber-200 rounded-lg m-4">
<Table>
<TableHeader>
<TableRow className="bg-amber-100/50">
<TableHead className="font-semibold"></TableHead>
<TableHead className="text-center font-semibold"></TableHead>
<TableHead className="text-center font-semibold w-28"></TableHead>
<TableHead className="text-center font-semibold w-24"></TableHead>
<TableHead className="text-center font-semibold w-20"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{bomItemsByTab.accessory?.map((item: any, index: number) => (
<TableRow key={item.id || `accessory-${index}`} className="bg-white">
<TableCell className="font-medium">{item.item_name}</TableCell>
<TableCell className="text-center text-gray-600">{item.spec || "-"}</TableCell>
<TableCell className="text-center">
<Select defaultValue={item.delivery_length} disabled={disabled}>
<SelectTrigger className="w-24 h-8">
<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={() => {}}
className="w-16 h-8 text-center"
min={1}
disabled={disabled}
/>
</TableCell>
<TableCell className="text-center">
<Button variant="ghost" size="sm" className="text-red-500 hover:text-red-700 hover:bg-red-50 p-1" disabled={disabled}>
<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>
<span className="text-sm text-gray-500 flex items-center gap-1">
💡
</span>
</div>
</div>
</TabsContent>
</Tabs>
</div>
{/* 금액 안내 */}
{!location.bomResult && (
<div className="bg-blue-50 px-4 py-2 border-t border-blue-200 text-center text-sm text-blue-700">
💡
</div>
)}
{/* 품목 검색 모달 */}
<ItemSearchModal
open={itemSearchOpen}
onOpenChange={setItemSearchOpen}
onSelectItem={(item) => {
if (!location) return;
// 탭 → process_group_key 매핑
const tabToProcessGroup: Record<string, string> = {
body: "screen",
"guide-rail": "bending",
case: "steel",
bottom: "electric",
motor: "motor",
accessory: "accessory",
};
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 } = {
item_code: item.code,
item_name: item.name,
specification: item.specification || "",
unit: "EA",
quantity: 1,
unit_price: 0,
total_price: 0,
process_group: processGroupLabel,
process_group_key: processGroupKey,
is_manual: true, // 수동 추가 품목 표시
};
// 기존 bomResult 가져오기
const existingBomResult = location.bomResult || {
finished_goods: { code: location.productCode || "", name: location.productName || "" },
subtotals: {},
grouped_items: {},
grand_total: 0,
items: [],
};
// 기존 items에 새 아이템 추가
const existingItems = existingBomResult.items || [];
const updatedItems = [...existingItems, newItem];
// subtotals 업데이트 (해당 카테고리의 count, subtotal 증가)
const existingSubtotals = existingBomResult.subtotals || {};
const categorySubtotal = existingSubtotals[processGroupKey] || {
name: processGroupLabel,
count: 0,
subtotal: 0,
};
const updatedSubtotals = {
...existingSubtotals,
[processGroupKey]: {
...categorySubtotal,
name: processGroupLabel,
count: (categorySubtotal.count || 0) + 1,
subtotal: (categorySubtotal.subtotal || 0) + (newItem.total_price || 0),
},
};
// grouped_items 업데이트 (해당 카테고리의 items 배열에 추가)
const existingGroupedItems = existingBomResult.grouped_items || {};
const categoryGroupedItems = existingGroupedItems[processGroupKey] || { items: [] };
const updatedGroupedItems = {
...existingGroupedItems,
[processGroupKey]: {
...categoryGroupedItems,
items: [...(categoryGroupedItems.items || []), newItem],
},
};
// grand_total 업데이트
const updatedGrandTotal = (existingBomResult.grand_total || 0) + (newItem.total_price || 0);
const updatedBomResult = {
...existingBomResult,
items: updatedItems,
subtotals: updatedSubtotals,
grouped_items: updatedGroupedItems,
grand_total: updatedGrandTotal,
};
// location 업데이트
onUpdateLocation(location.id, { bomResult: updatedBomResult });
console.log(`[품목 추가] ${item.code} - ${item.name}${processGroupLabel} (${processGroupKey})`);
}}
tabLabel={DETAIL_TABS.find((t) => t.value === activeTab)?.label}
/>
{/* 개소 정보 수정 모달 */}
<LocationEditModal
open={editModalOpen}
onOpenChange={setEditModalOpen}
location={location}
onSave={(locationId, updates) => {
onUpdateLocation(locationId, updates);
}}
/>
</div>
);
}