From a647c9682d81360bfad691e13356e5b8e394fa34 Mon Sep 17 00:00:00 2001 From: antigravity-bot Date: Sun, 18 Oct 2026 03:30:19 +0000 Subject: [PATCH] feat(raw-materials): add bulk JSON import with upsert and full-page edit routes (#10) Fixes #9 --------- Co-authored-by: Your Name Reviewed-on: https://git.kittisak.com/admin/homaroma/pulls/10 Co-authored-by: antigravity-bot --- apps/admin/src/App.tsx | 3 + .../components/RawMaterialBulkJsonModal.tsx | 419 +++++++++++++++++ .../admin/src/pages/RawMaterialDetailPage.tsx | 431 ++++++++++++++++++ apps/admin/src/pages/RawMaterialsPage.tsx | 402 +++++++++------- apps/backend/src/routes/raw-materials.ts | 66 +++ packages/types/index.ts | 3 + 6 files changed, 1153 insertions(+), 171 deletions(-) create mode 100644 apps/admin/src/components/RawMaterialBulkJsonModal.tsx create mode 100644 apps/admin/src/pages/RawMaterialDetailPage.tsx diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index 7a30639..5c8925c 100644 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -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() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/apps/admin/src/components/RawMaterialBulkJsonModal.tsx b/apps/admin/src/components/RawMaterialBulkJsonModal.tsx new file mode 100644 index 0000000..e4f4731 --- /dev/null +++ b/apps/admin/src/components/RawMaterialBulkJsonModal.tsx @@ -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(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) => { + 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 ( +
+ {/* Backdrop */} +
!submitting && onClose()} + /> + + {/* Modal Container */} +
+ {/* Header */} +
+
+
+ +
+
+

+ Raw Materials JSON Tool + + Bulk Upsert + +

+

+ นำเข้าและส่งออกข้อมูลวัตถุดิบ รองรับการอัปเดตข้อมูลเดิมอัตโนมัติเมื่อชื่อซ้ำ +

+
+
+ +
+ + {/* Navigation Tabs */} +
+ + +
+ + {/* Content Body */} +
+ {activeTab === 'import' ? ( +
+ {/* Guidance Notice */} +
+
+
+ + กลไกอัจฉริยะ (Upsert Automation) +
+

+ วาง JSON Array รายการวัตถุดิบที่ต้องการ หากพบ name ที่มีอยู่แล้วในระบบ + ระบบจะ Update หน่วยนับและราคาต้นทุนให้ทันที หากยังไม่มีจะสร้างรายการใหม่ +

+
+ +
+ + {/* JSON Input Area */} +
+
+ +
+ + + {importJsonText && ( + + )} +
+
+ +