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,
|
Coins,
|
||||||
Layers,
|
Layers,
|
||||||
Calculator,
|
Calculator,
|
||||||
Bot
|
Bot,
|
||||||
|
FileJson
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { API_BASE, type Product } from '@homaroma/types'
|
import { API_BASE, type Product } from '@homaroma/types'
|
||||||
import ProductBomPromptModal from '../components/ProductBomPromptModal'
|
import ProductBomPromptModal from '../components/ProductBomPromptModal'
|
||||||
|
import ProductBomImportModal from '../components/ProductBomImportModal'
|
||||||
|
|
||||||
function fmtMoney(val: number | null | undefined): string {
|
function fmtMoney(val: number | null | undefined): string {
|
||||||
if (val == null || isNaN(val)) return '฿0.00'
|
if (val == null || isNaN(val)) return '฿0.00'
|
||||||
@@ -41,6 +43,13 @@ export default function ProductBomPage() {
|
|||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [success, setSuccess] = useState(false)
|
const [success, setSuccess] = useState(false)
|
||||||
const [isPromptModalOpen, setIsPromptModalOpen] = 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(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
@@ -194,6 +203,15 @@ export default function ProductBomPage() {
|
|||||||
<Bot className="w-4 h-4 text-purple-400" />
|
<Bot className="w-4 h-4 text-purple-400" />
|
||||||
<span>Get Prompt</span>
|
<span>Get Prompt</span>
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={() => navigate('/products')}
|
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"
|
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}
|
variants={variants}
|
||||||
rawMaterials={rawMaterials}
|
rawMaterials={rawMaterials}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* LLM JSON Import Modal */}
|
||||||
|
<ProductBomImportModal
|
||||||
|
isOpen={isImportModalOpen}
|
||||||
|
onClose={() => setIsImportModalOpen(false)}
|
||||||
|
variants={variants}
|
||||||
|
rawMaterials={rawMaterials}
|
||||||
|
onApply={handleApplyBomJson}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user