From f27bd572e4a9e98236f12e7d699212a9a14b6dee Mon Sep 17 00:00:00 2001
From: Gemini CLI
Date: Mon, 27 Jul 2026 16:26:57 +1000
Subject: [PATCH] feat: add safe fresh batch inventory reset
---
.gitignore | 1 +
Dockerfile | 2 +-
backend/src/server.ts | 391 ++++++++++++---------
docker-compose.yml | 4 +-
frontend/src/App.tsx | 38 +-
frontend/src/components/AdminDashboard.tsx | 108 +++---
frontend/src/components/CartModal.tsx | 2 +
frontend/src/components/CustomerView.tsx | 4 +-
frontend/src/services/api.ts | 97 +++--
9 files changed, 400 insertions(+), 247 deletions(-)
diff --git a/.gitignore b/.gitignore
index d23ae0c..e92674c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,4 @@ backend/prisma/dev.db
frontend/dist/
backend/dist/
.DS_Store
+.serena/
diff --git a/Dockerfile b/Dockerfile
index 9f6ad9e..265b5dc 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -41,4 +41,4 @@ RUN mkdir -p /app/data
EXPOSE 3000
# Start script
-CMD ["sh", "-c", "npx prisma db push --accept-data-loss && node dist/server.js"]
+CMD ["sh", "-c", "npx prisma db push && node dist/server.js"]
diff --git a/backend/src/server.ts b/backend/src/server.ts
index 3f6335b..362f01a 100644
--- a/backend/src/server.ts
+++ b/backend/src/server.ts
@@ -11,15 +11,44 @@ const PORT = process.env.PORT || 3000;
app.use(cors());
app.use(express.json());
+const requireAdmin: express.RequestHandler = async (req, res, next) => {
+ try {
+ const adminCode = req.get('x-admin-code');
+ const config = await prisma.config.findUnique({ where: { id: 1 } });
+
+ if (!adminCode || !config || adminCode !== config.adminCode) {
+ res.status(401).json({ error: 'Admin authentication required' });
+ return;
+ }
+
+ next();
+ } catch (error) {
+ console.error('Admin authentication failed:', error);
+ res.status(500).json({ error: 'Failed to authenticate admin' });
+ }
+};
+
+const parseId = (value: string | string[] | undefined) => {
+ const id = Array.isArray(value) ? NaN : Number(value);
+ return Number.isInteger(id) && id > 0 ? id : null;
+};
+
// API Routes
// Fetch all data (Stock, Orders, Config, FAQs)
app.get('/api/data', async (req, res) => {
try {
- let stock = await prisma.stockItem.findMany();
+ const stock = await prisma.stockItem.findMany();
const orders = await prisma.order.findMany();
- const config = await prisma.config.findFirst();
+ const config = await prisma.config.findUnique({ where: { id: 1 } });
const faqs = await prisma.fAQ.findMany({ orderBy: { order: 'asc' } });
+
+ const suppliedAdminCode = req.get('x-admin-code');
+ const isAdmin = Boolean(suppliedAdminCode && config && suppliedAdminCode === config.adminCode);
+ if (suppliedAdminCode && !isAdmin) {
+ res.status(401).json({ error: 'Admin session expired' });
+ return;
+ }
// Parse order items from string to JSON
const parsedOrders = orders.map(order => ({
@@ -30,42 +59,40 @@ app.get('/api/data', async (req, res) => {
// Tally total ordered kg per stock item across all orders
const orderedQtyById: Record = {};
for (const order of parsedOrders) {
+ if (order.status === 'Cancelled') continue;
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 isOutOfStock = ordered >= item.maxStock;
- // Only auto-disable if it's currently marked as available but is actually out of stock
- if (isOutOfStock && item.available) {
- await prisma.stockItem.update({
- where: { id: item.id },
- data: { available: false }
- });
- }
- }
- }
+ // Attach computed batch availability without overwriting the admin's availability setting
+ const stockWithRemaining = stock.map(item => {
+ const orderedQty = orderedQtyById[item.id] || 0;
+ const remainingStock = item.maxStock !== null
+ ? Math.max(0, item.maxStock - orderedQty)
+ : null;
- // 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
- }));
+ return {
+ ...item,
+ available: item.available && (remainingStock === null || remainingStock > 0),
+ configuredAvailable: item.available,
+ orderedQty,
+ remainingStock
+ };
+ });
// Security: Don't send the admin code to the client
- const configResponse = config ? { ...config, adminCode: undefined } : null;
+ const configResponse = config ? {
+ id: config.id,
+ finalDepositDate: config.finalDepositDate,
+ cookDay: config.cookDay,
+ payIdInfo: config.payIdInfo,
+ termsOfService: config.termsOfService,
+ orderingPolicy: config.orderingPolicy,
+ depositPercentage: config.depositPercentage
+ } : null;
- res.json({ stock: stockWithRemaining, orders: parsedOrders, config: configResponse, faqs });
+ res.json({ stock: stockWithRemaining, orders: isAdmin ? parsedOrders : [], config: configResponse, faqs });
} catch (error) {
console.error(error);
res.status(500).json({ error: 'Failed to fetch data' });
@@ -76,13 +103,10 @@ app.get('/api/data', async (req, res) => {
app.post('/api/login', async (req, res) => {
try {
const { code } = req.body;
- console.log(`Login attempt with code: ${code}`);
- const config = await prisma.config.findFirst();
+ const config = await prisma.config.findUnique({ where: { id: 1 } });
if (config && config.adminCode === code) {
- console.log('Login successful');
res.json({ success: true });
} else {
- console.log(`Login failed. Expected: ${config?.adminCode}, Got: ${code}`);
res.status(401).json({ error: 'Invalid admin code' });
}
} catch (error) {
@@ -94,67 +118,97 @@ app.post('/api/login', async (req, res) => {
// Submit a new order
app.post('/api/orders', async (req, res) => {
try {
- const { name, phone, items, totalWeight, estimatedTotal } = req.body;
-
- // 1. Validate Stock Availability
- const stockItems = await prisma.stockItem.findMany();
- const allOrders = await prisma.order.findMany();
-
- const orderedQtyById: Record = {};
- for (const order of allOrders) {
- const parsedItems = JSON.parse(order.items);
- for (const item of parsedItems) {
- orderedQtyById[item.id] = (orderedQtyById[item.id] || 0) + (item.qty || 0);
- }
+ const { name, phone, items } = req.body;
+
+ if (typeof name !== 'string' || !name.trim() || typeof phone !== 'string' || !phone.trim()) {
+ throw new Error('Name and phone are required');
+ }
+ if (!Array.isArray(items) || items.length === 0) {
+ throw new Error('At least one item is required');
}
- let computedTotalWeight = 0;
- let computedEstimatedTotal = 0;
+ const updatedOrder = await prisma.$transaction(async (tx) => {
+ const stockItems = await tx.stockItem.findMany();
+ const allOrders = await tx.order.findMany({
+ where: { status: { not: 'Cancelled' } },
+ select: { items: true }
+ });
+ const orderedQtyById: Record = {};
- for (const item of items) {
- const dbItem = stockItems.find(s => s.id === item.id);
- if (!dbItem) throw new Error(`Item ${item.name} not found`);
- if (!dbItem.available) throw new Error(`Sorry, ${dbItem.name} has just sold out!`);
-
- if (dbItem.maxStock !== null && dbItem.maxStock !== undefined) {
- const alreadyOrdered = orderedQtyById[dbItem.id] || 0;
- if (alreadyOrdered + item.qty > dbItem.maxStock) {
- throw new Error(`Not enough stock for ${dbItem.name}. Only ${Math.max(0, dbItem.maxStock - alreadyOrdered)}${dbItem.unit} remaining.`);
+ for (const order of allOrders) {
+ const parsedItems = JSON.parse(order.items);
+ for (const item of parsedItems) {
+ orderedQtyById[item.id] = (orderedQtyById[item.id] || 0) + (item.qty || 0);
}
}
- // Recalculate prices server-side
- const getPrice = (stockItem: any, qty: number) => {
- if (stockItem.bulk2Threshold > 0 && qty >= stockItem.bulk2Threshold && stockItem.bulk2Price > 0) return stockItem.bulk2Price;
- if (stockItem.bulk1Threshold > 0 && qty >= stockItem.bulk1Threshold && stockItem.bulk1Price > 0) return stockItem.bulk1Price;
- return stockItem.price;
- };
+ const seenItemIds = new Set();
+ const validatedItems: Array<{
+ id: number;
+ name: string;
+ qty: number;
+ unit: string;
+ finalPricePerUnit: number;
+ lineTotal: number;
+ }> = [];
+ let computedTotalWeight = 0;
+ let computedEstimatedTotal = 0;
- const finalPrice = getPrice(dbItem, item.qty);
- item.finalPricePerUnit = finalPrice;
- item.lineTotal = finalPrice * item.qty;
-
- computedTotalWeight += item.qty;
- computedEstimatedTotal += item.lineTotal;
- }
+ for (const item of items) {
+ if (!Number.isInteger(item?.id) || typeof item?.qty !== 'number' || !Number.isFinite(item.qty) || item.qty <= 0) {
+ throw new Error('Each order item must have a valid item ID and quantity greater than zero');
+ }
+ if (seenItemIds.has(item.id)) {
+ throw new Error('Duplicate items are not allowed');
+ }
+ seenItemIds.add(item.id);
- // 2. Create the order
- const order = await prisma.order.create({
- data: {
- name,
- phone,
- totalWeight: computedTotalWeight,
- estimatedTotal: computedEstimatedTotal,
- items: JSON.stringify(items),
- status: 'Pending'
+ const dbItem = stockItems.find(stockItem => stockItem.id === item.id);
+ if (!dbItem) throw new Error('An item in this order no longer exists');
+ if (!dbItem.available) throw new Error(`Sorry, ${dbItem.name} has just sold out!`);
+
+ if (dbItem.maxStock !== null) {
+ const alreadyOrdered = orderedQtyById[dbItem.id] || 0;
+ if (alreadyOrdered + item.qty > dbItem.maxStock) {
+ throw new Error(`Not enough stock for ${dbItem.name}. Only ${Math.max(0, dbItem.maxStock - alreadyOrdered)}${dbItem.unit} remaining.`);
+ }
+ }
+
+ let finalPrice = dbItem.price;
+ if (dbItem.bulk2Threshold > 0 && item.qty >= dbItem.bulk2Threshold && dbItem.bulk2Price > 0) {
+ finalPrice = dbItem.bulk2Price;
+ } else if (dbItem.bulk1Threshold > 0 && item.qty >= dbItem.bulk1Threshold && dbItem.bulk1Price > 0) {
+ finalPrice = dbItem.bulk1Price;
+ }
+
+ const lineTotal = finalPrice * item.qty;
+ validatedItems.push({
+ id: dbItem.id,
+ name: dbItem.name,
+ qty: item.qty,
+ unit: dbItem.unit,
+ finalPricePerUnit: finalPrice,
+ lineTotal
+ });
+ computedTotalWeight += item.qty;
+ computedEstimatedTotal += lineTotal;
}
- });
- // 3. Generate and update order number based on ID (to avoid race conditions)
- const orderNumber = `#${order.id + 1000}`;
- const updatedOrder = await prisma.order.update({
- where: { id: order.id },
- data: { orderNumber }
+ const order = await tx.order.create({
+ data: {
+ name: name.trim(),
+ phone: phone.trim(),
+ totalWeight: computedTotalWeight,
+ estimatedTotal: computedEstimatedTotal,
+ items: JSON.stringify(validatedItems),
+ status: 'Pending'
+ }
+ });
+
+ return tx.order.update({
+ where: { id: order.id },
+ data: { orderNumber: `#${order.id + 1000}` }
+ });
});
res.json(updatedOrder);
@@ -165,12 +219,22 @@ app.post('/api/orders', async (req, res) => {
});
// Update Order Status
-app.patch('/api/orders/:id/status', async (req, res) => {
+app.patch('/api/orders/:id/status', requireAdmin, async (req, res) => {
try {
const { id } = req.params;
const { status } = req.body;
+ const orderId = parseId(id);
+ const allowedStatuses = ['Pending', 'Deposit Paid', 'Paid', 'Cooked', 'Completed', 'Cancelled'];
+ if (orderId === null) {
+ res.status(400).json({ error: 'Invalid order ID' });
+ return;
+ }
+ if (!allowedStatuses.includes(status)) {
+ res.status(400).json({ error: 'Invalid order status' });
+ return;
+ }
const order = await prisma.order.update({
- where: { id: parseInt(id) },
+ where: { id: orderId },
data: { status }
});
res.json(order);
@@ -181,10 +245,15 @@ app.patch('/api/orders/:id/status', async (req, res) => {
});
// Delete an order
-app.delete('/api/orders/:id', async (req, res) => {
+app.delete('/api/orders/:id', requireAdmin, async (req, res) => {
try {
const { id } = req.params;
- await prisma.order.delete({ where: { id: parseInt(id) } });
+ const orderId = parseId(id);
+ if (orderId === null) {
+ res.status(400).json({ error: 'Invalid order ID' });
+ return;
+ }
+ await prisma.order.delete({ where: { id: orderId } });
res.sendStatus(200);
} catch (error) {
console.error(error);
@@ -192,21 +261,34 @@ app.delete('/api/orders/:id', async (req, res) => {
}
});
-// Clear all orders
-app.delete('/api/orders', async (req, res) => {
+// Start a fresh cooking batch while preserving the menu and site settings
+app.post('/api/batches/reset', requireAdmin, async (req, res) => {
try {
- await prisma.order.deleteMany();
- res.sendStatus(200);
+ const [clearedOrders, reactivatedItems] = await prisma.$transaction([
+ prisma.order.deleteMany(),
+ prisma.stockItem.updateMany({
+ where: { available: false },
+ data: { available: true }
+ })
+ ]);
+
+ res.json({
+ clearedOrders: clearedOrders.count,
+ reactivatedItems: reactivatedItems.count
+ });
} catch (error) {
console.error(error);
- res.status(500).json({ error: 'Failed to clear orders' });
+ res.status(500).json({ error: 'Failed to start a new cooking batch' });
}
});
// Update or Create Stock Item
-app.post('/api/stock', async (req, res) => {
+app.post('/api/stock', requireAdmin, async (req, res) => {
try {
- const { orderedQty, remainingStock, ...item } = req.body; // strip virtual fields
+ const { orderedQty, remainingStock, configuredAvailable, ...item } = req.body; // strip virtual fields
+ if (typeof configuredAvailable === 'boolean') {
+ item.available = configuredAvailable;
+ }
if (item.id) {
const updated = await prisma.stockItem.update({
where: { id: item.id },
@@ -227,10 +309,15 @@ app.post('/api/stock', async (req, res) => {
});
// Delete Stock Item
-app.delete('/api/stock/:id', async (req, res) => {
+app.delete('/api/stock/:id', requireAdmin, async (req, res) => {
try {
const { id } = req.params;
- await prisma.stockItem.delete({ where: { id: parseInt(id) } });
+ const stockItemId = parseId(id);
+ if (stockItemId === null) {
+ res.status(400).json({ error: 'Invalid stock item ID' });
+ return;
+ }
+ await prisma.stockItem.delete({ where: { id: stockItemId } });
res.sendStatus(200);
} catch (error) {
console.error(error);
@@ -239,17 +326,34 @@ app.delete('/api/stock/:id', async (req, res) => {
});
// Update Config
-app.post('/api/config', async (req, res) => {
+app.post('/api/config', requireAdmin, async (req, res) => {
try {
const { adminCode, finalDepositDate, cookDay, payIdInfo, termsOfService, orderingPolicy, depositPercentage } = req.body;
const data: any = {};
- if (adminCode !== undefined) data.adminCode = adminCode;
+ if (adminCode !== undefined) {
+ if (typeof adminCode !== 'string' || adminCode.length < 4) {
+ res.status(400).json({ error: 'Admin code must be at least 4 characters' });
+ return;
+ }
+ data.adminCode = adminCode;
+ }
if (finalDepositDate !== undefined) data.finalDepositDate = finalDepositDate;
if (cookDay !== undefined) data.cookDay = cookDay;
if (payIdInfo !== undefined) data.payIdInfo = payIdInfo;
if (termsOfService !== undefined) data.termsOfService = termsOfService;
if (orderingPolicy !== undefined) data.orderingPolicy = orderingPolicy;
- if (depositPercentage !== undefined) data.depositPercentage = parseInt(depositPercentage);
+ if (depositPercentage !== undefined) {
+ if (depositPercentage === '') {
+ res.status(400).json({ error: 'Deposit percentage is required' });
+ return;
+ }
+ const parsedDepositPercentage = Number(depositPercentage);
+ if (!Number.isInteger(parsedDepositPercentage) || parsedDepositPercentage < 0 || parsedDepositPercentage > 100) {
+ res.status(400).json({ error: 'Deposit percentage must be a whole number from 0 to 100' });
+ return;
+ }
+ data.depositPercentage = parsedDepositPercentage;
+ }
// Use upsert to ensure we always use ID 1
await prisma.config.upsert({
@@ -266,7 +370,7 @@ app.post('/api/config', async (req, res) => {
});
// Update or Create FAQ Item
-app.post('/api/faqs', async (req, res) => {
+app.post('/api/faqs', requireAdmin, async (req, res) => {
try {
const item = req.body;
if (item.id) {
@@ -289,10 +393,15 @@ app.post('/api/faqs', async (req, res) => {
});
// Delete FAQ Item
-app.delete('/api/faqs/:id', async (req, res) => {
+app.delete('/api/faqs/:id', requireAdmin, async (req, res) => {
try {
const { id } = req.params;
- await prisma.fAQ.delete({ where: { id: parseInt(id) } });
+ const faqId = parseId(id);
+ if (faqId === null) {
+ res.status(400).json({ error: 'Invalid FAQ ID' });
+ return;
+ }
+ await prisma.fAQ.delete({ where: { id: faqId } });
res.sendStatus(200);
} catch (error) {
console.error(error);
@@ -300,49 +409,6 @@ app.delete('/api/faqs/:id', async (req, res) => {
}
});
-// Reset Database to defaults
-app.post('/api/reset', async (req, res) => {
- try {
- await prisma.order.deleteMany();
- await prisma.stockItem.deleteMany();
- await prisma.config.deleteMany();
- await prisma.fAQ.deleteMany();
-
- // Seed default admin code and limits with ID 1
- await prisma.config.create({
- data: {
- id: 1,
- adminCode: '1234',
- payIdInfo: 'Your PayID Here',
- termsOfService: 'To secure your order, a 30% non-refundable deposit is required upfront before smoking begins. Payments can be made via PayID or Cash. The remaining balance is payable upon pickup.',
- orderingPolicy: 'Deposits cover material costs and are final. PayID details provided after ordering.',
- depositPercentage: 30
- }
- });
-
- // Seed some default stock items
- await prisma.stockItem.createMany({
- data: [
- { name: 'Beef Brisket', category: 'Beef', price: 45, unit: 'kg', description: 'Low and slow smoked brisket', available: true },
- { name: 'Pork Shoulder', category: 'Pork', price: 35, unit: 'kg', description: 'Pulled pork perfection', available: true }
- ]
- });
-
- // Seed some default FAQs
- await prisma.fAQ.createMany({
- data: [
- { question: 'When is pickup?', answer: 'Pickups are usually scheduled for the afternoon of the Cook Day. We will contact you to confirm the exact time.', order: 1 },
- { question: 'How do I pay the deposit?', answer: 'Once you submit your order, you can pay the 30% deposit via PayID or Cash.', order: 2 }
- ]
- });
-
- res.sendStatus(200);
- } catch (error) {
- console.error(error);
- res.status(500).json({ error: 'Failed to reset database' });
- }
-});
-
// Serve Static Files in Production
const frontendPath = fs.existsSync(path.join(__dirname, '../frontend-dist'))
? path.join(__dirname, '../frontend-dist')
@@ -354,18 +420,23 @@ app.get(/^\/(?!api).*/, (req, res) => {
res.sendFile(path.join(frontendPath, 'index.html'));
});
-app.listen(PORT, async () => {
- // Ensure a default config exists
- const config = await prisma.config.findFirst();
- if (!config) {
- await prisma.config.create({
- data: {
- id: 1,
- adminCode: '1234',
- payIdInfo: 'Your PayID Here'
- }
- });
- console.log('Default config seeded');
- }
- console.log(`Server is running on http://localhost:${PORT}`);
+const startServer = async () => {
+ await prisma.config.upsert({
+ where: { id: 1 },
+ update: {},
+ create: {
+ id: 1,
+ adminCode: '1234',
+ payIdInfo: 'Your PayID Here'
+ }
+ });
+
+ app.listen(PORT, () => {
+ console.log(`Server is running on http://localhost:${PORT}`);
+ });
+};
+
+startServer().catch((error) => {
+ console.error('Failed to start server:', error);
+ process.exit(1);
});
diff --git a/docker-compose.yml b/docker-compose.yml
index 2a7972d..2b44af2 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -4,9 +4,9 @@ services:
ports:
- "3000:3000"
volumes:
- - meat-data:/app/backend/prisma
+ - meat-data:/app/data
environment:
- - DATABASE_URL=file:./dev.db
+ - DATABASE_URL=file:/app/data/dev.db
restart: always
volumes:
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 8a320f5..92c1fbb 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
-import { API } from './services/api';
+import { API, ApiError } from './services/api';
import { Icons } from './components/Icons';
import CustomerView from './components/CustomerView';
import AdminDashboard from './components/AdminDashboard';
@@ -21,17 +21,28 @@ function App() {
const [confirmModal, setConfirmModal] = useState<{ show: boolean, message: string, onConfirm: (() => void) | null }>({ show: false, message: '', onConfirm: null });
const [pickerItem, setPickerItem] = useState(null);
+ const showNotification = (msg: string) => {
+ setNotification(msg);
+ setTimeout(() => setNotification(null), 3000);
+ };
+
const refreshData = async () => {
try {
const data = await API.fetchData();
- setStock(data.stock);
- setOrders(data.orders.sort((a: any, b: any) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()));
+ setStock(data.stock || []);
+ setOrders((data.orders || []).sort((a: any, b: any) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()));
setFaqs(data.faqs || []);
if (data.config) {
setConfig(data.config);
}
} catch (err) {
console.error("Server connection failed.", err);
+ if (err instanceof ApiError && err.status === 401) {
+ API.logout();
+ setOrders([]);
+ setView('customer');
+ showNotification('Admin session expired. Please log in again.');
+ }
}
};
@@ -49,18 +60,18 @@ function App() {
clearInterval(intervalId);
};
}, []);
- const showNotification = (msg: string) => {
- setNotification(msg);
- setTimeout(() => setNotification(null), 3000);
- };
-
- const promptConfirm = (message: string, action: () => void) => {
+ const promptConfirm = (message: string, action: () => void | Promise) => {
setConfirmModal({
show: true,
message,
onConfirm: async () => {
- await action();
- setConfirmModal({ show: false, message: '', onConfirm: null });
+ try {
+ await action();
+ } catch (error) {
+ showNotification(error instanceof Error ? error.message : 'Action failed');
+ } finally {
+ setConfirmModal({ show: false, message: '', onConfirm: null });
+ }
}
});
};
@@ -128,9 +139,10 @@ function App() {
await API.login(adminCodeInput);
setView('admin');
setShowAdminLogin(false);
+ await refreshData();
setAdminCodeInput('');
showNotification("Logged in as Admin");
- } catch (err) {
+ } catch {
showNotification("Incorrect Admin Code");
}
};
@@ -159,7 +171,7 @@ function App() {
)}
)}
- { if (view === 'admin') setView('customer'); else setShowAdminLogin(true); }} className={`p-2 rounded-md transition-colors ${view === 'admin' ? 'bg-red-900/50 text-red-200' : 'text-stone-600 hover:text-stone-400'}`}>
+ { if (view === 'admin') { API.logout(); setOrders([]); setView('customer'); } else setShowAdminLogin(true); }} className={`p-2 rounded-md transition-colors ${view === 'admin' ? 'bg-red-900/50 text-red-200' : 'text-stone-600 hover:text-stone-400'}`}>
{view === 'admin' ? : }
diff --git a/frontend/src/components/AdminDashboard.tsx b/frontend/src/components/AdminDashboard.tsx
index f1bb270..a178367 100644
--- a/frontend/src/components/AdminDashboard.tsx
+++ b/frontend/src/components/AdminDashboard.tsx
@@ -7,6 +7,10 @@ export default function AdminDashboard({ stock, orders, config, faqs, refreshDat
const [newAdminCode, setNewAdminCode] = useState('');
const [settingsForm, setSettingsForm] = useState(null);
+ const notifyError = (error: unknown) => {
+ notify(error instanceof Error ? error.message : "Action failed");
+ };
+
useEffect(() => {
if (config && !settingsForm) {
setSettingsForm({
@@ -15,17 +19,21 @@ export default function AdminDashboard({ stock, orders, config, faqs, refreshDat
payIdInfo: config.payIdInfo || '',
termsOfService: config.termsOfService || '',
orderingPolicy: config.orderingPolicy || '',
- depositPercentage: config.depositPercentage || 30
+ depositPercentage: config.depositPercentage ?? 30
});
}
}, [config, settingsForm]);
const handleSaveSettings = async () => {
if (!settingsForm) return;
- await API.updateConfig(settingsForm);
- notify("Settings Saved");
- setSettingsForm(null);
- refreshData();
+ try {
+ await API.updateConfig(settingsForm);
+ notify("Settings Saved");
+ setSettingsForm(null);
+ refreshData();
+ } catch (error) {
+ notifyError(error);
+ }
};
const totalRevenue = orders.reduce((acc: number, order: any) => acc + (order.estimatedTotal || 0), 0);
@@ -37,10 +45,15 @@ export default function AdminDashboard({ stock, orders, config, faqs, refreshDat
notify("Code must be at least 4 characters");
return;
}
- await API.updateConfig({ adminCode: newAdminCode });
- setNewAdminCode('');
- notify("Admin Password Updated");
- refreshData();
+ try {
+ await API.updateConfig({ adminCode: newAdminCode });
+ API.setAdminCode(newAdminCode);
+ setNewAdminCode('');
+ notify("Admin Password Updated");
+ refreshData();
+ } catch (error) {
+ notifyError(error);
+ }
};
@@ -55,16 +68,30 @@ export default function AdminDashboard({ stock, orders, config, faqs, refreshDat
bulk1Threshold: 0, bulk1Price: 0,
bulk2Threshold: 0, bulk2Price: 0
};
- await API.updateStock(newItem);
- refreshData();
- notify("New Item Added");
+ try {
+ await API.updateStock(newItem);
+ refreshData();
+ notify("New Item Added");
+ } catch (error) {
+ notifyError(error);
+ }
};
const updateStockItem = async (id: number, updates: any) => {
const currentItem = stock.find((s: any) => s.id === id) || { id };
- await API.updateStock({ ...currentItem, ...updates });
- refreshData();
- notify("Stock updated");
+ const configuredAvailable = updates.available ?? currentItem.configuredAvailable ?? currentItem.available;
+ try {
+ await API.updateStock({
+ ...currentItem,
+ ...updates,
+ available: configuredAvailable,
+ configuredAvailable
+ });
+ refreshData();
+ notify("Stock updated");
+ } catch (error) {
+ notifyError(error);
+ }
};
const deleteStockItem = (id: number) => {
@@ -81,16 +108,24 @@ export default function AdminDashboard({ stock, orders, config, faqs, refreshDat
answer: "Answer here...",
order: faqs.length + 1
};
- await API.updateFAQ(newFAQ);
- refreshData();
- notify("New FAQ Added");
+ try {
+ await API.updateFAQ(newFAQ);
+ refreshData();
+ notify("New FAQ Added");
+ } catch (error) {
+ notifyError(error);
+ }
};
const updateFAQ = async (id: number, updates: any) => {
const currentFAQ = faqs.find((f: any) => f.id === id) || { id };
- await API.updateFAQ({ ...currentFAQ, ...updates });
- refreshData();
- notify("FAQ updated");
+ try {
+ await API.updateFAQ({ ...currentFAQ, ...updates });
+ refreshData();
+ notify("FAQ updated");
+ } catch (error) {
+ notifyError(error);
+ }
};
const deleteFAQ = (id: number) => {
@@ -101,19 +136,11 @@ export default function AdminDashboard({ stock, orders, config, faqs, refreshDat
});
};
- const clearAllOrders = () => {
- promptConfirm("WARNING: This will delete ALL current order history. Proceed?", async () => {
- await API.clearOrders();
+ const resetBatch = () => {
+ promptConfirm("Start a fresh cooking batch? This clears current orders and reactivates every inventory item, while preserving your menu and settings.", async () => {
+ await API.resetBatch();
refreshData();
- notify("All Orders Cleared");
- });
- };
-
- const resetDatabase = () => {
- promptConfirm("Wipe all data and reset to defaults?", async () => {
- await API.resetDB();
- refreshData();
- notify("Database Reset to Defaults");
+ notify("Fresh Cooking Batch Started");
});
};
@@ -198,7 +225,7 @@ export default function AdminDashboard({ stock, orders, config, faqs, refreshDat
API.updateOrderStatus(order.id, e.target.value).then(refreshData)}
+ onChange={(e) => API.updateOrderStatus(order.id, e.target.value).then(refreshData).catch(notifyError)}
className="bg-stone-900 border border-stone-800 rounded px-2 py-1 text-xs text-stone-400 focus:border-red-600 outline-none"
>
Pending
@@ -227,7 +254,7 @@ export default function AdminDashboard({ stock, orders, config, faqs, refreshDat
Estimated Total
${order.estimatedTotal?.toFixed(2)}
-
30% Deposit: ${(order.estimatedTotal * 0.3).toFixed(2)}
+
{config?.depositPercentage ?? 30}% Deposit: ${(order.estimatedTotal * ((config?.depositPercentage ?? 30) / 100)).toFixed(2)}
@@ -321,9 +348,9 @@ export default function AdminDashboard({ stock, orders, config, faqs, refreshDat
Update Password
-
Danger Zone
-
Clear All Order History
-
Reset Entire Database to Defaults
+
Cooking Batch
+
Clears current orders and restores all inventory availability. Menu items, pricing, FAQs, and settings are preserved.
+
Start Fresh Cooking Batch
)}
@@ -393,6 +420,7 @@ function AdminFAQItem({ item, onUpdate, onDelete }: any) {
function AdminStockItem({ item, onUpdate, onDelete }: any) {
const [isEditing, setIsEditing] = useState(false);
const [editData, setEditData] = useState({});
+ const configuredAvailable = item.configuredAvailable ?? item.available;
useEffect(() => {
if (!isEditing) {
@@ -441,7 +469,7 @@ function AdminStockItem({ item, onUpdate, onDelete }: any) {
- {config?.termsOfService || `To secure your order, a ${config?.depositPercentage || 30}% non-refundable deposit is required upfront before smoking begins. Payments can be made via PayID or Cash. The remaining balance is payable upon pickup.`}
+ {config?.termsOfService || `To secure your order, a ${config?.depositPercentage ?? 30}% non-refundable deposit is required upfront before smoking begins. Payments can be made via PayID or Cash. The remaining balance is payable upon pickup.`}
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts
index 360986b..e20ddb3 100644
--- a/frontend/src/services/api.ts
+++ b/frontend/src/services/api.ts
@@ -1,55 +1,94 @@
export const API_BASE = import.meta.env.VITE_API_URL || '';
+let adminCode: string | null = null;
+
+interface DataResponse {
+ stock?: Array>;
+ orders?: Array>;
+ faqs?: Array>;
+ config?: Record;
+}
+
+export class ApiError extends Error {
+ status: number;
+
+ constructor(message: string, status: number) {
+ super(message);
+ this.name = 'ApiError';
+ this.status = status;
+ }
+}
+
+const request = async (path: string, options: RequestInit = {}): Promise => {
+ const headers = new Headers(options.headers);
+ if (adminCode) headers.set('x-admin-code', adminCode);
+
+ const res = await fetch(`${API_BASE}${path}`, { ...options, headers });
+ const text = await res.text();
+ let data: unknown = null;
+
+ if (text) {
+ try {
+ data = JSON.parse(text);
+ } catch {
+ data = text;
+ }
+ }
+
+ if (!res.ok) {
+ const message = typeof data === 'object' && data !== null && 'error' in data && typeof data.error === 'string'
+ ? data.error
+ : `Request failed (${res.status})`;
+ throw new ApiError(message, res.status);
+ }
+
+ return data as T;
+};
+
export const API = {
- fetchData: async () => {
- const res = await fetch(`${API_BASE}/api/data`);
- return res.json();
- },
- submitOrder: async (order: any) => {
- const res = await fetch(`${API_BASE}/api/orders`, {
+ fetchData: () => request('/api/data'),
+ submitOrder: (order: any) => request('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(order)
- });
- return res.json();
- },
- updateOrderStatus: async (id: number, status: string) => {
- const res = await fetch(`${API_BASE}/api/orders/${id}/status`, {
+ }),
+ updateOrderStatus: (id: number, status: string) => request(`/api/orders/${id}/status`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status })
- });
- return res.json();
- },
- deleteOrder: async (id: number) => {
- await fetch(`${API_BASE}/api/orders/${id}`, { method: 'DELETE' });
- },
- clearOrders: () => fetch(`${API_BASE}/api/orders`, { method: 'DELETE' }),
- updateStock: (item: any) => fetch(`${API_BASE}/api/stock`, {
+ }),
+ deleteOrder: (id: number) => request(`/api/orders/${id}`, { method: 'DELETE' }),
+ resetBatch: () => request('/api/batches/reset', { method: 'POST' }),
+ updateStock: (item: any) => request('/api/stock', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(item)
- }).then(res => res.json()),
- deleteStock: (id: number) => fetch(`${API_BASE}/api/stock/${id}`, { method: 'DELETE' }),
- updateConfig: (cfg: any) => fetch(`${API_BASE}/api/config`, {
+ }),
+ deleteStock: (id: number) => request(`/api/stock/${id}`, { method: 'DELETE' }),
+ updateConfig: (cfg: any) => request('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(cfg)
}),
- updateFAQ: (faq: any) => fetch(`${API_BASE}/api/faqs`, {
+ updateFAQ: (faq: any) => request('/api/faqs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(faq)
- }).then(res => res.json()),
- deleteFAQ: (id: number) => fetch(`${API_BASE}/api/faqs/${id}`, { method: 'DELETE' }),
- resetDB: () => fetch(`${API_BASE}/api/reset`, { method: 'POST' }),
+ }),
+ deleteFAQ: (id: number) => request(`/api/faqs/${id}`, { method: 'DELETE' }),
login: async (code: string) => {
- const res = await fetch(`${API_BASE}/api/login`, {
+ const result = await request('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code })
});
- if (!res.ok) throw new Error('Invalid code');
- return res.json();
+ adminCode = code;
+ return result;
+ },
+ setAdminCode: (code: string) => {
+ adminCode = code;
+ },
+ logout: () => {
+ adminCode = null;
}
};