feat(raw-materials): add bulk JSON import with upsert and full-page edit routes

This commit is contained in:
Your Name committed 2026-10-18 10:19:35 +07:00
1 parent b13f37cf7e
commit c31a632c66
6 files changed
+1153 -171

No files matched your search

+3
View File
@@ -14,6 +14,7 @@ import PageBuilderPage from './pages/PageBuilderPage'
import PageReorderPage from './pages/PageReorderPage' import PageReorderPage from './pages/PageReorderPage'
import CmsMockupPage from './pages/CmsMockupPage' import CmsMockupPage from './pages/CmsMockupPage'
import RawMaterialsPage from './pages/RawMaterialsPage' import RawMaterialsPage from './pages/RawMaterialsPage'
import RawMaterialDetailPage from './pages/RawMaterialDetailPage'
import LoginPage from './pages/LoginPage' import LoginPage from './pages/LoginPage'
import InstallSeedPage from './pages/InstallSeedPage' import InstallSeedPage from './pages/InstallSeedPage'
import ProtectedRoute from './components/ProtectedRoute' import ProtectedRoute from './components/ProtectedRoute'
@@ -123,6 +124,8 @@ function App() {
<Route path="/promotions" element={<PromotionsPage />} /> <Route path="/promotions" element={<PromotionsPage />} />
<Route path="/coupons" element={<CouponsPage />} /> <Route path="/coupons" element={<CouponsPage />} />
<Route path="/raw-materials" element={<RawMaterialsPage />} /> <Route path="/raw-materials" element={<RawMaterialsPage />} />
<Route path="/raw-materials/new" element={<RawMaterialDetailPage />} />
<Route path="/raw-materials/:id" element={<RawMaterialDetailPage />} />
<Route path="/settings" element={<StoreSettingsPage />} /> <Route path="/settings" element={<StoreSettingsPage />} />
<Route path="/settings/brand-intelligence" element={<BrandIntelligencePage />} /> <Route path="/settings/brand-intelligence" element={<BrandIntelligencePage />} />
<Route path="/brand-intelligence" element={<BrandIntelligencePage />} /> <Route path="/brand-intelligence" element={<BrandIntelligencePage />} />
@@ -0,0 +1,419 @@
import { useState, useMemo, useRef, type ChangeEvent } from 'react';
import {
X,
Copy,
Check,
Download,
Upload,
FileJson,
AlertCircle,
CheckCircle2,
Loader2,
Sparkles,
} from 'lucide-react';
import { API_BASE, type RawMaterial } from '@homaroma/types';
interface RawMaterialBulkJsonModalProps {
isOpen: boolean;
onClose: () => void;
materials: RawMaterial[];
onSuccess: () => void;
}
const SAMPLE_TEMPLATE = [
{
name: "ขวดแก้วสีชา 100ml (Amber Glass Bottle)",
unit: "piece",
pricePerUnit: 18.50
},
{
name: "ก้านไม้ไฟเบอร์สีดำ 6 ก้าน (Black Fiber Reeds)",
unit: "pack",
pricePerUnit: 7.00
},
{
name: "น้ำมันหอมระเหย ลาเวนเดอร์บริสุทธิ์ (Pure Lavender Oil)",
unit: "kg",
pricePerUnit: 2400.00
},
{
name: "เบสกระจายความหอม MMB (Diffuser Base)",
unit: "liter",
pricePerUnit: 350.00
}
];
export default function RawMaterialBulkJsonModal({
isOpen,
onClose,
materials,
onSuccess,
}: RawMaterialBulkJsonModalProps) {
const [activeTab, setActiveTab] = useState<'import' | 'export'>('import');
const [copiedTemplate, setCopiedTemplate] = useState(false);
const [copiedExport, setCopiedExport] = useState(false);
const [importJsonText, setImportJsonText] = useState('');
const [submitting, setSubmitting] = useState(false);
const [resultMessage, setResultMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
// Parse & validate input text in real time
const parsedPreview = useMemo(() => {
if (!importJsonText.trim()) return null;
try {
const parsed = JSON.parse(importJsonText);
if (!Array.isArray(parsed)) {
return { valid: false, error: 'ข้อมูลต้องเป็น JSON Array [ { ... }, { ... } ]', count: 0, items: [] };
}
if (parsed.length === 0) {
return { valid: false, error: 'Array ว่างเปล่า ไม่มีรายการวัตถุดิบ', count: 0, items: [] };
}
const invalidItem = parsed.find((item: any) => !item || typeof item !== 'object' || !item.name);
if (invalidItem) {
return { valid: false, error: 'พบรายการที่ไม่มีฟิลด์ "name" หรือข้อมูลไม่สมบูรณ์', count: 0, items: [] };
}
return { valid: true, error: null, count: parsed.length, items: parsed };
} catch (err: any) {
return { valid: false, error: `รูปแบบ JSON ไม่ถูกต้อง: ${err.message}`, count: 0, items: [] };
}
}, [importJsonText]);
// Export JSON string
const exportJsonString = useMemo(() => {
const cleanList = materials.map((m) => ({
name: m.name,
unit: m.unit,
pricePerUnit: m.pricePerUnit,
}));
return JSON.stringify(cleanList, null, 2);
}, [materials]);
if (!isOpen) return null;
const handleCopyTemplate = async () => {
try {
await navigator.clipboard.writeText(JSON.stringify(SAMPLE_TEMPLATE, null, 2));
setCopiedTemplate(true);
setTimeout(() => setCopiedTemplate(false), 2000);
} catch (e) {
console.error(e);
}
};
const handleCopyExport = async () => {
try {
await navigator.clipboard.writeText(exportJsonString);
setCopiedExport(true);
setTimeout(() => setCopiedExport(false), 2000);
} catch (e) {
console.error(e);
}
};
const handleDownloadExport = () => {
const blob = new Blob([exportJsonString], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `raw-materials-${new Date().toISOString().split('T')[0]}.json`;
a.click();
URL.revokeObjectURL(url);
};
const handleFileUpload = (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
const content = event.target?.result as string;
setImportJsonText(content);
setResultMessage(null);
};
reader.readAsText(file);
e.target.value = '';
};
const handleImportSubmit = async () => {
if (!parsedPreview || !parsedPreview.valid) return;
setSubmitting(true);
setResultMessage(null);
try {
const res = await fetch(`${API_BASE}/raw-materials/bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(parsedPreview.items),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.message || 'นำเข้าข้อมูลไม่สำเร็จ');
}
setResultMessage({
type: 'success',
text: `นำเข้าและปรับปรุงข้อมูล (Upsert) สำเร็จ ${data.count || parsedPreview.count} รายการ`,
});
setImportJsonText('');
setTimeout(() => {
onSuccess();
onClose();
}, 1200);
} catch (err: any) {
setResultMessage({
type: 'error',
text: err.message || 'เกิดข้อผิดพลาดในการเชื่อมต่อเซิร์ฟเวอร์',
});
} finally {
setSubmitting(false);
}
};
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={() => !submitting && onClose()}
/>
{/* Modal Container */}
<div className="relative w-full max-w-3xl bg-night-900 border border-white/10 rounded-3xl shadow-2xl flex flex-col overflow-hidden max-h-[90vh] 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>
<h2 className="text-xl font-bold text-white flex items-center gap-2">
Raw Materials JSON Tool
<span className="text-xs px-2 py-0.5 rounded-full bg-cyan-500/10 text-cyan-300 border border-cyan-500/20">
Bulk Upsert
</span>
</h2>
<p className="text-xs text-slate-400">
นำเข้าและส่งออกข้อมูลวัตถุดิบ รองรับการอัปเดตข้อมูลเดิมอัตโนมัติเมื่อชื่อซ้ำ
</p>
</div>
</div>
<button
onClick={onClose}
disabled={submitting}
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>
{/* Navigation Tabs */}
<div className="flex border-b border-white/5 px-6 pt-2 bg-night-950/20">
<button
onClick={() => { setActiveTab('import'); setResultMessage(null); }}
className={`flex items-center gap-2 px-4 py-3 text-sm font-semibold border-b-2 transition-all ${
activeTab === 'import'
? 'border-cyan-500 text-cyan-400'
: 'border-transparent text-slate-400 hover:text-white'
}`}
>
<Upload className="w-4 h-4" />
<span>นำเข้า JSON (Bulk Import)</span>
</button>
<button
onClick={() => { setActiveTab('export'); setResultMessage(null); }}
className={`flex items-center gap-2 px-4 py-3 text-sm font-semibold border-b-2 transition-all ${
activeTab === 'export'
? 'border-cyan-500 text-cyan-400'
: 'border-transparent text-slate-400 hover:text-white'
}`}
>
<Download className="w-4 h-4" />
<span>ส่งออก JSON (Export {materials.length} รายการ)</span>
</button>
</div>
{/* Content Body */}
<div className="p-6 overflow-y-auto space-y-6 flex-1">
{activeTab === 'import' ? (
<div className="space-y-5">
{/* Guidance Notice */}
<div className="p-4 rounded-2xl bg-cyan-950/20 border border-cyan-500/20 text-xs text-cyan-200/90 flex items-start justify-between gap-4">
<div className="space-y-1">
<div className="flex items-center gap-1.5 font-bold text-cyan-300">
<Sparkles className="w-3.5 h-3.5" />
กลไกอัจฉริยะ (Upsert Automation)
</div>
<p className="text-slate-300 leading-relaxed">
วาง JSON Array รายการวัตถุดิบที่ต้องการ หากพบ <strong>name</strong> ที่มีอยู่แล้วในระบบ
ระบบจะ <strong>Update</strong> หน่วยนับและราคาต้นทุนให้ทันที หากยังไม่มีจะสร้างรายการใหม่
</p>
</div>
<button
type="button"
onClick={handleCopyTemplate}
className="shrink-0 px-3 py-1.5 rounded-lg bg-cyan-500/10 hover:bg-cyan-500/20 text-cyan-300 border border-cyan-500/30 font-medium flex items-center gap-1.5 transition-all text-xs"
>
{copiedTemplate ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
<span>{copiedTemplate ? 'คัดลอกแล้ว' : 'คัดลอก Template'}</span>
</button>
</div>
{/* JSON Input Area */}
<div className="space-y-2">
<div className="flex items-center justify-between text-xs">
<label className="font-bold text-slate-300">วาง JSON Payload:</label>
<div className="flex items-center gap-2">
<input
type="file"
ref={fileInputRef}
onChange={handleFileUpload}
accept=".json"
className="hidden"
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="text-cyan-400 hover:text-cyan-300 flex items-center gap-1 font-medium transition-colors"
>
<Upload className="w-3.5 h-3.5" />
<span>อัปโหลดไฟล์ .json</span>
</button>
{importJsonText && (
<button
type="button"
onClick={() => { setImportJsonText(''); setResultMessage(null); }}
className="text-rose-400 hover:text-rose-300 ml-2 font-medium"
>
ล้างข้อมูล
</button>
)}
</div>
</div>
<textarea
value={importJsonText}
onChange={(e) => {
setImportJsonText(e.target.value);
setResultMessage(null);
}}
rows={9}
placeholder={`[\n {\n "name": "ขวดแก้ว 100ml",\n "unit": "piece",\n "pricePerUnit": 18.5\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"
/>
</div>
{/* Live Preview / Validation Status */}
{parsedPreview && (
<div
className={`p-3.5 rounded-xl border text-xs flex items-center gap-2.5 ${
parsedPreview.valid
? 'bg-emerald-950/30 border-emerald-500/30 text-emerald-300'
: 'bg-rose-950/30 border-rose-500/30 text-rose-300'
}`}
>
{parsedPreview.valid ? (
<>
<CheckCircle2 className="w-4 h-4 shrink-0" />
<span>
JSON ถูกต้อง — ตรวจพบวัตถุดิบทั้งหมด <strong>{parsedPreview.count}</strong> รายการ พร้อมนำเข้าสู่ระบบ
</span>
</>
) : (
<>
<AlertCircle className="w-4 h-4 shrink-0" />
<span>{parsedPreview.error}</span>
</>
)}
</div>
)}
{/* Server Result Message */}
{resultMessage && (
<div
className={`p-3.5 rounded-xl border text-xs flex items-center gap-2.5 ${
resultMessage.type === 'success'
? 'bg-emerald-950/40 border-emerald-500/40 text-emerald-300'
: 'bg-rose-950/40 border-rose-500/40 text-rose-300'
}`}
>
{resultMessage.type === 'success' ? (
<CheckCircle2 className="w-4 h-4 shrink-0" />
) : (
<AlertCircle className="w-4 h-4 shrink-0" />
)}
<span>{resultMessage.text}</span>
</div>
)}
</div>
) : (
<div className="space-y-4">
<div className="flex items-center justify-between text-xs text-slate-400">
<span>วัตถุดิบทั้งหมดในระบบ ({materials.length} รายการ):</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleCopyExport}
className="px-3 py-1.5 rounded-lg bg-white/5 hover:bg-white/10 text-slate-200 border border-white/10 font-medium flex items-center gap-1.5 transition-all"
>
{copiedExport ? <Check className="w-3.5 h-3.5 text-emerald-400" /> : <Copy className="w-3.5 h-3.5" />}
<span>{copiedExport ? 'คัดลอกแล้ว' : 'คัดลอก JSON'}</span>
</button>
<button
type="button"
onClick={handleDownloadExport}
className="btn-primary text-xs px-3 py-1.5 flex items-center gap-1.5"
>
<Download className="w-3.5 h-3.5" />
<span>ดาวน์โหลด .json</span>
</button>
</div>
</div>
<textarea
readOnly
value={exportJsonString}
rows={12}
className="w-full font-mono text-xs bg-night-950/80 border border-white/10 rounded-2xl p-4 text-slate-300 focus:outline-none resize-y"
/>
</div>
)}
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-white/5 bg-night-950/40 flex items-center justify-end gap-3">
<button
type="button"
onClick={onClose}
disabled={submitting}
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>
{activeTab === 'import' && (
<button
type="button"
onClick={handleImportSubmit}
disabled={submitting || !parsedPreview?.valid}
className="btn-primary text-sm px-6 py-2.5 flex items-center gap-2 disabled:opacity-40 disabled:cursor-not-allowed shadow-lg shadow-cyan-500/10"
>
{submitting ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
<span>กำลังบันทึกข้อมูล...</span>
</>
) : (
<>
<Upload className="w-4 h-4" />
<span>ยืนยันการนำเข้า ({parsedPreview?.count || 0} รายการ)</span>
</>
)}
</button>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,431 @@
import { useState, useEffect, type FormEvent } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import {
ArrowLeft,
Save,
Trash2,
Loader2,
PackageOpen,
Boxes,
ExternalLink,
Layers,
Sparkles,
CheckCircle2,
AlertCircle
} from 'lucide-react';
import { API_BASE } from '@homaroma/types';
const COMMON_UNITS = [
{ label: 'ชิ้น (piece)', value: 'piece' },
{ label: 'กก. (kg)', value: 'kg' },
{ label: 'กรัม (g)', value: 'g' },
{ label: 'ลิตร (liter)', value: 'liter' },
{ label: 'มล. (ml)', value: 'ml' },
{ label: 'แพ็ค (pack)', value: 'pack' },
{ label: 'กล่อง (box)', value: 'box' },
{ label: 'หลอด (tube)', value: 'tube' },
];
export default function RawMaterialDetailPage() {
const { id } = useParams<{ id: string }>();
const isNew = !id || id === 'new';
const navigate = useNavigate();
const [loading, setLoading] = useState(!isNew);
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
const [toast, setToast] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
// Form Fields
const [name, setName] = useState('');
const [unit, setUnit] = useState('piece');
const [pricePerUnit, setPricePerUnit] = useState<number>(0);
const [materialData, setMaterialData] = useState<any>(null);
useEffect(() => {
if (isNew) return;
setLoading(true);
fetch(`${API_BASE}/raw-materials/${id}`)
.then(async (res) => {
if (!res.ok) throw new Error('ไม่พบข้อมูลวัตถุดิบ');
return await res.json();
})
.then((data) => {
setMaterialData(data);
setName(data.name || '');
setUnit(data.unit || 'piece');
setPricePerUnit(Number(data.pricePerUnit) || 0);
})
.catch((err) => {
console.error(err);
setToast({ type: 'error', text: err.message || 'เกิดข้อผิดพลาดในการโหลดข้อมูล' });
})
.finally(() => setLoading(false));
}, [id, isNew]);
const showToast = (type: 'success' | 'error', text: string) => {
setToast({ type, text });
setTimeout(() => setToast(null), 3000);
};
const handleSave = async (e?: FormEvent) => {
if (e) e.preventDefault();
if (!name.trim()) {
showToast('error', 'กรุณาระบุชื่อวัตถุดิบ');
return;
}
if (!unit.trim()) {
showToast('error', 'กรุณาระบุหน่วยนับ');
return;
}
if (pricePerUnit < 0) {
showToast('error', 'ราคาต้นทุนต่อหน่วยต้องไม่ติดลบ');
return;
}
setSaving(true);
try {
const payload = {
name: name.trim(),
unit: unit.trim(),
pricePerUnit: Number(pricePerUnit) || 0,
};
const url = isNew ? `${API_BASE}/raw-materials` : `${API_BASE}/raw-materials/${id}`;
const method = isNew ? 'POST' : 'PUT';
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.message || 'บันทึกข้อมูลไม่สำเร็จ');
}
showToast('success', isNew ? 'สร้างวัตถุดิบใหม่สำเร็จ' : 'บันทึกการแก้ไขสำเร็จ');
if (isNew && data.id) {
setTimeout(() => {
navigate(`/raw-materials/${data.id}`, { replace: true });
}, 800);
}
} catch (err: any) {
showToast('error', err.message || 'เกิดข้อผิดพลาดในการบันทึก');
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!confirm(`คุณแน่ใจหรือไม่ว่าต้องการลบวัตถุดิบ "${name}"?`)) return;
setDeleting(true);
try {
const res = await fetch(`${API_BASE}/raw-materials/${id}`, {
method: 'DELETE',
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.message || 'ไม่สามารถลบวัตถุดิบนี้ได้');
}
navigate('/raw-materials', { replace: true });
} catch (err: any) {
showToast('error', err.message || 'เกิดข้อผิดพลาดในการลบ');
setDeleting(false);
}
};
if (loading) {
return (
<div className="flex flex-col items-center justify-center min-h-[400px] space-y-4">
<Loader2 className="w-8 h-8 text-cyan-400 animate-spin" />
<p className="text-slate-400 text-sm">กำลังโหลดข้อมูลวัตถุดิบ...</p>
</div>
);
}
const bomItems = materialData?.bomItems || [];
return (
<div className="space-y-8 animate-fade-in max-w-5xl mx-auto pb-16">
{/* Toast Notification */}
{toast && (
<div
className={`fixed bottom-6 right-6 z-50 p-4 rounded-2xl border shadow-2xl flex items-center gap-3 animate-slide-up ${
toast.type === 'success'
? 'bg-emerald-950/90 border-emerald-500/40 text-emerald-200'
: 'bg-rose-950/90 border-rose-500/40 text-rose-200'
}`}
>
{toast.type === 'success' ? (
<CheckCircle2 className="w-5 h-5 text-emerald-400" />
) : (
<AlertCircle className="w-5 h-5 text-rose-400" />
)}
<span className="text-sm font-medium">{toast.text}</span>
</div>
)}
{/* Top Breadcrumb & Navigation */}
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2 text-xs text-slate-400">
<Link to="/raw-materials" className="hover:text-white transition-colors flex items-center gap-1">
<PackageOpen className="w-3.5 h-3.5" />
<span>Raw Materials</span>
</Link>
<span>/</span>
<span className="text-cyan-400 font-medium">
{isNew ? 'เพิ่มวัตถุดิบใหม่' : name || `ID #${id}`}
</span>
</div>
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => navigate('/raw-materials')}
className="p-2.5 rounded-2xl bg-white/5 border border-white/10 hover:bg-white/10 text-slate-300 hover:text-white transition-all shadow-sm"
title="ย้อนกลับไปหน้ารายการ"
>
<ArrowLeft className="w-5 h-5" />
</button>
<div>
<h1 className="text-2xl md:text-3xl font-bold text-white flex items-center gap-2.5">
{isNew ? 'เพิ่มวัตถุดิบใหม่' : `แก้ไขวัตถุดิบ`}
{!isNew && (
<span className="text-xs px-2.5 py-1 rounded-full bg-cyan-500/10 text-cyan-300 border border-cyan-500/20 font-mono">
ID: #{id}
</span>
)}
</h1>
<p className="text-xs md:text-sm text-slate-400">
{isNew
? 'สร้างรายการวัตถุดิบสำหรับใช้คำนวณต้นทุนสินค้าและผูกสูตรผลิต (BOM)'
: 'กำหนดข้อมูลราคาต้นทุนและหน่วยนับสำหรับใช้ในสูตรการผลิตสินค้า'}
</p>
</div>
</div>
<div className="flex items-center gap-3">
{!isNew && (
<button
type="button"
onClick={handleDelete}
disabled={deleting || saving}
className="px-4 py-2.5 rounded-xl border border-rose-500/30 text-rose-400 hover:bg-rose-500/10 hover:border-rose-500/50 font-medium text-sm flex items-center gap-2 transition-all disabled:opacity-50"
>
{deleting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
<span>ลบวัตถุดิบ</span>
</button>
)}
<button
type="button"
onClick={() => handleSave()}
disabled={saving}
className="btn-primary px-6 py-2.5 flex items-center gap-2 text-sm shadow-lg shadow-cyan-500/10"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
<span>{saving ? 'กำลังบันทึก...' : 'บันทึกข้อมูล'}</span>
</button>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Left Column: Form Details (2 cols) */}
<div className="lg:col-span-2 space-y-6">
<form onSubmit={handleSave} className="admin-card space-y-6 border-white/10 shadow-2xl p-6 md:p-8">
<div className="flex items-center gap-2 pb-4 border-b border-white/5">
<Sparkles className="w-4 h-4 text-cyan-400" />
<h2 className="text-base font-bold text-white uppercase tracking-wider text-xs">
ข้อมูลวัตถุดิบ (Material Information)
</h2>
</div>
{/* Material Name */}
<div className="space-y-2">
<label className="text-xs font-bold text-slate-300 uppercase tracking-wider flex items-center justify-between">
<span>ชื่อวัตถุดิบ (Material Name) <span className="text-rose-400">*</span></span>
<span className="text-[10px] text-slate-500 font-normal">ห้ามซ้ำกับรายการอื่นในระบบ</span>
</label>
<input
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="เช่น ขวดแก้ว 100ml, น้ำมันหอมระเหย ลาเวนเดอร์, ก้านไม้ไฟเบอร์"
className="admin-input w-full text-sm"
/>
</div>
{/* Unit Selection */}
<div className="space-y-3">
<label className="text-xs font-bold text-slate-300 uppercase tracking-wider">
หน่วยนับ (Unit of Measurement) <span className="text-rose-400">*</span>
</label>
<input
type="text"
required
value={unit}
onChange={(e) => setUnit(e.target.value)}
placeholder="เช่น piece, kg, liter, pack, ml"
className="admin-input w-full text-sm"
/>
{/* Quick Preset Unit Chips */}
<div className="space-y-1.5">
<span className="text-[11px] text-slate-400">เลือกหน่วยที่ใช้บ่อย:</span>
<div className="flex flex-wrap gap-2">
{COMMON_UNITS.map((u) => (
<button
key={u.value}
type="button"
onClick={() => setUnit(u.value)}
className={`text-xs px-2.5 py-1 rounded-lg border transition-all ${
unit === u.value
? 'bg-cyan-500/20 border-cyan-500/40 text-cyan-300 font-bold'
: 'bg-white/5 border-white/5 text-slate-400 hover:text-white hover:bg-white/10'
}`}
>
{u.label}
</button>
))}
</div>
</div>
</div>
{/* Cost Price per Unit */}
<div className="space-y-2">
<label className="text-xs font-bold text-slate-300 uppercase tracking-wider flex items-center justify-between">
<span>ต้นทุนต่อหน่วย (Cost per Unit) <span className="text-rose-400">*</span></span>
<span className="text-[10px] text-slate-500 font-normal">ใช้ในการคำนวณสูตร BOM สินค้า</span>
</label>
<div className="relative">
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 font-bold text-sm">
฿
</span>
<input
type="number"
step="0.01"
min="0"
required
value={pricePerUnit === 0 ? '' : pricePerUnit}
onChange={(e) => setPricePerUnit(parseFloat(e.target.value) || 0)}
placeholder="0.00"
className="admin-input w-full pl-9 font-mono text-emerald-400 font-bold text-base"
/>
</div>
<p className="text-[11px] text-slate-500">
ระบุราคาต้นทุนเฉลี่ยต่อ 1 {unit || 'หน่วย'} เช่น 18.50 บาท / ขวด
</p>
</div>
</form>
</div>
{/* Right Column: BOM Linked Products & Summary (1 col) */}
<div className="space-y-6">
{/* Summary Card */}
<div className="admin-card border-white/10 shadow-xl p-6 space-y-4">
<div className="flex items-center gap-2 pb-3 border-b border-white/5">
<Layers className="w-4 h-4 text-cyan-400" />
<h3 className="text-xs font-bold text-white uppercase tracking-wider">
สรุปภาพรวม (Overview)
</h3>
</div>
<div className="space-y-3 text-xs">
<div className="flex justify-between py-1.5 border-b border-white/5 text-slate-300">
<span className="text-slate-400">สถานะ:</span>
<span className="font-semibold text-emerald-400">พร้อมใช้งาน</span>
</div>
<div className="flex justify-between py-1.5 border-b border-white/5 text-slate-300">
<span className="text-slate-400">หน่วยนับ:</span>
<span className="font-mono font-bold text-white">{unit || '-'}</span>
</div>
<div className="flex justify-between py-1.5 border-b border-white/5 text-slate-300">
<span className="text-slate-400">ต้นทุน / หน่วย:</span>
<span className="font-mono font-bold text-emerald-400">
฿{Number(pricePerUnit || 0).toLocaleString(undefined, { minimumFractionDigits: 2 })}
</span>
</div>
{!isNew && materialData?.updatedAt && (
<div className="flex justify-between py-1.5 text-slate-300">
<span className="text-slate-400">อัปเดตล่าสุด:</span>
<span className="text-slate-400">
{new Date(materialData.updatedAt).toLocaleDateString('th-TH')}
</span>
</div>
)}
</div>
</div>
{/* BOM Usage Section */}
{!isNew && (
<div className="admin-card border-white/10 shadow-xl p-6 space-y-4">
<div className="flex items-center justify-between pb-3 border-b border-white/5">
<div className="flex items-center gap-2">
<Boxes className="w-4 h-4 text-cyan-400" />
<h3 className="text-xs font-bold text-white uppercase tracking-wider">
สูตรสินค้าที่ใช้วัตถุดิบนี้ ({bomItems.length})
</h3>
</div>
<Link
to="/products/bom"
className="text-[11px] text-cyan-400 hover:text-cyan-300 flex items-center gap-1 transition-colors"
>
<span>จัดการ BOM</span>
<ExternalLink className="w-3 h-3" />
</Link>
</div>
{bomItems.length === 0 ? (
<div className="py-6 text-center space-y-2">
<Boxes className="w-8 h-8 text-slate-700 mx-auto" />
<p className="text-xs text-slate-400 italic">
ยังไม่มีสูตรสินค้า (BOM) ที่ผูกกับวัตถุดิบนี้
</p>
<p className="text-[11px] text-slate-500">
สามารถไปผูกวัตถุดิบเข้ากับ Variant สินค้าได้ที่เมนู "BOM สูตรผลิต"
</p>
</div>
) : (
<div className="space-y-2.5 max-h-[300px] overflow-y-auto pr-1">
{bomItems.map((item: any, idx: number) => {
const product = item.productVariant?.product;
const variant = item.productVariant;
const variantLabel = [variant?.option1, variant?.option2, variant?.option3]
.filter(Boolean)
.join(' / ');
return (
<div
key={item.id || idx}
className="p-3 rounded-xl bg-white/[0.02] border border-white/5 space-y-1 hover:border-white/10 transition-all"
>
<p className="text-xs font-bold text-white line-clamp-1">
{product?.name || `Product #${variant?.productId}`}
</p>
<div className="flex items-center justify-between text-[11px] text-slate-400">
<span>{variantLabel ? `ตัวเลือก: ${variantLabel}` : 'Base Variant'}</span>
<span className="font-mono text-cyan-400 font-bold">
{item.quantity} {unit}
</span>
</div>
</div>
);
})}
</div>
)}
</div>
)}
</div>
</div>
</div>
);
}
+231 -171
View File
@@ -1,227 +1,287 @@
import { useState, useEffect } from 'react' import { useState, useEffect, useMemo } from 'react';
import { Plus, Edit3, Trash2, Loader2, PackageOpen } from 'lucide-react' import { useNavigate, Link } from 'react-router-dom';
import { API_BASE, type RawMaterial } from '@homaroma/types' import {
Plus,
Edit3,
Trash2,
PackageOpen,
Search,
FileJson,
Boxes,
Layers,
Sparkles,
} from 'lucide-react';
import { API_BASE, type RawMaterial } from '@homaroma/types';
import RawMaterialBulkJsonModal from '../components/RawMaterialBulkJsonModal';
export default function RawMaterialsPage() { export default function RawMaterialsPage() {
const [materials, setMaterials] = useState<RawMaterial[]>([]) const navigate = useNavigate();
const [loading, setLoading] = useState(true) const [materials, setMaterials] = useState<RawMaterial[]>([]);
const [loading, setLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false) const [searchQuery, setSearchQuery] = useState('');
const [editingId, setEditingId] = useState<number | null>(null) const [isBulkModalOpen, setIsBulkModalOpen] = useState(false);
const [name, setName] = useState('')
const [unit, setUnit] = useState('')
const [pricePerUnit, setPricePerUnit] = useState<number>(0)
const [saving, setSaving] = useState(false)
const fetchMaterials = async () => { const fetchMaterials = async () => {
setLoading(true) setLoading(true);
try { try {
const res = await fetch(`${API_BASE}/raw-materials`) const res = await fetch(`${API_BASE}/raw-materials`);
if (res.ok) { if (res.ok) {
setMaterials(await res.json()) setMaterials(await res.json());
} }
} catch (err) { } catch (err) {
console.error('Failed to fetch raw materials', err) console.error('Failed to fetch raw materials', err);
} finally { } finally {
setLoading(false) setLoading(false);
} }
} };
useEffect(() => { useEffect(() => {
fetchMaterials() fetchMaterials();
}, []) }, []);
const handleOpenModal = (material?: RawMaterial) => { const handleDelete = async (id: number, name: string) => {
if (material) { if (!confirm(`คุณแน่ใจหรือไม่ว่าต้องการลบวัตถุดิบ "${name}"?`)) return;
setEditingId(material.id)
setName(material.name)
setUnit(material.unit)
setPricePerUnit(material.pricePerUnit)
} else {
setEditingId(null)
setName('')
setUnit('')
setPricePerUnit(0)
}
setIsModalOpen(true)
}
const handleSave = async () => {
if (!name || !unit || pricePerUnit <= 0) return alert('Please fill all fields')
setSaving(true)
try { try {
const payload = { name, unit, pricePerUnit } const res = await fetch(`${API_BASE}/raw-materials/${id}`, { method: 'DELETE' });
const url = editingId
? `${API_BASE}/raw-materials/${editingId}`
: `${API_BASE}/raw-materials`
const res = await fetch(url, {
method: editingId ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
if (res.ok) { if (res.ok) {
setIsModalOpen(false) fetchMaterials();
fetchMaterials()
} else { } else {
alert('Failed to save raw material') const data = await res.json();
alert(data.message || 'ไม่สามารถลบวัตถุดิบนี้ได้');
} }
} catch (err) { } catch (err) {
console.error(err) console.error(err);
} finally {
setSaving(false)
} }
} };
const handleDelete = async (id: number) => { // Filtered materials by search query
if (!confirm('Are you sure you want to delete this raw material?')) return const filteredMaterials = useMemo(() => {
if (!searchQuery.trim()) return materials;
try { const q = searchQuery.toLowerCase().trim();
const res = await fetch(`${API_BASE}/raw-materials/${id}`, { method: 'DELETE' }) return materials.filter(
if (res.ok) { (m) =>
fetchMaterials() m.name.toLowerCase().includes(q) ||
} m.unit.toLowerCase().includes(q) ||
} catch (err) { String(m.id).includes(q)
console.error(err) );
} }, [materials, searchQuery]);
}
// Aggregate metrics
const metrics = useMemo(() => {
const totalCount = materials.length;
const avgCost = totalCount > 0
? materials.reduce((sum, m) => sum + (Number(m.pricePerUnit) || 0), 0) / totalCount
: 0;
const units = new Set(materials.map((m) => m.unit.toLowerCase())).size;
return { totalCount, avgCost, units };
}, [materials]);
return ( return (
<div className="space-y-8 animate-fade-in"> <div className="space-y-8 animate-fade-in pb-12">
{/* Page Header */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6"> <div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
<div className="space-y-1"> <div className="space-y-1">
<h1 className="text-3xl font-bold">Raw Materials (วัตถุดิบ)</h1> <div className="flex items-center gap-3">
<p className="text-slate-500 text-sm">Manage inventory and costs for BOM calculation.</p> <h1 className="text-3xl font-bold text-white">Raw Materials (วัตถุดิบ)</h1>
<span className="px-2.5 py-0.5 rounded-full text-xs font-semibold bg-cyan-500/10 text-cyan-400 border border-cyan-500/20">
{materials.length} รายการ
</span>
</div>
<p className="text-slate-400 text-sm">
จัดการรายการวัตถุดิบ หน่วยนับ และต้นทุนสำหรับผูกสูตรผลิตสินค้า (BOM)
</p>
</div>
<div className="flex items-center gap-3">
{/* Bulk JSON Import / Export Button */}
<button
type="button"
onClick={() => setIsBulkModalOpen(true)}
className="px-4 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-200 border border-white/10 font-medium text-sm flex items-center gap-2 transition-all shadow-sm"
>
<FileJson className="w-4 h-4 text-cyan-400" />
<span>Import / Export JSON</span>
</button>
{/* Add Material (Navigates to Full Page Route) */}
<button
type="button"
onClick={() => navigate('/raw-materials/new')}
className="btn-primary flex items-center gap-2 text-sm shadow-lg shadow-cyan-500/10"
>
<Plus className="w-4 h-4" />
<span>เพิ่มวัตถุดิบ</span>
</button>
</div> </div>
<button onClick={() => handleOpenModal()} className="btn-primary flex items-center gap-2">
<Plus className="w-4 h-4" />
Add Material
</button>
</div> </div>
{/* Metrics Row */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="admin-card p-5 border-white/5 bg-night-900/60 flex items-center gap-4">
<div className="w-12 h-12 rounded-2xl bg-cyan-500/10 border border-cyan-500/20 flex items-center justify-center text-cyan-400 shrink-0">
<PackageOpen className="w-6 h-6" />
</div>
<div>
<p className="text-xs font-bold text-slate-400 uppercase tracking-wider">วัตถุดิบทั้งหมด</p>
<p className="text-2xl font-bold text-white font-mono mt-0.5">{metrics.totalCount} <span className="text-xs font-normal text-slate-400">รายการ</span></p>
</div>
</div>
<div className="admin-card p-5 border-white/5 bg-night-900/60 flex items-center gap-4">
<div className="w-12 h-12 rounded-2xl bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center text-emerald-400 shrink-0">
<Sparkles className="w-6 h-6" />
</div>
<div>
<p className="text-xs font-bold text-slate-400 uppercase tracking-wider">ต้นทุนเฉลี่ย / หน่วย</p>
<p className="text-2xl font-bold text-emerald-400 font-mono mt-0.5">
฿{metrics.avgCost.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</p>
</div>
</div>
<div className="admin-card p-5 border-white/5 bg-night-900/60 flex items-center gap-4">
<div className="w-12 h-12 rounded-2xl bg-purple-500/10 border border-purple-500/20 flex items-center justify-center text-purple-400 shrink-0">
<Layers className="w-6 h-6" />
</div>
<div>
<p className="text-xs font-bold text-slate-400 uppercase tracking-wider">ประเภทหน่วยนับ</p>
<p className="text-2xl font-bold text-white font-mono mt-0.5">{metrics.units} <span className="text-xs font-normal text-slate-400">แบบ</span></p>
</div>
</div>
</div>
{/* Search and Filters */}
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
<div className="relative w-full sm:w-80">
<Search className="w-4 h-4 text-slate-400 absolute left-3.5 top-1/2 -translate-y-1/2" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="ค้นหาตามชื่อ หรือ หน่วยนับ..."
className="admin-input w-full pl-10 text-xs"
/>
</div>
<div className="text-xs text-slate-400">
แสดง <strong>{filteredMaterials.length}</strong> จากทั้งหมด {materials.length} รายการ
</div>
</div>
{/* Data Table */}
<div className="admin-card !p-0 overflow-hidden border-white/10 shadow-2xl"> <div className="admin-card !p-0 overflow-hidden border-white/10 shadow-2xl">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-left border-collapse"> <table className="w-full text-left border-collapse">
<thead> <thead>
<tr className="bg-white/[0.02] border-b border-white/5"> <tr className="bg-white/[0.02] border-b border-white/5">
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">Material Name</th> <th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500 w-16">ID</th>
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">Unit</th> <th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">ชื่อวัตถุดิบ (Material Name)</th>
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">Cost Per Unit</th> <th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">หน่วยนับ (Unit)</th>
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500 text-right">Actions</th> <th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">ต้นทุนต่อหน่วย (Cost)</th>
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500 text-right">การจัดการ</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-white/[0.03]"> <tbody className="divide-y divide-white/[0.03]">
{loading ? ( {loading ? (
Array.from({ length: 3 }).map((_, i) => ( Array.from({ length: 4 }).map((_, i) => (
<tr key={i}> <tr key={i}>
<td colSpan={4} className="px-6 py-8"> <td colSpan={5} className="px-6 py-8">
<div className="h-6 w-full bg-white/5 animate-pulse rounded-lg" /> <div className="h-6 w-full bg-white/5 animate-pulse rounded-lg" />
</td> </td>
</tr> </tr>
)) ))
) : materials.length === 0 ? ( ) : filteredMaterials.length === 0 ? (
<tr> <tr>
<td colSpan={4} className="px-6 py-20 text-center space-y-3"> <td colSpan={5} className="px-6 py-20 text-center space-y-3">
<PackageOpen className="w-10 h-10 text-slate-700 mx-auto" /> <PackageOpen className="w-10 h-10 text-slate-700 mx-auto" />
<p className="text-slate-500 font-medium italic">No raw materials found.</p> <p className="text-slate-400 font-medium">
</td> {searchQuery ? 'ไม่พบวัตถุดิบที่ตรงกับคำค้นหา' : 'ยังไม่มีข้อมูลวัตถุดิบในระบบ'}
</tr> </p>
) : materials.map((item) => ( <div className="flex items-center justify-center gap-3 pt-2">
<tr key={item.id} className="hover:bg-white/[0.01] transition-colors"> <button
<td className="px-6 py-4"> type="button"
<p className="text-sm font-bold text-white">{item.name}</p> onClick={() => setIsBulkModalOpen(true)}
</td> className="px-4 py-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 text-xs font-medium border border-white/10 transition-colors"
<td className="px-6 py-4"> >
<span className="inline-block px-2 py-1 rounded-md bg-night-950 border border-white/5 text-slate-400 text-[10px] uppercase font-bold tracking-wider"> นำเข้าผ่าน JSON
{item.unit}
</span>
</td>
<td className="px-6 py-4">
<p className="text-sm font-bold text-emerald-400">฿{item.pricePerUnit.toLocaleString(undefined, { minimumFractionDigits: 2 })}</p>
</td>
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-2">
<button onClick={() => handleOpenModal(item)} className="p-2 rounded-lg hover:bg-white/5 text-slate-500 hover:text-cyan-glow transition-all">
<Edit3 className="w-4 h-4" />
</button> </button>
<button onClick={() => handleDelete(item.id)} className="p-2 rounded-lg hover:bg-white/5 text-slate-500 hover:text-rose-500 transition-all"> <button
<Trash2 className="w-4 h-4" /> type="button"
onClick={() => navigate('/raw-materials/new')}
className="btn-primary text-xs px-4 py-2"
>
เพิ่มวัตถุดิบใหม่
</button> </button>
</div> </div>
</td> </td>
</tr> </tr>
))} ) : (
filteredMaterials.map((item) => (
<tr key={item.id} className="hover:bg-white/[0.015] transition-colors group">
<td className="px-6 py-4 text-xs font-mono text-slate-500">
#{item.id}
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-xl bg-cyan-500/10 border border-cyan-500/20 flex items-center justify-center text-cyan-400 shrink-0">
<Boxes className="w-4 h-4" />
</div>
<Link
to={`/raw-materials/${item.id}`}
className="text-sm font-bold text-white hover:text-cyan-400 transition-colors"
>
{item.name}
</Link>
</div>
</td>
<td className="px-6 py-4">
<span className="inline-block px-2.5 py-1 rounded-md bg-night-950 border border-white/5 text-slate-300 text-xs font-mono font-medium">
{item.unit}
</span>
</td>
<td className="px-6 py-4">
<p className="text-sm font-bold text-emerald-400 font-mono">
฿{Number(item.pricePerUnit).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</p>
</td>
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-2">
{/* Edit Button navigates to Full Page Route */}
<button
type="button"
onClick={() => navigate(`/raw-materials/${item.id}`)}
className="px-3 py-1.5 rounded-lg bg-white/5 hover:bg-cyan-500/10 text-slate-400 hover:text-cyan-400 border border-white/5 hover:border-cyan-500/20 text-xs font-medium transition-all flex items-center gap-1.5"
title="แก้ไขรายละเอียด"
>
<Edit3 className="w-3.5 h-3.5" />
<span>แก้ไข</span>
</button>
<button
type="button"
onClick={() => handleDelete(item.id, item.name)}
className="p-1.5 rounded-lg hover:bg-rose-500/10 text-slate-500 hover:text-rose-400 transition-all"
title="ลบรายการ"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))
)}
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
{isModalOpen && ( {/* Bulk JSON Import / Export Modal */}
<div className="fixed inset-0 z-50 flex items-center justify-center p-4"> <RawMaterialBulkJsonModal
<div className="absolute inset-0 bg-night-950/80 backdrop-blur-sm" onClick={() => !saving && setIsModalOpen(false)} /> isOpen={isBulkModalOpen}
<div className="relative w-full max-w-md bg-night-900 border border-white/10 rounded-3xl shadow-2xl flex flex-col overflow-hidden animate-zoom-in p-6 space-y-6"> onClose={() => setIsBulkModalOpen(false)}
<h3 className="text-xl font-bold">{editingId ? 'Edit Material' : 'Add New Material'}</h3> materials={materials}
onSuccess={fetchMaterials}
<div className="space-y-4"> />
<div className="space-y-1">
<label className="text-xs font-bold text-slate-400 uppercase tracking-wider">Material Name</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
className="admin-input w-full"
placeholder="e.g. Alcohol, Glass Bottle 50ml"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<label className="text-xs font-bold text-slate-400 uppercase tracking-wider">Unit</label>
<input
type="text"
value={unit}
onChange={e => setUnit(e.target.value)}
className="admin-input w-full"
placeholder="e.g. kg, liter, piece"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-bold text-slate-400 uppercase tracking-wider">Price per Unit (฿)</label>
<input
type="number"
value={pricePerUnit || ''}
onChange={e => setPricePerUnit(Number(e.target.value))}
className="admin-input w-full"
placeholder="0.00"
step="0.01"
/>
</div>
</div>
</div>
<div className="flex items-center justify-end gap-3 pt-4 border-t border-white/5">
<button
onClick={() => setIsModalOpen(false)}
disabled={saving}
className="px-4 py-2 rounded-xl text-slate-400 hover:text-white hover:bg-white/5 font-medium transition-all"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={saving}
className="btn-primary px-6 py-2 flex items-center gap-2"
>
{saving && <Loader2 className="w-4 h-4 animate-spin" />}
{saving ? 'Saving...' : 'Save Material'}
</button>
</div>
</div>
</div>
)}
</div> </div>
) );
} }
+66
View File
@@ -14,11 +14,77 @@ export const rawMaterialsRoute = new Elysia({ prefix: "/raw-materials" })
const rawMaterial = await prisma.rawMaterial.findUnique({ const rawMaterial = await prisma.rawMaterial.findUnique({
where: { id }, where: { id },
include: {
bomItems: {
include: {
productVariant: {
include: {
product: {
select: { id: true, name: true, sku: true }
}
}
}
}
}
}
}); });
if (!rawMaterial) return status(404, { message: "Raw material not found" }); if (!rawMaterial) return status(404, { message: "Raw material not found" });
return rawMaterial; return rawMaterial;
}) })
.post("/bulk", async ({ body, status }) => {
try {
const items = Array.isArray(body) ? body : [body];
if (items.length === 0) {
return status(400, { message: "Payload must be a non-empty array" });
}
const results = await prisma.$transaction(async (tx) => {
const records = [];
for (const item of items) {
const name = String(item.name || "").trim();
if (!name) continue;
const unit = String(item.unit || "piece").trim() || "piece";
const rawPrice = item.pricePerUnit;
const pricePerUnit = typeof rawPrice === "string" ? (parseFloat(rawPrice) || 0) : Number(rawPrice) || 0;
const record = await tx.rawMaterial.upsert({
where: { name },
create: {
name,
unit,
pricePerUnit,
},
update: {
unit,
pricePerUnit,
},
});
records.push(record);
}
return records;
});
return status(200, {
success: true,
count: results.length,
items: results,
});
} catch (err: any) {
console.error("Bulk raw material import failed:", err);
return status(500, { message: "Failed to bulk import raw materials", error: err.message });
}
}, {
body: t.Array(
t.Object({
name: t.String(),
unit: t.Optional(t.String()),
pricePerUnit: t.Union([t.Number(), t.String()]),
})
),
detail: { tags: ["raw-materials"] }
})
.post("/", async ({ body, status }) => { .post("/", async ({ body, status }) => {
try { try {
const rawMaterial = await prisma.rawMaterial.create({ const rawMaterial = await prisma.rawMaterial.create({
+3
View File
@@ -143,6 +143,9 @@ export interface RawMaterial {
name: string name: string
unit: string unit: string
pricePerUnit: number pricePerUnit: number
bomItems?: BomItem[]
createdAt?: string
updatedAt?: string
} }
export interface BomItem { export interface BomItem {