diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index 9a93f3a..d87c1e0 100644 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -3,6 +3,7 @@ import AdminLayout from './components/AdminLayout' import DashboardPage from './pages/DashboardPage' import ProductsPage from './pages/ProductsPage' import ProductEditPage from './pages/ProductEditPage' +import ProductBomPage from './pages/ProductBomPage' import CategoriesPage from './pages/CategoriesPage' import CustomersPage from './pages/CustomersPage' import PageBuilderPage from './pages/PageBuilderPage' @@ -50,6 +51,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/admin/src/pages/ProductBomPage.tsx b/apps/admin/src/pages/ProductBomPage.tsx new file mode 100644 index 0000000..f843717 --- /dev/null +++ b/apps/admin/src/pages/ProductBomPage.tsx @@ -0,0 +1,247 @@ +import { useState, useEffect } from 'react' +import { useParams, useNavigate, Link } from 'react-router-dom' +import { ArrowLeft, Save, Trash2, Plus, Loader2, CheckCircle2, AlertCircle, Package } from 'lucide-react' +import { API_BASE, type Product, type Variant } from '@homaroma/types' + +export default function ProductBomPage() { + const { id } = useParams<{ id: string }>() + const navigate = useNavigate() + + const [product, setProduct] = useState(null) + const [variants, setVariants] = useState([]) + const [rawMaterials, setRawMaterials] = useState([]) + + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(false) + + useEffect(() => { + const fetchData = async () => { + try { + const rmRes = await fetch(`${API_BASE}/raw-materials`) + if (rmRes.ok) { + const rmData = await rmRes.json() + setRawMaterials(rmData) + } + + if (id) { + const prodRes = await fetch(`${API_BASE}/products/${id}?lang=all`) + if (!prodRes.ok) throw new Error('Product not found') + + const prodData: Product = await prodRes.json() + setProduct(prodData) + setVariants(JSON.parse(JSON.stringify(prodData.variants))) // Deep copy + } + } catch (err: any) { + setError(err.message) + } finally { + setLoading(false) + } + } + + fetchData() + }, [id]) + + const handleSave = async () => { + setSaving(true) + setError(null) + setSuccess(false) + + try { + if (!product) throw new Error("Product not loaded") + + const payload = { + categoryId: product.categoryId, + name: product.name, + options: product.options, + images: product.images, // Need to pass existing images to avoid deleting them in some backends + variants: variants.map(v => ({ + id: v.id, + option1: v.option1, + option2: v.option2, + option3: v.option3, + price: v.price, + stock: v.stock, + sku: v.sku, + barcode: v.barcode, + bomItems: v.bomItems?.map((b: any) => ({ + rawMaterialId: b.rawMaterialId, + quantity: Number(b.quantity) || 0 + })) || [] + })) + } + + const response = await fetch(`${API_BASE}/products/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + + if (!response.ok) throw new Error('Failed to save BOM') + + setSuccess(true) + setTimeout(() => setSuccess(false), 3000) + } catch (err: any) { + setError(err.message) + } finally { + setSaving(false) + } + } + + if (loading) { + return ( +
+ +
+ ) + } + + if (!product) { + return
Product not found.
+ } + + const productName = typeof product.name === 'string' ? product.name : (product.name as any)?.th || (product.name as any)?.en || 'Unknown Product' + + return ( +
+ {/* Header & Actions */} +
+
+
+ Products + / + BOM +
+

+ BOM {productName} +

+
+ +
+ + +
+
+ + {success && ( +
+ +

BOM updated successfully!

+
+ )} + + {error && ( +
+ +

{error}

+
+ )} + +
+ {variants.length === 0 ? ( +
+ No variants found for this product. +
+ ) : variants.map((v, vIdx) => { + const variantName = [v.option1, v.option2, v.option3].filter(Boolean).join(' - ') || 'Default' + return ( +
+
+
+
+ +
+
+

{variantName}

+

SKU: {v.sku || 'N/A'}

+
+
+ +
+ +
+ {(!v.bomItems || v.bomItems.length === 0) ? ( +
+ No materials specified for this variant. +
+ ) : v.bomItems.map((bom: any, bIdx: number) => ( +
+
+ + +
+
+ + { + const newVariants = [...variants] + newVariants[vIdx].bomItems[bIdx].quantity = Number(e.target.value) + setVariants(newVariants) + }} + placeholder="0.00" + step="0.01" + className="w-full px-4 py-2.5 bg-night-950 border border-white/10 focus:border-cyan-glow/30 rounded-lg outline-none text-sm text-white transition-all shadow-inner" + /> +
+
+ +
+
+ ))} +
+
+ ) + })} +
+
+ ) +} diff --git a/apps/admin/src/pages/ProductEditPage.tsx b/apps/admin/src/pages/ProductEditPage.tsx index d02992b..82c95e2 100644 --- a/apps/admin/src/pages/ProductEditPage.tsx +++ b/apps/admin/src/pages/ProductEditPage.tsx @@ -58,6 +58,7 @@ export default function ProductEditPage() { const [attributes, setAttributes] = useState([]) const [selectedDefinitionId, setSelectedDefinitionId] = useState(0) + const [isMediaModalOpen, setIsMediaModalOpen] = useState(false) const [onSelectMedia, setOnSelectMedia] = useState<((url: string) => void) | null>(null) @@ -383,7 +384,7 @@ export default function ProductEditPage() { } return ( -
+
{/* Header & Actions */}
@@ -429,9 +430,9 @@ export default function ProductEditPage() {
)} -
- {/* Left Column: Identities */} -
+
+ {/* Main Content */} +
{/* Basic Info */}
@@ -940,77 +941,6 @@ export default function ProductEditPage() { )}
- - {/* Right Column: Preview/Summary */} -
-
-
-
- -

Preview Gallery

-
-
- Live -
-
- -
- {/* Main Image Preview */} -
- img.url)?.url ? fixUrl(images.find(img => img.url)!.url!) : getCategoryImage(typeof product?.category.name === 'string' ? product.category.name : (product?.category.name as any)?.en || '')} - className="w-full h-full object-contain transition-all duration-700" - alt="" - /> -
-
-

