feat: add safe fresh batch inventory reset
This commit is contained in:
1 parent
27f3d73d01
commit
f27bd572e4
9 files changed
+400
-247
No files matched your search
@@ -8,3 +8,4 @@ backend/prisma/dev.db
|
|||||||
frontend/dist/
|
frontend/dist/
|
||||||
backend/dist/
|
backend/dist/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
.serena/
|
||||||
+1
-1
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
Reference in new issue
Block a user