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
+25
-13
@@ -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>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
Reference in new issue
Block a user