diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 5b1ef7c..9e631e9 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -22,6 +22,7 @@ model StockItem { bulk1Price Float @default(0) bulk2Threshold Float @default(0) bulk2Price Float @default(0) + maxStock Float? // null = unlimited } model Order { @@ -42,6 +43,4 @@ model Config { finalDepositDate String? cookDay String? payIdInfo String? @default("Your PayID Here") - maxTotalWeight Float? @default(100.0) - maxItemWeight Float? @default(50.0) } diff --git a/backend/src/server.ts b/backend/src/server.ts index 50d5ff0..98767d7 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -16,7 +16,7 @@ app.use(express.json()); // Fetch all data (Stock, Orders, Config) app.get('/api/data', async (req, res) => { try { - const stock = await prisma.stockItem.findMany(); + let stock = await prisma.stockItem.findMany(); const orders = await prisma.order.findMany(); const config = await prisma.config.findFirst(); @@ -26,7 +26,41 @@ app.get('/api/data', async (req, res) => { items: JSON.parse(order.items) })); - res.json({ stock, orders: parsedOrders, config }); + // Tally total ordered kg per stock item across all orders + const orderedQtyById: Record = {}; + for (const order of parsedOrders) { + for (const item of order.items) { + orderedQtyById[item.id] = (orderedQtyById[item.id] || 0) + (item.qty || 0); + } + } + + // Auto-deactivate items that have hit their maxStock limit + for (const item of stock) { + if (item.maxStock !== null && item.maxStock !== undefined) { + const ordered = orderedQtyById[item.id] || 0; + const shouldBeAvailable = ordered < item.maxStock; + if (item.available !== shouldBeAvailable) { + await prisma.stockItem.update({ + where: { id: item.id }, + data: { available: shouldBeAvailable } + }); + } + } + } + + // Re-fetch stock after potential auto-updates + stock = await prisma.stockItem.findMany(); + + // Attach remaining stock info to each item + const stockWithRemaining = stock.map(item => ({ + ...item, + orderedQty: orderedQtyById[item.id] || 0, + remainingStock: item.maxStock !== null && item.maxStock !== undefined + ? Math.max(0, item.maxStock - (orderedQtyById[item.id] || 0)) + : null + })); + + res.json({ stock: stockWithRemaining, orders: parsedOrders, config }); } catch (error) { console.error(error); res.status(500).json({ error: 'Failed to fetch data' }); @@ -138,15 +172,13 @@ app.delete('/api/stock/:id', async (req, res) => { // Update Config app.post('/api/config', async (req, res) => { try { - const { adminCode, finalDepositDate, cookDay, payIdInfo, maxTotalWeight, maxItemWeight } = req.body; + const { adminCode, finalDepositDate, cookDay, payIdInfo } = req.body; const config = await prisma.config.findFirst(); const data: any = {}; if (adminCode !== undefined) data.adminCode = adminCode; if (finalDepositDate !== undefined) data.finalDepositDate = finalDepositDate; if (cookDay !== undefined) data.cookDay = cookDay; if (payIdInfo !== undefined) data.payIdInfo = payIdInfo; - if (maxTotalWeight !== undefined) data.maxTotalWeight = maxTotalWeight !== null ? parseFloat(maxTotalWeight) : null; - if (maxItemWeight !== undefined) data.maxItemWeight = maxItemWeight !== null ? parseFloat(maxItemWeight) : null; if (config) { await prisma.config.update({ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 786125a..4cb8122 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,6 +4,7 @@ import { Icons } from './components/Icons'; import CustomerView from './components/CustomerView'; import AdminDashboard from './components/AdminDashboard'; import CartModal from './components/CartModal'; +import QuantityPicker from './components/QuantityPicker'; function App() { const [view, setView] = useState<'customer' | 'admin'>('customer'); @@ -18,6 +19,7 @@ function App() { const [adminCode, setAdminCode] = useState('1234'); const [config, setConfig] = useState(null); const [confirmModal, setConfirmModal] = useState<{ show: boolean, message: string, onConfirm: (() => void) | null }>({ show: false, message: '', onConfirm: null }); + const [pickerItem, setPickerItem] = useState(null); useEffect(() => { refreshData(); @@ -56,81 +58,25 @@ function App() { }); }; - const addToCart = (item: any) => { - const maxItem = config?.maxItemWeight ?? 50; - const maxTotal = config?.maxTotalWeight ?? 100; - - let isAllowed = true; - let errorMsg = ''; - + const addToCart = (item: any, qty: number = 1) => { setCart(prev => { const existing = prev.find(i => i.id === item.id); - const currentQty = existing ? existing.qty : 0; - const targetItemQty = currentQty + 1; - const currentTotalWeight = prev.reduce((acc, i) => acc + i.qty, 0); - const targetTotalWeight = currentTotalWeight + 1; - - if (targetItemQty > maxItem) { - isAllowed = false; - errorMsg = `Limit reached: Max ${maxItem}${item.unit} per item.`; - return prev; - } - - if (targetTotalWeight > maxTotal) { - isAllowed = false; - errorMsg = `Limit reached: Max ${maxTotal}kg total order weight.`; - return prev; - } - if (existing) { - return prev.map(i => i.id === item.id ? { ...i, qty: i.qty + 1 } : i); + return prev.map(i => i.id === item.id ? { ...i, qty: i.qty + qty } : i); } - return [...prev, { ...item, qty: 1 }]; + return [...prev, { ...item, qty }]; }); - - if (!isAllowed) { - showNotification(errorMsg); - } else { - showNotification(`Added 1${item.unit} ${item.name}`); - } + showNotification(`Added ${qty}${item.unit} ${item.name}`); }; const updateCartQty = (itemId: number, delta: number) => { - const maxItem = config?.maxItemWeight ?? 50; - const maxTotal = config?.maxTotalWeight ?? 100; - - let isAllowed = true; - let errorMsg = ''; - - setCart(prev => { - const existing = prev.find(i => i.id === itemId); - if (!existing) return prev; - - const newQty = existing.qty + delta; - if (newQty <= 0) return prev; - - if (delta > 0) { - if (newQty > maxItem) { - isAllowed = false; - errorMsg = `Limit reached: Max ${maxItem}${existing.unit} per item.`; - return prev; - } - - const currentTotalWeight = prev.reduce((acc, i) => acc + i.qty, 0); - const targetTotalWeight = currentTotalWeight + delta; - if (targetTotalWeight > maxTotal) { - isAllowed = false; - errorMsg = `Limit reached: Max ${maxTotal}kg total order weight.`; - return prev; - } + setCart(prev => prev.map(i => { + if (i.id === itemId) { + const newQty = i.qty + delta; + return newQty > 0 ? { ...i, qty: newQty } : i; } - - return prev.map(i => i.id === itemId ? { ...i, qty: newQty } : i); - }); - - if (!isAllowed) { - showNotification(errorMsg); - } + return i; + })); }; const removeFromCart = (itemId: number) => { @@ -214,11 +160,13 @@ function App() {
{notification &&
{notification}
} - {view === 'customer' ? : } + {view === 'customer' ? setPickerItem(item)} config={config} /> : }
{showCartModal && setShowCartModal(false)} onRemove={removeFromCart} onUpdateQty={updateCartQty} onSubmit={submitOrder} />} + {pickerItem && setPickerItem(null)} />} + {showAdminLogin && (
diff --git a/frontend/src/components/AdminDashboard.tsx b/frontend/src/components/AdminDashboard.tsx index 93f314f..26e81c5 100644 --- a/frontend/src/components/AdminDashboard.tsx +++ b/frontend/src/components/AdminDashboard.tsx @@ -12,9 +12,7 @@ export default function AdminDashboard({ stock, orders, config, refreshData, not setSettingsForm({ finalDepositDate: config.finalDepositDate || '', cookDay: config.cookDay || '', - payIdInfo: config.payIdInfo || '', - maxTotalWeight: config.maxTotalWeight !== undefined && config.maxTotalWeight !== null ? config.maxTotalWeight : 100, - maxItemWeight: config.maxItemWeight !== undefined && config.maxItemWeight !== null ? config.maxItemWeight : 50 + payIdInfo: config.payIdInfo || '' }); } }, [config, settingsForm]); @@ -230,28 +228,6 @@ export default function AdminDashboard({ stock, orders, config, refreshData, not />
-

Order Limits

-
-
- - setSettingsForm({ ...settingsForm, maxTotalWeight: parseFloat(e.target.value) || 0 })} - className="w-full bg-stone-900 border border-stone-700 rounded p-3 text-white focus:border-red-600 outline-none" - /> -
-
- - setSettingsForm({ ...settingsForm, maxItemWeight: parseFloat(e.target.value) || 0 })} - className="w-full bg-stone-900 border border-stone-700 rounded p-3 text-white focus:border-red-600 outline-none" - /> -
-
-
) : (
@@ -355,6 +339,8 @@ function AdminStockItem({ item, onUpdate, onDelete }: any) { setEditData({ ...editData, price: e.target.value })} className="w-full bg-black text-white border border-stone-700 rounded px-2 py-1 text-sm" /> setEditData({ ...editData, unit: e.target.value })} className="w-full bg-black text-white border border-stone-700 rounded px-2 py-1 text-sm" /> + + setEditData({ ...editData, maxStock: e.target.value })} placeholder="Unlimited" className="w-full bg-black text-white border border-stone-700 rounded px-2 py-1 text-sm" />

