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

+25 -13
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { API } from './services/api';
import { API, ApiError } from './services/api';
import { Icons } from './components/Icons';
import CustomerView from './components/CustomerView';
import AdminDashboard from './components/AdminDashboard';
@@ -21,17 +21,28 @@ function App() {
const [confirmModal, setConfirmModal] = useState<{ show: boolean, message: string, onConfirm: (() => void) | null }>({ show: false, message: '', onConfirm: null });
const [pickerItem, setPickerItem] = useState<any>(null);
const showNotification = (msg: string) => {
setNotification(msg);
setTimeout(() => setNotification(null), 3000);
};
const refreshData = async () => {
try {
const data = await API.fetchData();
setStock(data.stock);
setOrders(data.orders.sort((a: any, b: any) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()));
setStock(data.stock || []);
setOrders((data.orders || []).sort((a: any, b: any) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()));
setFaqs(data.faqs || []);
if (data.config) {
setConfig(data.config);
}
} catch (err) {
console.error("Server connection failed.", err);
if (err instanceof ApiError && err.status === 401) {
API.logout();
setOrders([]);
setView('customer');
showNotification('Admin session expired. Please log in again.');
}
}
};
@@ -49,18 +60,18 @@ function App() {
clearInterval(intervalId);
};
}, []);
const showNotification = (msg: string) => {
setNotification(msg);
setTimeout(() => setNotification(null), 3000);
};
const promptConfirm = (message: string, action: () => void) => {
const promptConfirm = (message: string, action: () => void | Promise<void>) => {
setConfirmModal({
show: true,
message,
onConfirm: async () => {
await action();
setConfirmModal({ show: false, message: '', onConfirm: null });
try {
await action();
} catch (error) {
showNotification(error instanceof Error ? error.message : 'Action failed');
} finally {
setConfirmModal({ show: false, message: '', onConfirm: null });
}
}
});
};
@@ -128,9 +139,10 @@ function App() {
await API.login(adminCodeInput);
setView('admin');
setShowAdminLogin(false);
await refreshData();
setAdminCodeInput('');
showNotification("Logged in as Admin");
} catch (err) {
} catch {
showNotification("Incorrect Admin Code");
}
};
@@ -159,7 +171,7 @@ function App() {
)}
</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 />}
</button>
</div>
+68 -40
View File
@@ -7,6 +7,10 @@ export default function AdminDashboard({ stock, orders, config, faqs, refreshDat
const [newAdminCode, setNewAdminCode] = useState('');
const [settingsForm, setSettingsForm] = useState<any>(null);
const notifyError = (error: unknown) => {
notify(error instanceof Error ? error.message : "Action failed");
};
useEffect(() => {
if (config && !settingsForm) {
setSettingsForm({
@@ -15,17 +19,21 @@ export default function AdminDashboard({ stock, orders, config, faqs, refreshDat
payIdInfo: config.payIdInfo || '',
termsOfService: config.termsOfService || '',
orderingPolicy: config.orderingPolicy || '',
depositPercentage: config.depositPercentage || 30
depositPercentage: config.depositPercentage ?? 30
});
}
}, [config, settingsForm]);
const handleSaveSettings = async () => {
if (!settingsForm) return;
await API.updateConfig(settingsForm);
notify("Settings Saved");
setSettingsForm(null);
refreshData();
try {
await API.updateConfig(settingsForm);
notify("Settings Saved");
setSettingsForm(null);
refreshData();
} catch (error) {
notifyError(error);
}
};
const totalRevenue = orders.reduce((acc: number, order: any) => acc + (order.estimatedTotal || 0), 0);
@@ -37,10 +45,15 @@ export default function AdminDashboard({ stock, orders, config, faqs, refreshDat
notify("Code must be at least 4 characters");
return;
}
await API.updateConfig({ adminCode: newAdminCode });
setNewAdminCode('');
notify("Admin Password Updated");
refreshData();
try {
await API.updateConfig({ adminCode: newAdminCode });
API.setAdminCode(newAdminCode);
setNewAdminCode('');
notify("Admin Password Updated");
refreshData();
} catch (error) {
notifyError(error);
}
};
@@ -55,16 +68,30 @@ export default function AdminDashboard({ stock, orders, config, faqs, refreshDat
bulk1Threshold: 0, bulk1Price: 0,
bulk2Threshold: 0, bulk2Price: 0
};
await API.updateStock(newItem);
refreshData();
notify("New Item Added");
try {
await API.updateStock(newItem);
refreshData();
notify("New Item Added");
} catch (error) {
notifyError(error);
}
};
const updateStockItem = async (id: number, updates: any) => {
const currentItem = stock.find((s: any) => s.id === id) || { id };
await API.updateStock({ ...currentItem, ...updates });
refreshData();
notify("Stock updated");
const configuredAvailable = updates.available ?? currentItem.configuredAvailable ?? currentItem.available;
try {
await API.updateStock({
...currentItem,
...updates,
available: configuredAvailable,
configuredAvailable
});
refreshData();
notify("Stock updated");
} catch (error) {
notifyError(error);
}
};
const deleteStockItem = (id: number) => {
@@ -81,16 +108,24 @@ export default function AdminDashboard({ stock, orders, config, faqs, refreshDat
answer: "Answer here...",
order: faqs.length + 1
};
await API.updateFAQ(newFAQ);
refreshData();
notify("New FAQ Added");
try {
await API.updateFAQ(newFAQ);
refreshData();
notify("New FAQ Added");
} catch (error) {
notifyError(error);
}
};
const updateFAQ = async (id: number, updates: any) => {
const currentFAQ = faqs.find((f: any) => f.id === id) || { id };
await API.updateFAQ({ ...currentFAQ, ...updates });
refreshData();
notify("FAQ updated");
try {
await API.updateFAQ({ ...currentFAQ, ...updates });
refreshData();
notify("FAQ updated");
} catch (error) {
notifyError(error);
}
};
const deleteFAQ = (id: number) => {
@@ -101,19 +136,11 @@ export default function AdminDashboard({ stock, orders, config, faqs, refreshDat
});
};
const clearAllOrders = () => {
promptConfirm("WARNING: This will delete ALL current order history. Proceed?", async () => {
await API.clearOrders();
const resetBatch = () => {
promptConfirm("Start a fresh cooking batch? This clears current orders and reactivates every inventory item, while preserving your menu and settings.", async () => {
await API.resetBatch();
refreshData();
notify("All Orders Cleared");
});
};
const resetDatabase = () => {
promptConfirm("Wipe all data and reset to defaults?", async () => {
await API.resetDB();
refreshData();
notify("Database Reset to Defaults");
notify("Fresh Cooking Batch Started");
});
};
@@ -198,7 +225,7 @@ export default function AdminDashboard({ stock, orders, config, faqs, refreshDat
<div className="flex gap-2">
<select
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"
>
<option value="Pending">Pending</option>
@@ -227,7 +254,7 @@ export default function AdminDashboard({ stock, orders, config, faqs, refreshDat
<div className="text-right">
<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-[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>
@@ -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>
</div>
<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>
<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>
<button onClick={resetDatabase} className="text-xs text-stone-600 hover:text-red-500 underline block w-full">Reset Entire Database to Defaults</button>
<p className="text-xs font-bold text-stone-500 uppercase">Cooking Batch</p>
<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={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>
)}
@@ -393,6 +420,7 @@ function AdminFAQItem({ item, onUpdate, onDelete }: any) {
function AdminStockItem({ item, onUpdate, onDelete }: any) {
const [isEditing, setIsEditing] = useState(false);
const [editData, setEditData] = useState<any>({});
const configuredAvailable = item.configuredAvailable ?? item.available;
useEffect(() => {
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" />
</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 className="flex items-center gap-2">
{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={() => 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>
{!isEditing ? (
+2
View File
@@ -40,6 +40,8 @@ export default function CartModal({ cart, onClose, onRemove, onUpdateQty, onSubm
try {
const order = await onSubmit(name, phone);
setSubmittedOrder(order);
} catch {
// The parent displays the API error and keeps the cart open for correction.
} finally {
setIsSubmitting(false);
}
+2 -2
View File
@@ -92,7 +92,7 @@ export default function CustomerView({ stock, faqs, onAddToCart, config }: { sto
hour: '2-digit',
minute: '2-digit'
});
} catch (e) { return d; }
} catch { return d; }
};
return (
@@ -114,7 +114,7 @@ export default function CustomerView({ stock, faqs, onAddToCart, config }: { sto
Terms of Service & Ordering Policy
</p>
<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>
</div>
+68 -29
View File
@@ -1,55 +1,94 @@
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 = {
fetchData: async () => {
const res = await fetch(`${API_BASE}/api/data`);
return res.json();
},
submitOrder: async (order: any) => {
const res = await fetch(`${API_BASE}/api/orders`, {
fetchData: () => request<DataResponse>('/api/data'),
submitOrder: (order: any) => request('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(order)
});
return res.json();
},
updateOrderStatus: async (id: number, status: string) => {
const res = await fetch(`${API_BASE}/api/orders/${id}/status`, {
}),
updateOrderStatus: (id: number, status: string) => request(`/api/orders/${id}/status`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status })
});
return res.json();
},
deleteOrder: async (id: number) => {
await fetch(`${API_BASE}/api/orders/${id}`, { method: 'DELETE' });
},
clearOrders: () => fetch(`${API_BASE}/api/orders`, { method: 'DELETE' }),
updateStock: (item: any) => fetch(`${API_BASE}/api/stock`, {
}),
deleteOrder: (id: number) => request(`/api/orders/${id}`, { method: 'DELETE' }),
resetBatch: () => request('/api/batches/reset', { method: 'POST' }),
updateStock: (item: any) => request('/api/stock', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(item)
}).then(res => res.json()),
deleteStock: (id: number) => fetch(`${API_BASE}/api/stock/${id}`, { method: 'DELETE' }),
updateConfig: (cfg: any) => fetch(`${API_BASE}/api/config`, {
}),
deleteStock: (id: number) => request(`/api/stock/${id}`, { method: 'DELETE' }),
updateConfig: (cfg: any) => request('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(cfg)
}),
updateFAQ: (faq: any) => fetch(`${API_BASE}/api/faqs`, {
updateFAQ: (faq: any) => request('/api/faqs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(faq)
}).then(res => res.json()),
deleteFAQ: (id: number) => fetch(`${API_BASE}/api/faqs/${id}`, { method: 'DELETE' }),
resetDB: () => fetch(`${API_BASE}/api/reset`, { method: 'POST' }),
}),
deleteFAQ: (id: number) => request(`/api/faqs/${id}`, { method: 'DELETE' }),
login: async (code: string) => {
const res = await fetch(`${API_BASE}/api/login`, {
const result = await request('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code })
});
if (!res.ok) throw new Error('Invalid code');
return res.json();
adminCode = code;
return result;
},
setAdminCode: (code: string) => {
adminCode = code;
},
logout: () => {
adminCode = null;
}
};