This commit is contained in:
Your Name committed 2026-06-28 01:40:45 -04:00
1 parent 6a35dcf955
commit b362f5cadf
10 files changed
+335 -78

No files matched your search

+2
View File
@@ -3,6 +3,7 @@ import AdminLayout from './components/AdminLayout'
import DashboardPage from './pages/DashboardPage' import DashboardPage from './pages/DashboardPage'
import ProductsPage from './pages/ProductsPage' import ProductsPage from './pages/ProductsPage'
import ProductEditPage from './pages/ProductEditPage' import ProductEditPage from './pages/ProductEditPage'
import ProductBomPage from './pages/ProductBomPage'
import CategoriesPage from './pages/CategoriesPage' import CategoriesPage from './pages/CategoriesPage'
import CustomersPage from './pages/CustomersPage' import CustomersPage from './pages/CustomersPage'
import PageBuilderPage from './pages/PageBuilderPage' import PageBuilderPage from './pages/PageBuilderPage'
@@ -50,6 +51,7 @@ function App() {
<Route path="/product-attributes" element={<ProductAttributesPage />} /> <Route path="/product-attributes" element={<ProductAttributesPage />} />
<Route path="/products/new" element={<ProductEditPage />} /> <Route path="/products/new" element={<ProductEditPage />} />
<Route path="/products/:id/edit" element={<ProductEditPage />} /> <Route path="/products/:id/edit" element={<ProductEditPage />} />
<Route path="/products/:id/bom" element={<ProductBomPage />} />
<Route path="/categories" element={<CategoriesPage />} /> <Route path="/categories" element={<CategoriesPage />} />
<Route path="/customers" element={<CustomersPage />} /> <Route path="/customers" element={<CustomersPage />} />
<Route path="/page-builder" element={<PageBuilderPage />} /> <Route path="/page-builder" element={<PageBuilderPage />} />
+247
View File
@@ -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<Product | null>(null)
const [variants, setVariants] = useState<any[]>([])
const [rawMaterials, setRawMaterials] = useState<any[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(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 (
<div className="flex items-center justify-center min-h-[50vh]">
<Loader2 className="w-8 h-8 animate-spin text-cyan-glow" />
</div>
)
}
if (!product) {
return <div className="text-center py-12 text-slate-400">Product not found.</div>
}
const productName = typeof product.name === 'string' ? product.name : (product.name as any)?.th || (product.name as any)?.en || 'Unknown Product'
return (
<div className="w-full space-y-8 animate-fade-in max-w-5xl mx-auto">
{/* Header & Actions */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
<div className="space-y-1">
<div className="flex items-center gap-2 text-xs text-slate-500 uppercase tracking-widest mb-1">
<Link to="/products" className="hover:text-cyan-glow transition-colors">Products</Link>
<span>/</span>
<span className="text-white">BOM</span>
</div>
<h1 className="text-3xl font-bold tracking-tight">
BOM <span className="text-cyan-glow">{productName}</span>
</h1>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => navigate('/products')}
className="px-6 py-2.5 rounded-xl border border-white/5 bg-white/5 hover:bg-white/10 text-sm font-bold transition-all"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={saving}
className="btn-primary py-2.5 px-8 flex items-center gap-2"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
{saving ? 'Saving...' : 'Save BOM'}
</button>
</div>
</div>
{success && (
<div className="flex items-center gap-3 p-4 bg-emerald-500/10 border border-emerald-500/20 rounded-xl text-emerald-500 text-sm animate-fade-in">
<CheckCircle2 className="w-5 h-5 shrink-0" />
<p>BOM updated successfully!</p>
</div>
)}
{error && (
<div className="flex items-center gap-3 p-4 bg-rose-500/10 border border-rose-500/20 rounded-xl text-rose-500 text-sm animate-fade-in">
<AlertCircle className="w-5 h-5 shrink-0" />
<p>{error}</p>
</div>
)}
<div className="space-y-8">
{variants.length === 0 ? (
<div className="admin-card p-8 text-center text-slate-400">
No variants found for this product.
</div>
) : variants.map((v, vIdx) => {
const variantName = [v.option1, v.option2, v.option3].filter(Boolean).join(' - ') || 'Default'
return (
<section key={vIdx} className="admin-card space-y-4 relative">
<div className="flex items-center justify-between border-b border-white/10 pb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-cyan-glow/10 border border-cyan-glow/20 flex items-center justify-center">
<Package className="w-5 h-5 text-cyan-glow" />
</div>
<div>
<h3 className="font-bold text-lg text-white">{variantName}</h3>
<p className="text-xs text-slate-500 uppercase tracking-widest">SKU: {v.sku || 'N/A'}</p>
</div>
</div>
<button
onClick={() => {
const newVariants = [...variants]
if (!newVariants[vIdx].bomItems) newVariants[vIdx].bomItems = []
newVariants[vIdx].bomItems.push({ rawMaterialId: 0, quantity: 1 })
setVariants(newVariants)
}}
className="px-4 py-2 bg-white/5 hover:bg-white/10 text-white rounded-lg text-sm font-bold transition-all flex items-center gap-2"
>
<Plus className="w-4 h-4" />
Add Material
</button>
</div>
<div className="space-y-3 pt-2">
{(!v.bomItems || v.bomItems.length === 0) ? (
<div className="text-center py-6 text-slate-500 text-sm italic bg-white/[0.02] rounded-xl border border-white/5 border-dashed">
No materials specified for this variant.
</div>
) : v.bomItems.map((bom: any, bIdx: number) => (
<div key={bIdx} className="flex items-center gap-4 bg-night-950/50 p-4 rounded-xl border border-white/5">
<div className="flex-1">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1 mb-1 block">Raw Material</label>
<select
value={bom.rawMaterialId || ''}
onChange={(e) => {
const newVariants = [...variants]
newVariants[vIdx].bomItems[bIdx].rawMaterialId = Number(e.target.value)
setVariants(newVariants)
}}
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"
>
<option value="" disabled>Select Raw Material</option>
{rawMaterials.map(rm => (
<option key={rm.id} value={rm.id}>{rm.name} ({rm.unit})</option>
))}
</select>
</div>
<div className="w-48">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1 mb-1 block">Quantity</label>
<input
type="number"
value={bom.quantity || ''}
onChange={(e) => {
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"
/>
</div>
<div className="pt-5">
<button
onClick={() => {
const newVariants = [...variants]
newVariants[vIdx].bomItems.splice(bIdx, 1)
setVariants(newVariants)
}}
className="p-2.5 text-slate-500 hover:text-rose-500 hover:bg-rose-500/10 rounded-lg transition-colors"
title="Remove Material"
>
<Trash2 className="w-5 h-5" />
</button>
</div>
</div>
))}
</div>
</section>
)
})}
</div>
</div>
)
}
+6 -75
View File
@@ -58,6 +58,7 @@ export default function ProductEditPage() {
const [attributes, setAttributes] = useState<EditableAttribute[]>([]) const [attributes, setAttributes] = useState<EditableAttribute[]>([])
const [selectedDefinitionId, setSelectedDefinitionId] = useState<number>(0) const [selectedDefinitionId, setSelectedDefinitionId] = useState<number>(0)
const [isMediaModalOpen, setIsMediaModalOpen] = useState(false) const [isMediaModalOpen, setIsMediaModalOpen] = useState(false)
const [onSelectMedia, setOnSelectMedia] = useState<((url: string) => void) | null>(null) const [onSelectMedia, setOnSelectMedia] = useState<((url: string) => void) | null>(null)
@@ -383,7 +384,7 @@ export default function ProductEditPage() {
} }
return ( return (
<div className="max-w-5xl mx-auto space-y-8 animate-fade-in"> <div className="w-full space-y-8 animate-fade-in">
{/* Header & Actions */} {/* Header & Actions */}
<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">
@@ -429,9 +430,9 @@ export default function ProductEditPage() {
</div> </div>
)} )}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8"> <div>
{/* Left Column: Identities */} {/* Main Content */}
<div className="lg:col-span-2 space-y-8"> <div className="space-y-8">
{/* Basic Info */} {/* Basic Info */}
<section className="admin-card space-y-6"> <section className="admin-card space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -940,77 +941,6 @@ export default function ProductEditPage() {
)} )}
</section> </section>
</div> </div>
{/* Right Column: Preview/Summary */}
<div className="space-y-8">
<section className="admin-card border-white/5 shadow-2xl overflow-hidden !p-0">
<div className="p-6 bg-white/[0.02] border-b border-white/5 flex items-center justify-between">
<div className="flex items-center gap-2 text-cyan-glow">
<Tag className="w-5 h-5" />
<h2 className="font-bold text-lg">Preview Gallery</h2>
</div>
<div className="px-2 py-1 bg-white/5 rounded-md text-[10px] text-slate-500 font-bold uppercase tracking-widest">
Live
</div>
</div>
<div className="p-6 space-y-6">
{/* Main Image Preview */}
<div className="aspect-square rounded-2xl overflow-hidden bg-night-950 border border-white/5 p-4 flex items-center justify-center relative group">
<img
src={images.find(img => 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=""
/>
<div className="absolute inset-0 bg-gradient-to-t from-night-950/80 to-transparent flex items-end p-6">
<div className="space-y-1">
<p className="text-cyan-glow font-bold text-xs uppercase tracking-widest">{typeof product?.category.name === 'string' ? product.category.name : (product?.category.name as any)?.th || (product?.category.name as any)?.en || ''}</p>
<h3 className="text-xl font-bold text-white">{product?.brand.name}</h3>
</div>
</div>
</div>
{/* Thumbnail Strip Preview */}
{images.length > 0 && (
<div className="grid grid-cols-4 gap-2">
{images.filter(img => img.url).map((img, i) => (
<div key={i} className="aspect-square rounded-lg overflow-hidden border border-white/10 bg-night-950">
<img src={img.url} className={`w-full h-full object-cover ${i === 0 ? 'opacity-100' : 'opacity-40'}`} alt="" />
</div>
))}
</div>
)}
<div className="space-y-4 p-4 bg-white/5 rounded-2xl border border-white/5">
<h4 className="text-[10px] uppercase font-bold tracking-[0.2em] text-slate-500 text-center">Summary</h4>
<div className="space-y-3">
<div className="flex justify-between text-sm">
<span className="text-slate-500">Variants</span>
<span className="text-white font-bold">{variants.length}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-slate-500">Min Price</span>
<span className="text-cyan-glow font-bold">
{variants.length > 0
? formatPrice(Math.min(...variants.map(v => Number(v.price) || 0)))
: '-'}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-slate-500">Images</span>
<span className="text-white font-bold">{images.filter(img => img.url).length}</span>
</div>
</div>
</div>
</div>
</section>
<div className="p-6 rounded-2xl bg-cyan-glow/5 border border-cyan-glow/10 space-y-3">
<p className="text-xs text-cyan-glow/70 leading-relaxed italic">
Tip: The first image in the list will be used as the primary thumbnail across the store.
</p>
</div>
</div>
</div> </div>
<MediaLibraryModal <MediaLibraryModal
@@ -1020,6 +950,7 @@ export default function ProductEditPage() {
if (onSelectMedia) onSelectMedia(url) if (onSelectMedia) onSelectMedia(url)
}} }}
/> />
</div> </div>
) )
} }
+15 -2
View File
@@ -284,7 +284,7 @@ export default function ProductsPage() {
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">Product</th> <th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">Product</th>
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">Category</th> <th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">Category</th>
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">Variants</th> <th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">Variants</th>
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">Base Price</th> <th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">Price</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 text-right">Actions</th>
</tr> </tr>
</thead> </thead>
@@ -335,7 +335,13 @@ export default function ProductsPage() {
</p> </p>
</td> </td>
<td className="px-6 py-4"> <td className="px-6 py-4">
<p className="text-sm font-bold text-white">{formatPrice(product.price)}</p> {(() => {
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 <p className="text-sm font-bold text-white">{priceDisplay}</p>
})()}
</td> </td>
<td className="px-6 py-4 text-right"> <td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
@@ -353,6 +359,13 @@ export default function ProductsPage() {
<button className="p-2 rounded-lg hover:bg-white/5 text-slate-500 hover:text-white transition-all"> <button className="p-2 rounded-lg hover:bg-white/5 text-slate-500 hover:text-white transition-all">
<Eye className="w-4 h-4" /> <Eye className="w-4 h-4" />
</button> </button>
<button
onClick={() => navigate(`/products/${product.id}/bom`)}
title="Manage BOM"
className="p-2 rounded-lg hover:bg-cyan-glow/10 text-slate-500 hover:text-cyan-glow transition-all"
>
<Package className="w-4 h-4" />
</button>
<button <button
onClick={() => navigate(`/products/${product.id}/edit`)} onClick={() => navigate(`/products/${product.id}/edit`)}
className="p-2 rounded-lg hover:bg-white/5 text-slate-500 hover:text-cyan-glow transition-all" className="p-2 rounded-lg hover:bg-white/5 text-slate-500 hover:text-cyan-glow transition-all"
+21
View File
@@ -0,0 +1,21 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
await prisma.product.updateMany({
data: {
price: null,
},
});
console.log('Successfully set all Product prices to null');
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/expect -f
set timeout -1
spawn bunx prisma migrate dev --name remove_product_price
expect {
"We need to reset" { send "y\r"; exp_continue }
"Are you sure you want to create this migration?" { send "y\r"; exp_continue }
"(y/N)" { send "y\r"; exp_continue }
"(y/n)" { send "y\r"; exp_continue }
eof
}
+19
View File
@@ -0,0 +1,19 @@
import pty
import os
import sys
pid, fd = pty.fork()
if pid == 0:
os.execvp("bun", ["bun", "run", "./node_modules/.bin/prisma", "migrate", "dev", "--name", "remove_product_price"])
else:
while True:
try:
output = os.read(fd, 1024)
if not output: break
sys.stdout.write(output.decode('utf-8', 'ignore'))
sys.stdout.flush()
lower_out = output.lower()
if b"(y/n)" in lower_out or b"reset" in lower_out:
os.write(fd, b"y\n")
except OSError:
break
@@ -0,0 +1,8 @@
/*
Warnings:
- You are about to drop the column `price` on the `Product` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "Product" DROP COLUMN "price";
-1
View File
@@ -55,7 +55,6 @@ model CategoryTranslation {
model Product { model Product {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
price Float? @default(0)
categoryId Int categoryId Int
category Category @relation(fields: [categoryId], references: [id]) category Category @relation(fields: [categoryId], references: [id])
name String? name String?
+7
View File
@@ -312,6 +312,13 @@ export const productsRoute = new Elysia({ prefix: "/products" })
option2: v.option2 || "", option2: v.option2 || "",
option3: v.option3 || "", option3: v.option3 || "",
price: v.price || 0, price: v.price || 0,
bomItems: v.bomItems ? {
deleteMany: {},
create: v.bomItems.map((b: any) => ({
rawMaterialId: b.rawMaterialId,
quantity: b.quantity,
}))
} : undefined
} }
}); });
} }