feat(raw-materials): add bulk JSON import with upsert and full-page edit routes #10
No files matched your search
@@ -14,6 +14,7 @@ import PageBuilderPage from './pages/PageBuilderPage'
|
||||
import PageReorderPage from './pages/PageReorderPage'
|
||||
import CmsMockupPage from './pages/CmsMockupPage'
|
||||
import RawMaterialsPage from './pages/RawMaterialsPage'
|
||||
import RawMaterialDetailPage from './pages/RawMaterialDetailPage'
|
||||
import LoginPage from './pages/LoginPage'
|
||||
import InstallSeedPage from './pages/InstallSeedPage'
|
||||
import ProtectedRoute from './components/ProtectedRoute'
|
||||
@@ -123,6 +124,8 @@ function App() {
|
||||
<Route path="/promotions" element={<PromotionsPage />} />
|
||||
<Route path="/coupons" element={<CouponsPage />} />
|
||||
<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/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>
|
||||
);
|
||||
}
|
||||
@@ -1,227 +1,287 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Plus, Edit3, Trash2, Loader2, PackageOpen } from 'lucide-react'
|
||||
import { API_BASE, type RawMaterial } from '@homaroma/types'
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
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() {
|
||||
const [materials, setMaterials] = useState<RawMaterial[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const [editingId, setEditingId] = useState<number | null>(null)
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [unit, setUnit] = useState('')
|
||||
const [pricePerUnit, setPricePerUnit] = useState<number>(0)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const navigate = useNavigate();
|
||||
const [materials, setMaterials] = useState<RawMaterial[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isBulkModalOpen, setIsBulkModalOpen] = useState(false);
|
||||
|
||||
const fetchMaterials = async () => {
|
||||
setLoading(true)
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/raw-materials`)
|
||||
const res = await fetch(`${API_BASE}/raw-materials`);
|
||||
if (res.ok) {
|
||||
setMaterials(await res.json())
|
||||
setMaterials(await res.json());
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch raw materials', err)
|
||||
console.error('Failed to fetch raw materials', err);
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchMaterials()
|
||||
}, [])
|
||||
fetchMaterials();
|
||||
}, []);
|
||||
|
||||
const handleOpenModal = (material?: RawMaterial) => {
|
||||
if (material) {
|
||||
setEditingId(material.id)
|
||||
setName(material.name)
|
||||
setUnit(material.unit)
|
||||
setPricePerUnit(material.pricePerUnit)
|
||||
} else {
|
||||
setEditingId(null)
|
||||
setName('')
|
||||
setUnit('')
|
||||
setPricePerUnit(0)
|
||||
}
|
||||
setIsModalOpen(true)
|
||||
}
|
||||
const handleDelete = async (id: number, name: string) => {
|
||||
if (!confirm(`คุณแน่ใจหรือไม่ว่าต้องการลบวัตถุดิบ "${name}"?`)) return;
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!name || !unit || pricePerUnit <= 0) return alert('Please fill all fields')
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
const payload = { name, unit, pricePerUnit }
|
||||
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)
|
||||
})
|
||||
|
||||
const res = await fetch(`${API_BASE}/raw-materials/${id}`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
setIsModalOpen(false)
|
||||
fetchMaterials()
|
||||
fetchMaterials();
|
||||
} else {
|
||||
alert('Failed to save raw material')
|
||||
const data = await res.json();
|
||||
alert(data.message || 'ไม่สามารถลบวัตถุดิบนี้ได้');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('Are you sure you want to delete this raw material?')) return
|
||||
// Filtered materials by search query
|
||||
const filteredMaterials = useMemo(() => {
|
||||
if (!searchQuery.trim()) return materials;
|
||||
const q = searchQuery.toLowerCase().trim();
|
||||
return materials.filter(
|
||||
(m) =>
|
||||
m.name.toLowerCase().includes(q) ||
|
||||
m.unit.toLowerCase().includes(q) ||
|
||||
String(m.id).includes(q)
|
||||
);
|
||||
}, [materials, searchQuery]);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/raw-materials/${id}`, { method: 'DELETE' })
|
||||
if (res.ok) {
|
||||
fetchMaterials()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
// 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 (
|
||||
<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="space-y-1">
|
||||
<h1 className="text-3xl font-bold">Raw Materials (วัตถุดิบ)</h1>
|
||||
<p className="text-slate-500 text-sm">Manage inventory and costs for BOM calculation.</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<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>
|
||||
<button onClick={() => handleOpenModal()} className="btn-primary flex items-center gap-2">
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Material
|
||||
</button>
|
||||
</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="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<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">Unit</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 text-right">Actions</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">ชื่อวัตถุดิบ (Material Name)</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">ต้นทุนต่อหน่วย (Cost)</th>
|
||||
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500 text-right">การจัดการ</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/[0.03]">
|
||||
{loading ? (
|
||||
Array.from({ length: 3 }).map((_, i) => (
|
||||
Array.from({ length: 4 }).map((_, 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" />
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : materials.length === 0 ? (
|
||||
) : filteredMaterials.length === 0 ? (
|
||||
<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" />
|
||||
<p className="text-slate-500 font-medium italic">No raw materials found.</p>
|
||||
</td>
|
||||
</tr>
|
||||
) : materials.map((item) => (
|
||||
<tr key={item.id} className="hover:bg-white/[0.01] transition-colors">
|
||||
<td className="px-6 py-4">
|
||||
<p className="text-sm font-bold text-white">{item.name}</p>
|
||||
</td>
|
||||
<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">
|
||||
{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" />
|
||||
<p className="text-slate-400 font-medium">
|
||||
{searchQuery ? 'ไม่พบวัตถุดิบที่ตรงกับคำค้นหา' : 'ยังไม่มีข้อมูลวัตถุดิบในระบบ'}
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsBulkModalOpen(true)}
|
||||
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"
|
||||
>
|
||||
นำเข้าผ่าน JSON
|
||||
</button>
|
||||
<button onClick={() => handleDelete(item.id)} className="p-2 rounded-lg hover:bg-white/5 text-slate-500 hover:text-rose-500 transition-all">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/raw-materials/new')}
|
||||
className="btn-primary text-xs px-4 py-2"
|
||||
>
|
||||
เพิ่มวัตถุดิบใหม่
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</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>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-night-950/80 backdrop-blur-sm" onClick={() => !saving && setIsModalOpen(false)} />
|
||||
<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">
|
||||
<h3 className="text-xl font-bold">{editingId ? 'Edit Material' : 'Add New Material'}</h3>
|
||||
|
||||
<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>
|
||||
)}
|
||||
{/* Bulk JSON Import / Export Modal */}
|
||||
<RawMaterialBulkJsonModal
|
||||
isOpen={isBulkModalOpen}
|
||||
onClose={() => setIsBulkModalOpen(false)}
|
||||
materials={materials}
|
||||
onSuccess={fetchMaterials}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -14,11 +14,77 @@ export const rawMaterialsRoute = new Elysia({ prefix: "/raw-materials" })
|
||||
|
||||
const rawMaterial = await prisma.rawMaterial.findUnique({
|
||||
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" });
|
||||
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 }) => {
|
||||
try {
|
||||
const rawMaterial = await prisma.rawMaterial.create({
|
||||
|
||||
@@ -143,6 +143,9 @@ export interface RawMaterial {
|
||||
name: string
|
||||
unit: string
|
||||
pricePerUnit: number
|
||||
bomItems?: BomItem[]
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export interface BomItem {
|
||||
|
||||
Reference in new issue
Block a user