diff --git a/apps/admin/src/components/ProductBomImportModal.tsx b/apps/admin/src/components/ProductBomImportModal.tsx new file mode 100644 index 0000000..20f2f60 --- /dev/null +++ b/apps/admin/src/components/ProductBomImportModal.tsx @@ -0,0 +1,390 @@ +import { useState, useMemo } from 'react'; +import { + X, + FileJson, + Upload, + CheckCircle2, + AlertCircle, + Layers, + Sparkles +} from 'lucide-react'; + +interface ProductBomImportModalProps { + isOpen: boolean; + onClose: () => void; + variants: any[]; + rawMaterials: any[]; + onApply: (updatedVariants: any[]) => void; +} + +export default function ProductBomImportModal({ + isOpen, + onClose, + variants, + rawMaterials, + onApply, +}: ProductBomImportModalProps) { + const [jsonInput, setJsonInput] = useState(''); + + // Real-time Parser & Matcher + const parsedResult = useMemo(() => { + if (!jsonInput.trim()) return null; + + try { + const parsed = JSON.parse(jsonInput); + if (!Array.isArray(parsed)) { + return { + valid: false, + error: 'ข้อมูลต้องเป็น JSON Array [ { ... }, { ... } ]', + matchedVariants: [], + unmatchedCount: 0, + }; + } + + if (parsed.length === 0) { + return { + valid: false, + error: 'JSON Array ว่างเปล่า ไม่มีข้อมูลสูตร', + matchedVariants: [], + unmatchedCount: 0, + }; + } + + const matched: any[] = []; + let unmatchedCount = 0; + + for (const entry of parsed) { + // 1. Match Variant by ID or SKU or Option + const variant = variants.find((v) => { + if (entry.variantId && Number(v.id) === Number(entry.variantId)) return true; + if (entry.sku && v.sku && String(v.sku).trim().toLowerCase() === String(entry.sku).trim().toLowerCase()) return true; + if (entry.option1 && v.option1 && String(v.option1).trim().toLowerCase() === String(entry.option1).trim().toLowerCase()) return true; + return false; + }); + + if (!variant) { + unmatchedCount++; + continue; + } + + // 2. Match BOM items to RawMaterials + const rawItems = Array.isArray(entry.bomItems) ? entry.bomItems : []; + const resolvedBomItems: any[] = []; + let totalCost = 0; + + for (const b of rawItems) { + const qty = Number(b.quantity) || 0; + if (qty <= 0) continue; + + // Find raw material by ID or Name + const rm = rawMaterials.find((m) => { + if (b.rawMaterialId && Number(m.id) === Number(b.rawMaterialId)) return true; + if (b.name && String(m.name).trim().toLowerCase() === String(b.name).trim().toLowerCase()) return true; + return false; + }); + + if (rm) { + const lineCost = (Number(rm.pricePerUnit) || 0) * qty; + totalCost += lineCost; + resolvedBomItems.push({ + rawMaterialId: rm.id, + name: rm.name, + unit: rm.unit, + quantity: qty, + pricePerUnit: rm.pricePerUnit, + lineCost, + }); + } else { + // Unresolved material ID or name + resolvedBomItems.push({ + rawMaterialId: Number(b.rawMaterialId) || 0, + name: b.name || `Material #${b.rawMaterialId}`, + unit: 'unit', + quantity: qty, + pricePerUnit: 0, + lineCost: 0, + unknown: true, + }); + } + } + + const price = Number(variant.price) || 0; + const profit = price - totalCost; + const margin = price > 0 ? (profit / price) * 100 : 0; + + matched.push({ + targetVariant: variant, + resolvedBomItems, + totalCost, + price, + profit, + margin, + }); + } + + if (matched.length === 0) { + return { + valid: false, + error: 'ไม่พบ Variant ในระบบที่ตรงกับ ID หรือ SKU ใน JSON ที่วางมา', + matchedVariants: [], + unmatchedCount, + }; + } + + return { + valid: true, + error: null, + matchedVariants: matched, + unmatchedCount, + }; + } catch (err: any) { + return { + valid: false, + error: `รูปแบบ JSON ไม่ถูกต้อง: ${err.message}`, + matchedVariants: [], + unmatchedCount: 0, + }; + } + }, [jsonInput, variants, rawMaterials]); + + if (!isOpen) return null; + + const handleApply = () => { + if (!parsedResult || !parsedResult.valid) return; + + // Deep copy current variants + const newVariants = JSON.parse(JSON.stringify(variants)); + + for (const match of parsedResult.matchedVariants) { + const idx = newVariants.findIndex((v: any) => v.id === match.targetVariant.id); + if (idx !== -1) { + // Map to exact BOM structure expected by ProductBomPage + newVariants[idx].bomItems = match.resolvedBomItems.map((b: any) => ({ + rawMaterialId: b.rawMaterialId, + quantity: b.quantity, + })); + } + } + + onApply(newVariants); + onClose(); + }; + + return ( +
+ {/* Backdrop */} +
+ + {/* Modal Container */} +
+ {/* Header */} +
+
+
+ +
+
+
+

Import BOM from LLM JSON

+ + Auto-Match & Calculate + +
+

+ วาง JSON ที่ได้รับจาก LLM ระบบจะจับคู่ Variant และคำนวณต้นทุนให้ก่อนกรอกลงตาราง +

+
+
+ +
+ + {/* Content Body */} +
+ {/* Textarea Input */} +
+
+ + {jsonInput && ( + + )} +
+ +