feat(bom): add BOM JSON import modal with auto-matching and live cost preview
This commit is contained in:
1 parent
d8f6d8139f
commit
62cbabae8a
2 files changed
+418
-1
No files matched your search
@@ -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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-night-950/80 backdrop-blur-sm transition-opacity"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal Container */}
|
||||
<div className="relative w-full max-w-4xl bg-night-900 border border-white/10 rounded-3xl shadow-2xl flex flex-col overflow-hidden max-h-[92vh] animate-zoom-in">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-5 border-b border-white/5 bg-night-950/40">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-2xl bg-cyan-500/10 border border-cyan-500/20 flex items-center justify-center text-cyan-400">
|
||||
<FileJson className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-xl font-bold text-white">Import BOM from LLM JSON</h2>
|
||||
<span className="text-xs px-2.5 py-0.5 rounded-full bg-cyan-500/10 text-cyan-300 border border-cyan-500/20 font-bold">
|
||||
Auto-Match & Calculate
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400">
|
||||
วาง JSON ที่ได้รับจาก LLM ระบบจะจับคู่ Variant และคำนวณต้นทุนให้ก่อนกรอกลงตาราง
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-xl text-slate-400 hover:text-white hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content Body */}
|
||||
<div className="p-6 overflow-y-auto space-y-6 flex-1">
|
||||
{/* Textarea Input */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<label className="font-bold text-slate-300 flex items-center gap-1.5">
|
||||
<Sparkles className="w-3.5 h-3.5 text-cyan-400" />
|
||||
วางข้อความ JSON ที่ได้จาก AI (Ctrl+V):
|
||||
</label>
|
||||
{jsonInput && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setJsonInput('')}
|
||||
className="text-rose-400 hover:text-rose-300 font-medium"
|
||||
>
|
||||
ล้างข้อความ
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
value={jsonInput}
|
||||
onChange={(e) => setJsonInput(e.target.value)}
|
||||
rows={8}
|
||||
placeholder={`[\n {\n "variantId": 3206,\n "sku": "4-1-30ML",\n "bomItems": [\n { "rawMaterialId": 4, "quantity": 2.4 },\n { "rawMaterialId": 91, "quantity": 27.6 }\n ]\n }\n]`}
|
||||
className="w-full font-mono text-xs bg-night-950/80 border border-white/10 rounded-2xl p-4 text-slate-200 placeholder-slate-600 focus:outline-none focus:border-cyan-500/50 transition-all resize-y leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Validation Status Banner */}
|
||||
{parsedResult && (
|
||||
<div
|
||||
className={`p-4 rounded-2xl border text-xs flex items-center justify-between ${
|
||||
parsedResult.valid
|
||||
? 'bg-emerald-950/30 border-emerald-500/30 text-emerald-300'
|
||||
: 'bg-rose-950/30 border-rose-500/30 text-rose-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
{parsedResult.valid ? (
|
||||
<CheckCircle2 className="w-5 h-5 shrink-0 text-emerald-400" />
|
||||
) : (
|
||||
<AlertCircle className="w-5 h-5 shrink-0 text-rose-400" />
|
||||
)}
|
||||
<span>
|
||||
{parsedResult.valid ? (
|
||||
<>
|
||||
ตรวจพบสูตร BOM ที่ตรงกับสินค้า <strong>{parsedResult.matchedVariants.length}</strong> ตัวเลือก
|
||||
{parsedResult.unmatchedCount > 0 && ` (ข้ามที่ไม่ตรง ${parsedResult.unmatchedCount} รายการ)`}
|
||||
</>
|
||||
) : (
|
||||
parsedResult.error
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{parsedResult.valid && (
|
||||
<span className="text-[11px] px-2 py-0.5 rounded-md bg-emerald-500/20 text-emerald-300 border border-emerald-500/30 font-medium">
|
||||
พร้อมนำเข้า
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Live Preview Cards */}
|
||||
{parsedResult?.valid && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||
<span className="font-bold text-white flex items-center gap-1.5">
|
||||
<Layers className="w-4 h-4 text-cyan-400" />
|
||||
พรีวิวการจัดสูตรและคำนวณต้นทุน (BOM Preview):
|
||||
</span>
|
||||
<span>จำนวน {parsedResult.matchedVariants.length} Variants</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 max-h-[280px] overflow-y-auto pr-1">
|
||||
{parsedResult.matchedVariants.map((item: any, idx: number) => {
|
||||
const v = item.targetVariant;
|
||||
const label = [v.option1, v.option2, v.option3].filter(Boolean).join(' / ') || 'Default';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={v.id || idx}
|
||||
className="p-4 rounded-2xl bg-white/[0.02] border border-white/5 hover:border-white/10 transition-all space-y-3"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 border-b border-white/5 pb-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-6 h-6 rounded-lg bg-cyan-500/10 text-cyan-400 text-xs font-mono font-bold flex items-center justify-center">
|
||||
#{v.id}
|
||||
</span>
|
||||
<span className="text-sm font-bold text-white">{label}</span>
|
||||
{v.sku && (
|
||||
<span className="text-[11px] font-mono text-slate-500 bg-white/5 px-2 py-0.5 rounded-md">
|
||||
SKU: {v.sku}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-xs font-mono">
|
||||
<span className="text-slate-400">
|
||||
ราคาขาย: <strong className="text-white">฿{item.price.toLocaleString()}</strong>
|
||||
</span>
|
||||
<span className="text-slate-400">
|
||||
ต้นทุน BOM: <strong className="text-amber-400">฿{item.totalCost.toFixed(2)}</strong>
|
||||
</span>
|
||||
<span className="text-slate-400">
|
||||
กำไร:{' '}
|
||||
<strong className={item.profit >= 0 ? 'text-emerald-400' : 'text-rose-400'}>
|
||||
฿{item.profit.toFixed(2)} ({item.margin.toFixed(1)}%)
|
||||
</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ingredients Breakdown */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-2">
|
||||
{item.resolvedBomItems.map((b: any, bIdx: number) => (
|
||||
<div
|
||||
key={bIdx}
|
||||
className={`p-2.5 rounded-xl border text-xs flex items-center justify-between ${
|
||||
b.unknown
|
||||
? 'bg-amber-950/20 border-amber-500/30 text-amber-300'
|
||||
: 'bg-night-950/60 border-white/5 text-slate-300'
|
||||
}`}
|
||||
>
|
||||
<div className="space-y-0.5 min-w-0 pr-2">
|
||||
<p className="font-medium truncate text-white text-[11px]">
|
||||
{b.name}
|
||||
</p>
|
||||
<p className="text-[10px] text-slate-500">
|
||||
ID: #{b.rawMaterialId}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<span className="font-mono font-bold text-cyan-400">
|
||||
{b.quantity} {b.unit}
|
||||
</span>
|
||||
<p className="text-[10px] text-slate-500 font-mono">
|
||||
฿{b.lineCost.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-white/5 bg-night-950/40 flex items-center justify-between">
|
||||
<p className="text-xs text-slate-500">
|
||||
เมื่อกด Apply ข้อมูลจะถูกกรอกลงตารางบนหน้าเว็บทันทีเพื่อให้คุณตรวจสอบก่อนกดบันทึก
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-5 py-2.5 rounded-xl text-sm font-medium text-slate-400 hover:text-white hover:bg-white/5 transition-all"
|
||||
>
|
||||
ยกเลิก
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleApply}
|
||||
disabled={!parsedResult?.valid}
|
||||
className="btn-primary text-sm px-6 py-2.5 flex items-center gap-2 shadow-lg shadow-cyan-500/20 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
<span>กรอกลงตาราง ({parsedResult?.matchedVariants?.length || 0} รายการ)</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,10 +12,12 @@ import {
|
||||
Coins,
|
||||
Layers,
|
||||
Calculator,
|
||||
Bot
|
||||
Bot,
|
||||
FileJson
|
||||
} from 'lucide-react'
|
||||
import { API_BASE, type Product } from '@homaroma/types'
|
||||
import ProductBomPromptModal from '../components/ProductBomPromptModal'
|
||||
import ProductBomImportModal from '../components/ProductBomImportModal'
|
||||
|
||||
function fmtMoney(val: number | null | undefined): string {
|
||||
if (val == null || isNaN(val)) return '฿0.00'
|
||||
@@ -41,6 +43,13 @@ export default function ProductBomPage() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState(false)
|
||||
const [isPromptModalOpen, setIsPromptModalOpen] = useState(false)
|
||||
const [isImportModalOpen, setIsImportModalOpen] = useState(false)
|
||||
|
||||
const handleApplyBomJson = (updatedVariants: any[]) => {
|
||||
setVariants(updatedVariants)
|
||||
setSuccess(true)
|
||||
setTimeout(() => setSuccess(false), 3500)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
@@ -194,6 +203,15 @@ export default function ProductBomPage() {
|
||||
<Bot className="w-4 h-4 text-purple-400" />
|
||||
<span>Get Prompt</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsImportModalOpen(true)}
|
||||
className="px-4 py-2.5 rounded-xl bg-cyan-500/10 hover:bg-cyan-500/20 text-cyan-300 border border-cyan-500/30 text-sm font-bold flex items-center gap-2 transition-all shadow-sm"
|
||||
title="วาง JSON จาก LLM เพื่อกรอกสูตร BOM ลงในตารางอัตโนมัติ"
|
||||
>
|
||||
<FileJson className="w-4 h-4 text-cyan-400" />
|
||||
<span>Import BOM JSON</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate('/products')}
|
||||
className="px-5 py-2.5 rounded-xl border border-white/10 bg-white/5 hover:bg-white/10 text-sm font-bold text-slate-300 transition-all"
|
||||
@@ -561,6 +579,15 @@ export default function ProductBomPage() {
|
||||
variants={variants}
|
||||
rawMaterials={rawMaterials}
|
||||
/>
|
||||
|
||||
{/* LLM JSON Import Modal */}
|
||||
<ProductBomImportModal
|
||||
isOpen={isImportModalOpen}
|
||||
onClose={() => setIsImportModalOpen(false)}
|
||||
variants={variants}
|
||||
rawMaterials={rawMaterials}
|
||||
onApply={handleApplyBomJson}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user