{typeof product?.category.name === 'string' ? product.category.name : (product?.category.name as any)?.th || (product?.category.name as any)?.en || ''}

-

{product?.brand.name}

-
-
-
- - {/* Thumbnail Strip Preview */} - {images.length > 0 && ( -
- {images.filter(img => img.url).map((img, i) => ( -
- -
- ))} -
- )} - -
-

Summary

-
-
- Variants - {variants.length} -
-
- Min Price - - {variants.length > 0 - ? formatPrice(Math.min(...variants.map(v => Number(v.price) || 0))) - : '-'} - -
-
- Images - {images.filter(img => img.url).length} -
-
-
-
-
- -
-

- Tip: The first image in the list will be used as the primary thumbnail across the store. -

-
-
+
) } diff --git a/apps/admin/src/pages/ProductsPage.tsx b/apps/admin/src/pages/ProductsPage.tsx index b7652db..c861cce 100644 --- a/apps/admin/src/pages/ProductsPage.tsx +++ b/apps/admin/src/pages/ProductsPage.tsx @@ -284,7 +284,7 @@ export default function ProductsPage() { Product Category Variants - Base Price + Price Actions @@ -335,7 +335,13 @@ export default function ProductsPage() {

-

{formatPrice(product.price)}

+ {(() => { + const prices = product.variants?.map(v => v.price || 0) || [] + const minPrice = prices.length > 0 ? Math.min(...prices) : 0 + const maxPrice = prices.length > 0 ? Math.max(...prices) : 0 + const priceDisplay = minPrice === maxPrice ? formatPrice(minPrice) : `${formatPrice(minPrice)} - ${formatPrice(maxPrice)}` + return

{priceDisplay}

+ })()}
@@ -353,6 +359,13 @@ export default function ProductsPage() { +