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 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 />} />
|
||||||
|
|||||||
@@ -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 [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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 {
|
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?
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user