feat: add safe fresh batch inventory reset

This commit is contained in:
Gemini CLI committed 2026-07-27 16:26:57 +10:00
1 parent 27f3d73d01
commit f27bd572e4
9 files changed
+400 -247

No files matched your search

+1
View File
@@ -8,3 +8,4 @@ backend/prisma/dev.db
frontend/dist/ frontend/dist/
backend/dist/ backend/dist/
.DS_Store .DS_Store
.serena/
+1 -1
View File
@@ -41,4 +41,4 @@ RUN mkdir -p /app/data
EXPOSE 3000 EXPOSE 3000
# Start script # 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"]
+231 -160
View File
@@ -11,15 +11,44 @@ const PORT = process.env.PORT || 3000;
app.use(cors()); app.use(cors());
app.use(express.json()); 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 // API Routes
// Fetch all data (Stock, Orders, Config, FAQs) // Fetch all data (Stock, Orders, Config, FAQs)
app.get('/api/data', async (req, res) => { app.get('/api/data', async (req, res) => {
try { try {
let stock = await prisma.stockItem.findMany(); const stock = await prisma.stockItem.findMany();
const orders = await prisma.order.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 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 // Parse order items from string to JSON
const parsedOrders = orders.map(order => ({ 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 // Tally total ordered kg per stock item across all orders
const orderedQtyById: Record<number, number> = {}; const orderedQtyById: Record<number, number> = {};
for (const order of parsedOrders) { for (const order of parsedOrders) {
if (order.status === 'Cancelled') continue;
for (const item of order.items) { for (const item of order.items) {
orderedQtyById[item.id] = (orderedQtyById[item.id] || 0) + (item.qty || 0); orderedQtyById[item.id] = (orderedQtyById[item.id] || 0) + (item.qty || 0);
} }
} }
// Auto-deactivate items that have hit their maxStock limit // Attach computed batch availability without overwriting the admin's availability setting
for (const item of stock) { const stockWithRemaining = stock.map(item => {
if (item.maxStock !== null && item.maxStock !== undefined) { const orderedQty = orderedQtyById[item.id] || 0;
const ordered = orderedQtyById[item.id] || 0; const remainingStock = item.maxStock !== null
const isOutOfStock = ordered >= item.maxStock; ? Math.max(0, item.maxStock - orderedQty)
// Only auto-disable if it's currently marked as available but is actually out of stock : null;
if (isOutOfStock && item.available) {
await prisma.stockItem.update({
where: { id: item.id },
data: { available: false }
});
}
}
}
// Re-fetch stock after potential auto-updates return {
stock = await prisma.stockItem.findMany(); ...item,
available: item.available && (remainingStock === null || remainingStock > 0),
// Attach remaining stock info to each item configuredAvailable: item.available,
const stockWithRemaining = stock.map(item => ({ orderedQty,
...item, remainingStock
orderedQty: orderedQtyById[item.id] || 0, };
remainingStock: item.maxStock !== null && item.maxStock !== undefined });
? Math.max(0, item.maxStock - (orderedQtyById[item.id] || 0))
: null
}));
// Security: Don't send the admin code to the client // 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) { } catch (error) {
console.error(error); console.error(error);
res.status(500).json({ error: 'Failed to fetch data' }); 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) => { app.post('/api/login', async (req, res) => {
try { try {
const { code } = req.body; const { code } = req.body;
console.log(`Login attempt with code: ${code}`); const config = await prisma.config.findUnique({ where: { id: 1 } });
const config = await prisma.config.findFirst();
if (config && config.adminCode === code) { if (config && config.adminCode === code) {
console.log('Login successful');
res.json({ success: true }); res.json({ success: true });
} else { } else {
console.log(`Login failed. Expected: ${config?.adminCode}, Got: ${code}`);
res.status(401).json({ error: 'Invalid admin code' }); res.status(401).json({ error: 'Invalid admin code' });
} }
} catch (error) { } catch (error) {
@@ -94,67 +118,97 @@ app.post('/api/login', async (req, res) => {
// Submit a new order // Submit a new order
app.post('/api/orders', async (req, res) => { app.post('/api/orders', async (req, res) => {
try { try {
const { name, phone, items, totalWeight, estimatedTotal } = req.body; const { name, phone, items } = req.body;
// 1. Validate Stock Availability if (typeof name !== 'string' || !name.trim() || typeof phone !== 'string' || !phone.trim()) {
const stockItems = await prisma.stockItem.findMany(); throw new Error('Name and phone are required');
const allOrders = await prisma.order.findMany(); }
if (!Array.isArray(items) || items.length === 0) {
const orderedQtyById: Record<number, number> = {}; throw new Error('At least one item is required');
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);
}
} }
let computedTotalWeight = 0; const updatedOrder = await prisma.$transaction(async (tx) => {
let computedEstimatedTotal = 0; const stockItems = await tx.stockItem.findMany();
const allOrders = await tx.order.findMany({
where: { status: { not: 'Cancelled' } },
select: { items: true }
});
const orderedQtyById: Record<number, number> = {};
for (const item of items) { for (const order of allOrders) {
const dbItem = stockItems.find(s => s.id === item.id); const parsedItems = JSON.parse(order.items);
if (!dbItem) throw new Error(`Item ${item.name} not found`); for (const item of parsedItems) {
if (!dbItem.available) throw new Error(`Sorry, ${dbItem.name} has just sold out!`); orderedQtyById[item.id] = (orderedQtyById[item.id] || 0) + (item.qty || 0);
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.`);
} }
} }
// Recalculate prices server-side const seenItemIds = new Set<number>();
const getPrice = (stockItem: any, qty: number) => { const validatedItems: Array<{
if (stockItem.bulk2Threshold > 0 && qty >= stockItem.bulk2Threshold && stockItem.bulk2Price > 0) return stockItem.bulk2Price; id: number;
if (stockItem.bulk1Threshold > 0 && qty >= stockItem.bulk1Threshold && stockItem.bulk1Price > 0) return stockItem.bulk1Price; name: string;
return stockItem.price; qty: number;
}; unit: string;
finalPricePerUnit: number;
lineTotal: number;
}> = [];
let computedTotalWeight = 0;
let computedEstimatedTotal = 0;
const finalPrice = getPrice(dbItem, item.qty); for (const item of items) {
item.finalPricePerUnit = finalPrice; if (!Number.isInteger(item?.id) || typeof item?.qty !== 'number' || !Number.isFinite(item.qty) || item.qty <= 0) {
item.lineTotal = finalPrice * item.qty; throw new Error('Each order item must have a valid item ID and quantity greater than zero');
}
computedTotalWeight += item.qty; if (seenItemIds.has(item.id)) {
computedEstimatedTotal += item.lineTotal; throw new Error('Duplicate items are not allowed');
} }
seenItemIds.add(item.id);
// 2. Create the order const dbItem = stockItems.find(stockItem => stockItem.id === item.id);
const order = await prisma.order.create({ if (!dbItem) throw new Error('An item in this order no longer exists');
data: { if (!dbItem.available) throw new Error(`Sorry, ${dbItem.name} has just sold out!`);
name,
phone, if (dbItem.maxStock !== null) {
totalWeight: computedTotalWeight, const alreadyOrdered = orderedQtyById[dbItem.id] || 0;
estimatedTotal: computedEstimatedTotal, if (alreadyOrdered + item.qty > dbItem.maxStock) {
items: JSON.stringify(items), throw new Error(`Not enough stock for ${dbItem.name}. Only ${Math.max(0, dbItem.maxStock - alreadyOrdered)}${dbItem.unit} remaining.`);
status: 'Pending' }
}
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 order = await tx.order.create({
const orderNumber = `#${order.id + 1000}`; data: {
const updatedOrder = await prisma.order.update({ name: name.trim(),
where: { id: order.id }, phone: phone.trim(),
data: { orderNumber } 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); res.json(updatedOrder);
@@ -165,12 +219,22 @@ app.post('/api/orders', async (req, res) => {
}); });
// Update Order Status // Update Order Status
app.patch('/api/orders/:id/status', async (req, res) => { app.patch('/api/orders/:id/status', requireAdmin, async (req, res) => {
try { try {
const { id } = req.params; const { id } = req.params;
const { status } = req.body; 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({ const order = await prisma.order.update({
where: { id: parseInt(id) }, where: { id: orderId },
data: { status } data: { status }
}); });
res.json(order); res.json(order);
@@ -181,10 +245,15 @@ app.patch('/api/orders/:id/status', async (req, res) => {
}); });
// Delete an order // Delete an order
app.delete('/api/orders/:id', async (req, res) => { app.delete('/api/orders/:id', requireAdmin, async (req, res) => {
try { try {
const { id } = req.params; 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); res.sendStatus(200);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
@@ -192,21 +261,34 @@ app.delete('/api/orders/:id', async (req, res) => {
} }
}); });
// Clear all orders // Start a fresh cooking batch while preserving the menu and site settings
app.delete('/api/orders', async (req, res) => { app.post('/api/batches/reset', requireAdmin, async (req, res) => {
try { try {
await prisma.order.deleteMany(); const [clearedOrders, reactivatedItems] = await prisma.$transaction([
res.sendStatus(200); prisma.order.deleteMany(),
prisma.stockItem.updateMany({
where: { available: false },
data: { available: true }
})
]);
res.json({
clearedOrders: clearedOrders.count,
reactivatedItems: reactivatedItems.count
});
} catch (error) { } catch (error) {
console.error(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 // Update or Create Stock Item
app.post('/api/stock', async (req, res) => { app.post('/api/stock', requireAdmin, async (req, res) => {
try { 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) { if (item.id) {
const updated = await prisma.stockItem.update({ const updated = await prisma.stockItem.update({
where: { id: item.id }, where: { id: item.id },
@@ -227,10 +309,15 @@ app.post('/api/stock', async (req, res) => {
}); });
// Delete Stock Item // Delete Stock Item
app.delete('/api/stock/:id', async (req, res) => { app.delete('/api/stock/:id', requireAdmin, async (req, res) => {
try { try {
const { id } = req.params; 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); res.sendStatus(200);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
@@ -239,17 +326,34 @@ app.delete('/api/stock/:id', async (req, res) => {
}); });
// Update Config // Update Config
app.post('/api/config', async (req, res) => { app.post('/api/config', requireAdmin, async (req, res) => {
try { try {
const { adminCode, finalDepositDate, cookDay, payIdInfo, termsOfService, orderingPolicy, depositPercentage } = req.body; const { adminCode, finalDepositDate, cookDay, payIdInfo, termsOfService, orderingPolicy, depositPercentage } = req.body;
const data: any = {}; 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 (finalDepositDate !== undefined) data.finalDepositDate = finalDepositDate;
if (cookDay !== undefined) data.cookDay = cookDay; if (cookDay !== undefined) data.cookDay = cookDay;
if (payIdInfo !== undefined) data.payIdInfo = payIdInfo; if (payIdInfo !== undefined) data.payIdInfo = payIdInfo;
if (termsOfService !== undefined) data.termsOfService = termsOfService; if (termsOfService !== undefined) data.termsOfService = termsOfService;
if (orderingPolicy !== undefined) data.orderingPolicy = orderingPolicy; 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 // Use upsert to ensure we always use ID 1
await prisma.config.upsert({ await prisma.config.upsert({
@@ -266,7 +370,7 @@ app.post('/api/config', async (req, res) => {
}); });
// Update or Create FAQ Item // Update or Create FAQ Item
app.post('/api/faqs', async (req, res) => { app.post('/api/faqs', requireAdmin, async (req, res) => {
try { try {
const item = req.body; const item = req.body;
if (item.id) { if (item.id) {
@@ -289,10 +393,15 @@ app.post('/api/faqs', async (req, res) => {
}); });
// Delete FAQ Item // Delete FAQ Item
app.delete('/api/faqs/:id', async (req, res) => { app.delete('/api/faqs/:id', requireAdmin, async (req, res) => {
try { try {
const { id } = req.params; 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); res.sendStatus(200);
} catch (error) { } catch (error) {
console.error(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 // Serve Static Files in Production
const frontendPath = fs.existsSync(path.join(__dirname, '../frontend-dist')) const frontendPath = fs.existsSync(path.join(__dirname, '../frontend-dist'))
? 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')); res.sendFile(path.join(frontendPath, 'index.html'));
}); });
app.listen(PORT, async () => { const startServer = async () => {
// Ensure a default config exists await prisma.config.upsert({
const config = await prisma.config.findFirst(); where: { id: 1 },
if (!config) { update: {},
await prisma.config.create({ create: {
data: { id: 1,
id: 1, adminCode: '1234',
adminCode: '1234', payIdInfo: 'Your PayID Here'
payIdInfo: 'Your PayID Here' }
} });
});
console.log('Default config seeded'); app.listen(PORT, () => {
} console.log(`Server is running on http://localhost:${PORT}`);
console.log(`Server is running on http://localhost:${PORT}`); });
};
startServer().catch((error) => {
console.error('Failed to start server:', error);
process.exit(1);
}); });
+2 -2
View File
@@ -4,9 +4,9 @@ services:
ports: ports:
- "3000:3000" - "3000:3000"
volumes: volumes:
- meat-data:/app/backend/prisma - meat-data:/app/data
environment: environment:
- DATABASE_URL=file:./dev.db - DATABASE_URL=file:/app/data/dev.db
restart: always restart: always
volumes: volumes:
+25 -13
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { API } from './services/api'; import { API, ApiError } from './services/api';
import { Icons } from './components/Icons'; import { Icons } from './components/Icons';
import CustomerView from './components/CustomerView'; import CustomerView from './components/CustomerView';
import AdminDashboard from './components/AdminDashboard'; 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 [confirmModal, setConfirmModal] = useState<{ show: boolean, message: string, onConfirm: (() => void) | null }>({ show: false, message: '', onConfirm: null });
const [pickerItem, setPickerItem] = useState<any>(null); const [pickerItem, setPickerItem] = useState<any>(null);
const showNotification = (msg: string) => {
setNotification(msg);
setTimeout(() => setNotification(null), 3000);
};
const refreshData = async () => { const refreshData = async () => {
try { try {
const data = await API.fetchData(); const data = await API.fetchData();
setStock(data.stock); setStock(data.stock || []);
setOrders(data.orders.sort((a: any, b: any) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())); setOrders((data.orders || []).sort((a: any, b: any) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()));
setFaqs(data.faqs || []); setFaqs(data.faqs || []);
if (data.config) { if (data.config) {
setConfig(data.config); setConfig(data.config);
} }
} catch (err) { } catch (err) {
console.error("Server connection failed.", 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); clearInterval(intervalId);
}; };
}, []); }, []);
const showNotification = (msg: string) => { const promptConfirm = (message: string, action: () => void | Promise<void>) => {
setNotification(msg);
setTimeout(() => setNotification(null), 3000);
};
const promptConfirm = (message: string, action: () => void) => {
setConfirmModal({ setConfirmModal({
show: true, show: true,
message, message,
onConfirm: async () => { onConfirm: async () => {
await action(); try {
setConfirmModal({ show: false, message: '', onConfirm: null }); 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); await API.login(adminCodeInput);
setView('admin'); setView('admin');
setShowAdminLogin(false); setShowAdminLogin(false);
await refreshData();
setAdminCodeInput(''); setAdminCodeInput('');
showNotification("Logged in as Admin"); showNotification("Logged in as Admin");
} catch (err) { } catch {
showNotification("Incorrect Admin Code"); showNotification("Incorrect Admin Code");
} }
}; };
@@ -159,7 +171,7 @@ function App() {
)} )}
</button> </button>
)} )}
<button onClick={() => { 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'}`}> <button onClick={() => { 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' ? <Icons.X /> : <Icons.Lock />} {view === 'admin' ? <Icons.X /> : <Icons.Lock />}
</button> </button>
</div> </div>
+68 -40
View File
@@ -7,6 +7,10 @@ export default function AdminDashboard({ stock, orders, config, faqs, refreshDat
const [newAdminCode, setNewAdminCode] = useState(''); const [newAdminCode, setNewAdminCode] = useState('');
const [settingsForm, setSettingsForm] = useState<any>(null); const [settingsForm, setSettingsForm] = useState<any>(null);
const notifyError = (error: unknown) => {
notify(error instanceof Error ? error.message : "Action failed");
};
useEffect(() => { useEffect(() => {
if (config && !settingsForm) { if (config && !settingsForm) {
setSettingsForm({ setSettingsForm({
@@ -15,17 +19,21 @@ export default function AdminDashboard({ stock, orders, config, faqs, refreshDat
payIdInfo: config.payIdInfo || '', payIdInfo: config.payIdInfo || '',
termsOfService: config.termsOfService || '', termsOfService: config.termsOfService || '',
orderingPolicy: config.orderingPolicy || '', orderingPolicy: config.orderingPolicy || '',
depositPercentage: config.depositPercentage || 30 depositPercentage: config.depositPercentage ?? 30
}); });
} }
}, [config, settingsForm]); }, [config, settingsForm]);
const handleSaveSettings = async () => { const handleSaveSettings = async () => {
if (!settingsForm) return; if (!settingsForm) return;
await API.updateConfig(settingsForm); try {
notify("Settings Saved"); await API.updateConfig(settingsForm);
setSettingsForm(null); notify("Settings Saved");
refreshData(); setSettingsForm(null);
refreshData();
} catch (error) {
notifyError(error);
}
}; };
const totalRevenue = orders.reduce((acc: number, order: any) => acc + (order.estimatedTotal || 0), 0); 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"); notify("Code must be at least 4 characters");
return; return;
} }
await API.updateConfig({ adminCode: newAdminCode }); try {
setNewAdminCode(''); await API.updateConfig({ adminCode: newAdminCode });
notify("Admin Password Updated"); API.setAdminCode(newAdminCode);
refreshData(); 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, bulk1Threshold: 0, bulk1Price: 0,
bulk2Threshold: 0, bulk2Price: 0 bulk2Threshold: 0, bulk2Price: 0
}; };
await API.updateStock(newItem); try {
refreshData(); await API.updateStock(newItem);
notify("New Item Added"); refreshData();
notify("New Item Added");
} catch (error) {
notifyError(error);
}
}; };
const updateStockItem = async (id: number, updates: any) => { const updateStockItem = async (id: number, updates: any) => {
const currentItem = stock.find((s: any) => s.id === id) || { id }; const currentItem = stock.find((s: any) => s.id === id) || { id };
await API.updateStock({ ...currentItem, ...updates }); const configuredAvailable = updates.available ?? currentItem.configuredAvailable ?? currentItem.available;
refreshData(); try {
notify("Stock updated"); await API.updateStock({
...currentItem,
...updates,
available: configuredAvailable,
configuredAvailable
});
refreshData();
notify("Stock updated");
} catch (error) {
notifyError(error);
}
}; };
const deleteStockItem = (id: number) => { const deleteStockItem = (id: number) => {
@@ -81,16 +108,24 @@ export default function AdminDashboard({ stock, orders, config, faqs, refreshDat
answer: "Answer here...", answer: "Answer here...",
order: faqs.length + 1 order: faqs.length + 1
}; };
await API.updateFAQ(newFAQ); try {
refreshData(); await API.updateFAQ(newFAQ);
notify("New FAQ Added"); refreshData();
notify("New FAQ Added");
} catch (error) {
notifyError(error);
}
}; };
const updateFAQ = async (id: number, updates: any) => { const updateFAQ = async (id: number, updates: any) => {
const currentFAQ = faqs.find((f: any) => f.id === id) || { id }; const currentFAQ = faqs.find((f: any) => f.id === id) || { id };
await API.updateFAQ({ ...currentFAQ, ...updates }); try {
refreshData(); await API.updateFAQ({ ...currentFAQ, ...updates });
notify("FAQ updated"); refreshData();
notify("FAQ updated");
} catch (error) {
notifyError(error);
}
}; };
const deleteFAQ = (id: number) => { const deleteFAQ = (id: number) => {
@@ -101,19 +136,11 @@ export default function AdminDashboard({ stock, orders, config, faqs, refreshDat
}); });
}; };
const clearAllOrders = () => { const resetBatch = () => {
promptConfirm("WARNING: This will delete ALL current order history. Proceed?", async () => { promptConfirm("Start a fresh cooking batch? This clears current orders and reactivates every inventory item, while preserving your menu and settings.", async () => {
await API.clearOrders(); await API.resetBatch();
refreshData(); refreshData();
notify("All Orders Cleared"); notify("Fresh Cooking Batch Started");
});
};
const resetDatabase = () => {
promptConfirm("Wipe all data and reset to defaults?", async () => {
await API.resetDB();
refreshData();
notify("Database Reset to Defaults");
}); });
}; };
@@ -198,7 +225,7 @@ export default function AdminDashboard({ stock, orders, config, faqs, refreshDat
<div className="flex gap-2"> <div className="flex gap-2">
<select <select
value={order.status || 'Pending'} value={order.status || 'Pending'}
onChange={(e) => 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" className="bg-stone-900 border border-stone-800 rounded px-2 py-1 text-xs text-stone-400 focus:border-red-600 outline-none"
> >
<option value="Pending">Pending</option> <option value="Pending">Pending</option>
@@ -227,7 +254,7 @@ export default function AdminDashboard({ stock, orders, config, faqs, refreshDat
<div className="text-right"> <div className="text-right">
<p className="text-[10px] text-stone-500 uppercase font-bold">Estimated Total</p> <p className="text-[10px] text-stone-500 uppercase font-bold">Estimated Total</p>
<p className="text-xl font-black text-red-500">${order.estimatedTotal?.toFixed(2)}</p> <p className="text-xl font-black text-red-500">${order.estimatedTotal?.toFixed(2)}</p>
<p className="text-[10px] text-red-800 font-bold uppercase tracking-tighter">30% Deposit: ${(order.estimatedTotal * 0.3).toFixed(2)}</p> <p className="text-[10px] text-red-800 font-bold uppercase tracking-tighter">{config?.depositPercentage ?? 30}% Deposit: ${(order.estimatedTotal * ((config?.depositPercentage ?? 30) / 100)).toFixed(2)}</p>
</div> </div>
</div> </div>
</div> </div>
@@ -321,9 +348,9 @@ export default function AdminDashboard({ stock, orders, config, faqs, refreshDat
<button onClick={handleUpdatePassword} disabled={!newAdminCode} className="w-full bg-red-700 hover:bg-red-600 disabled:bg-stone-800 disabled:text-stone-600 text-white font-bold py-3 rounded transition-colors">Update Password</button> <button onClick={handleUpdatePassword} disabled={!newAdminCode} className="w-full bg-red-700 hover:bg-red-600 disabled:bg-stone-800 disabled:text-stone-600 text-white font-bold py-3 rounded transition-colors">Update Password</button>
</div> </div>
<div className="space-y-4 border-t border-stone-800 pt-8"> <div className="space-y-4 border-t border-stone-800 pt-8">
<p className="text-xs font-bold text-stone-500 uppercase">Danger Zone</p> <p className="text-xs font-bold text-stone-500 uppercase">Cooking Batch</p>
<button onClick={clearAllOrders} className="w-full py-3 border border-red-900/50 text-red-500 hover:bg-red-900/20 rounded text-sm font-bold transition-colors">Clear All Order History</button> <p className="text-xs text-stone-500">Clears current orders and restores all inventory availability. Menu items, pricing, FAQs, and settings are preserved.</p>
<button onClick={resetDatabase} className="text-xs text-stone-600 hover:text-red-500 underline block w-full">Reset Entire Database to Defaults</button> <button onClick={resetBatch} className="w-full py-3 border border-red-900/50 text-red-500 hover:bg-red-900/20 rounded text-sm font-bold transition-colors">Start Fresh Cooking Batch</button>
</div> </div>
</div> </div>
)} )}
@@ -393,6 +420,7 @@ function AdminFAQItem({ item, onUpdate, onDelete }: any) {
function AdminStockItem({ item, onUpdate, onDelete }: any) { function AdminStockItem({ item, onUpdate, onDelete }: any) {
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [editData, setEditData] = useState<any>({}); const [editData, setEditData] = useState<any>({});
const configuredAvailable = item.configuredAvailable ?? item.available;
useEffect(() => { useEffect(() => {
if (!isEditing) { if (!isEditing) {
@@ -441,7 +469,7 @@ function AdminStockItem({ item, onUpdate, onDelete }: any) {
<textarea value={editData.description} onChange={e => setEditData({ ...editData, description: e.target.value })} className="bg-black text-stone-400 border border-stone-700 rounded px-2 py-1 text-xs w-full h-16" placeholder="Description" /> <textarea value={editData.description} onChange={e => setEditData({ ...editData, description: e.target.value })} className="bg-black text-stone-400 border border-stone-700 rounded px-2 py-1 text-xs w-full h-16" placeholder="Description" />
</div> </div>
)} )}
{!isEditing && <span className={`text-[10px] px-1.5 py-0.5 rounded font-bold uppercase ${item.available ? 'bg-green-900 text-green-400' : 'bg-red-900 text-red-400'}`}>{item.available ? 'Active' : 'Disabled'}</span>} {!isEditing && <span className={`text-[10px] px-1.5 py-0.5 rounded font-bold uppercase ${item.available ? 'bg-green-900 text-green-400' : 'bg-red-900 text-red-400'}`}>{item.available ? 'Active' : configuredAvailable ? 'Batch Full' : 'Disabled'}</span>}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isEditing ? ( {isEditing ? (
@@ -452,7 +480,7 @@ function AdminStockItem({ item, onUpdate, onDelete }: any) {
) : ( ) : (
<button onClick={() => setIsEditing(true)} className="p-2 bg-stone-800 hover:bg-stone-700 text-stone-400 hover:text-white rounded"><Icons.Edit /></button> <button onClick={() => setIsEditing(true)} className="p-2 bg-stone-800 hover:bg-stone-700 text-stone-400 hover:text-white rounded"><Icons.Edit /></button>
)} )}
<button onClick={() => onUpdate({ available: !item.available })} className={`px-3 py-2 rounded font-bold text-xs uppercase w-24 text-center ${item.available ? 'bg-stone-800 text-red-400 border border-red-900/30 hover:bg-red-900/20' : 'bg-green-900/20 text-green-400 border border-green-900/30 hover:bg-green-900/40'}`}>{item.available ? 'Sold Out' : 'Activate'}</button> <button onClick={() => onUpdate({ available: !configuredAvailable })} className={`px-3 py-2 rounded font-bold text-xs uppercase w-24 text-center ${configuredAvailable ? 'bg-stone-800 text-red-400 border border-red-900/30 hover:bg-red-900/20' : 'bg-green-900/20 text-green-400 border border-green-900/30 hover:bg-green-900/40'}`}>{configuredAvailable ? 'Disable' : 'Activate'}</button>
</div> </div>
</div> </div>
{!isEditing ? ( {!isEditing ? (
+2
View File
@@ -40,6 +40,8 @@ export default function CartModal({ cart, onClose, onRemove, onUpdateQty, onSubm
try { try {
const order = await onSubmit(name, phone); const order = await onSubmit(name, phone);
setSubmittedOrder(order); setSubmittedOrder(order);
} catch {
// The parent displays the API error and keeps the cart open for correction.
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
+2 -2
View File
@@ -92,7 +92,7 @@ export default function CustomerView({ stock, faqs, onAddToCart, config }: { sto
hour: '2-digit', hour: '2-digit',
minute: '2-digit' minute: '2-digit'
}); });
} catch (e) { return d; } } catch { return d; }
}; };
return ( return (
@@ -114,7 +114,7 @@ export default function CustomerView({ stock, faqs, onAddToCart, config }: { sto
Terms of Service & Ordering Policy Terms of Service & Ordering Policy
</p> </p>
<p className="text-stone-300 text-sm leading-relaxed"> <p className="text-stone-300 text-sm leading-relaxed">
{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.`}
</p> </p>
</div> </div>
+68 -29
View File
@@ -1,55 +1,94 @@
export const API_BASE = import.meta.env.VITE_API_URL || ''; export const API_BASE = import.meta.env.VITE_API_URL || '';
let adminCode: string | null = null;
interface DataResponse {
stock?: Array<Record<string, unknown>>;
orders?: Array<Record<string, unknown>>;
faqs?: Array<Record<string, unknown>>;
config?: Record<string, unknown>;
}
export class ApiError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.name = 'ApiError';
this.status = status;
}
}
const request = async <T = unknown>(path: string, options: RequestInit = {}): Promise<T> => {
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 = { export const API = {
fetchData: async () => { fetchData: () => request<DataResponse>('/api/data'),
const res = await fetch(`${API_BASE}/api/data`); submitOrder: (order: any) => request('/api/orders', {
return res.json();
},
submitOrder: async (order: any) => {
const res = await fetch(`${API_BASE}/api/orders`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(order) body: JSON.stringify(order)
}); }),
return res.json(); updateOrderStatus: (id: number, status: string) => request(`/api/orders/${id}/status`, {
},
updateOrderStatus: async (id: number, status: string) => {
const res = await fetch(`${API_BASE}/api/orders/${id}/status`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status }) body: JSON.stringify({ status })
}); }),
return res.json(); deleteOrder: (id: number) => request(`/api/orders/${id}`, { method: 'DELETE' }),
}, resetBatch: () => request('/api/batches/reset', { method: 'POST' }),
deleteOrder: async (id: number) => { updateStock: (item: any) => request('/api/stock', {
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`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(item) body: JSON.stringify(item)
}).then(res => res.json()), }),
deleteStock: (id: number) => fetch(`${API_BASE}/api/stock/${id}`, { method: 'DELETE' }), deleteStock: (id: number) => request(`/api/stock/${id}`, { method: 'DELETE' }),
updateConfig: (cfg: any) => fetch(`${API_BASE}/api/config`, { updateConfig: (cfg: any) => request('/api/config', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(cfg) body: JSON.stringify(cfg)
}), }),
updateFAQ: (faq: any) => fetch(`${API_BASE}/api/faqs`, { updateFAQ: (faq: any) => request('/api/faqs', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(faq) body: JSON.stringify(faq)
}).then(res => res.json()), }),
deleteFAQ: (id: number) => fetch(`${API_BASE}/api/faqs/${id}`, { method: 'DELETE' }), deleteFAQ: (id: number) => request(`/api/faqs/${id}`, { method: 'DELETE' }),
resetDB: () => fetch(`${API_BASE}/api/reset`, { method: 'POST' }),
login: async (code: string) => { login: async (code: string) => {
const res = await fetch(`${API_BASE}/api/login`, { const result = await request('/api/login', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code }) body: JSON.stringify({ code })
}); });
if (!res.ok) throw new Error('Invalid code'); adminCode = code;
return res.json(); return result;
},
setAdminCode: (code: string) => {
adminCode = code;
},
logout: () => {
adminCode = null;
} }
}; };