Tier 1 Bulk

diff --git a/frontend/src/components/CartModal.tsx b/frontend/src/components/CartModal.tsx index fba1108..67857f2 100644 --- a/frontend/src/components/CartModal.tsx +++ b/frontend/src/components/CartModal.tsx @@ -167,11 +167,6 @@ export default function CartModal({ cart, onClose, onRemove, onUpdateQty, onSubm

Deposit Required

Order will not be cooked until the 30% deposit is paid. We accept PayID and Cash.

- {(config?.maxTotalWeight || config?.maxItemWeight) && ( -

- Limits: Max {config.maxTotalWeight ?? 100}kg total weight | Max {config.maxItemWeight ?? 50}kg per item. -

- )}
diff --git a/frontend/src/components/CustomerView.tsx b/frontend/src/components/CustomerView.tsx index 6b64e3a..58f4711 100644 --- a/frontend/src/components/CustomerView.tsx +++ b/frontend/src/components/CustomerView.tsx @@ -14,15 +14,27 @@ interface StockItem { bulk2Price: number; } -function StockCard({ item, onAdd, disabled }: { item: StockItem, onAdd?: () => void, disabled?: boolean }) { +function StockCard({ item, onAdd, disabled }: { item: any, onAdd?: () => void, disabled?: boolean }) { const hasTier1 = item.bulk1Threshold > 0 && item.bulk1Price > 0; const hasTier2 = item.bulk2Threshold > 0 && item.bulk2Price > 0; + const hasLimit = item.maxStock !== null && item.maxStock !== undefined; + const remaining = item.remainingStock; + const stockPct = hasLimit ? Math.max(0, (remaining / item.maxStock) * 100) : 100; return (
{item.category} + {hasLimit && ( + + {remaining}{item.unit} left + + )}

{item.name}

@@ -45,6 +57,21 @@ function StockCard({ item, onAdd, disabled }: { item: StockItem, onAdd?: () => v

{item.description}

+ + {hasLimit && ( +
+
+
+
+
+ )}
@@ -64,7 +91,7 @@ function StockCard({ item, onAdd, disabled }: { item: StockItem, onAdd?: () => v ); } -export default function CustomerView({ stock, addToCart, config }: { stock: StockItem[], addToCart: (item: StockItem) => void, config?: any }) { +export default function CustomerView({ stock, onAddToCart, config }: { stock: any[], onAddToCart: (item: any) => void, config?: any }) { const availableStock = stock.filter(item => item.available); const soldOutStock = stock.filter(item => !item.available); @@ -126,8 +153,8 @@ export default function CustomerView({ stock, addToCart, config }: { stock: Stoc

In The Smoker

- {availableStock.map(item => ( - addToCart(item)} /> + {availableStock.map(item => ( + onAddToCart(item)} /> ))} {availableStock.length === 0 &&

Nothing in the smoker right now.

}
diff --git a/frontend/src/components/QuantityPicker.tsx b/frontend/src/components/QuantityPicker.tsx new file mode 100644 index 0000000..8e2191d --- /dev/null +++ b/frontend/src/components/QuantityPicker.tsx @@ -0,0 +1,146 @@ +import { useState } from 'react'; +import { Icons } from './Icons'; + +interface QuantityPickerProps { + item: any; + onConfirm: (item: any, qty: number) => void; + onClose: () => void; +} + +export default function QuantityPicker({ item, onConfirm, onClose }: QuantityPickerProps) { + const remaining = item.remainingStock !== null && item.remainingStock !== undefined + ? item.remainingStock + : null; + const max = remaining !== null ? remaining : 999; + + const [qty, setQty] = useState(1); + + const getPrice = (q: number) => { + if (item.bulk2Threshold > 0 && q >= item.bulk2Threshold && item.bulk2Price > 0) return item.bulk2Price; + if (item.bulk1Threshold > 0 && q >= item.bulk1Threshold && item.bulk1Price > 0) return item.bulk1Price; + return item.price; + }; + + const unitPrice = getPrice(qty); + const lineTotal = unitPrice * qty; + + const increment = () => setQty(q => Math.min(q + 1, max)); + const decrement = () => setQty(q => Math.max(q - 1, 1)); + + const handleConfirm = () => { + onConfirm(item, qty); + onClose(); + }; + + return ( +
e.target === e.currentTarget && onClose()} + > + {/* Backdrop */} +
+ + {/* Modal */} +
+ + {/* Header stripe */} +
+ +
+ {/* Close */} + + + {/* Item info */} +
+ + {item.category} + +

{item.name}

+ + {remaining !== null && ( +
+
+
+
+ + {remaining}{item.unit} left + +
+ )} +
+ + {/* Quantity Selector */} +
+

Select Quantity

+
+ + +
+ { + const v = parseInt(e.target.value) || 1; + setQty(Math.min(Math.max(v, 1), max)); + }} + className="w-20 text-center text-3xl font-black text-white bg-transparent border-b-2 border-red-600 outline-none pb-1 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" + /> +

{item.unit}

+
+ + +
+
+ + {/* Pricing */} +
+
+ Unit price + + ${unitPrice.toFixed(2)}/{item.unit} + {unitPrice < item.price && ( + Bulk rate! + )} + +
+
+ Line total + ${lineTotal.toFixed(2)} +
+
+ + {/* Confirm */} + +
+
+
+ ); +}