x
This commit is contained in:
1 parent
6a35dcf955
commit
b362f5cadf
10 files changed
+335
-78
No files matched your search
@@ -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() {
|
||||
<Route path="/product-attributes" element={<ProductAttributesPage />} />
|
||||
<Route path="/products/new" element={<ProductEditPage />} />
|
||||
<Route path="/products/:id/edit" element={<ProductEditPage />} />
|
||||
<Route path="/products/:id/bom" element={<ProductBomPage />} />
|
||||
<Route path="/categories" element={<CategoriesPage />} />
|
||||
<Route path="/customers" element={<CustomersPage />} />
|
||||
<Route path="/page-builder" element={<PageBuilderPage />} />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -58,6 +58,7 @@ export default function ProductEditPage() {
|
||||
const [attributes, setAttributes] = useState<EditableAttribute[]>([])
|
||||
const [selectedDefinitionId, setSelectedDefinitionId] = useState<number>(0)
|
||||
|
||||
|
||||
const [isMediaModalOpen, setIsMediaModalOpen] = useState(false)
|
||||
const [onSelectMedia, setOnSelectMedia] = useState<((url: string) => void) | null>(null)
|
||||
|
||||
@@ -383,7 +384,7 @@ export default function ProductEditPage() {
|
||||
}
|
||||
|
||||
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 */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
|
||||
<div className="space-y-1">
|
||||
@@ -429,9 +430,9 @@ export default function ProductEditPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Left Column: Identities */}
|
||||
<div className="lg:col-span-2 space-y-8">
|
||||
<div>
|
||||
{/* Main Content */}
|
||||
<div className="space-y-8">
|
||||
{/* Basic Info */}
|
||||
<section className="admin-card space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -940,77 +941,6 @@ export default function ProductEditPage() {
|
||||
)}
|
||||
</section>
|
||||
</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>
|
||||
|
||||
<MediaLibraryModal
|
||||
@@ -1020,6 +950,7 @@ export default function ProductEditPage() {
|
||||
if (onSelectMedia) onSelectMedia(url)
|
||||
}}
|
||||
/>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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">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">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>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -335,7 +335,13 @@ export default function ProductsPage() {
|
||||
</p>
|
||||
</td>
|
||||
<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 className="px-6 py-4 text-right">
|
||||
<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">
|
||||
<Eye className="w-4 h-4" />
|
||||
</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
|
||||
onClick={() => navigate(`/products/${product.id}/edit`)}
|
||||
className="p-2 rounded-lg hover:bg-white/5 text-slate-500 hover:text-cyan-glow transition-all"
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
Executable
+10
@@ -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
|
||||
}
|
||||
@@ -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";
|
||||
@@ -55,7 +55,6 @@ model CategoryTranslation {
|
||||
|
||||
model Product {
|
||||
id Int @id @default(autoincrement())
|
||||
price Float? @default(0)
|
||||
categoryId Int
|
||||
category Category @relation(fields: [categoryId], references: [id])
|
||||
name String?
|
||||
|
||||
@@ -312,6 +312,13 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
option2: v.option2 || "",
|
||||
option3: v.option3 || "",
|
||||
price: v.price || 0,
|
||||
bomItems: v.bomItems ? {
|
||||
deleteMany: {},
|
||||
create: v.bomItems.map((b: any) => ({
|
||||
rawMaterialId: b.rawMaterialId,
|
||||
quantity: b.quantity,
|
||||
}))
|
||||
} : undefined
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user