// =====================================================
// UmkhoAI Spaza ERP - Main Application Script
// =====================================================
// --- Data Layer (localStorage) ---
const DB_KEYS = {
items: 'umkho_items',
sales: 'umkho_sales',
suppliers: 'umkho_suppliers',
supplierPayments: 'umkho_supplier_payments',
staff: 'umkho_staff',
payroll: 'umkho_payroll',
restocks: 'umkho_restocks',
notifications: 'umkho_notifications'
};
function getDB(key) {
try {
return JSON.parse(localStorage.getItem(key)) || [];
} catch { return []; }
}
function setDB(key, data) {
localStorage.setItem(key, JSON.stringify(data));
}
function genId() {
return Date.now().toString(36) + Math.random().toString(36).substr(2, 5);
}
function todayStr() {
return new Date().toISOString().split('T')[0];
}
// --- Seed Demo Data if Empty ---
function seedIfEmpty() {
if (getDB(DB_KEYS.items).length === 0) {
const demoItems = [
{ id: genId(), name: 'Albany Bread', category: 'food', buyPrice: 14.50, sellPrice: 18.00, qty: 24, lowAlert: 5 },
{ id: genId(), name: 'Coca-Cola 2L', category: 'drinks', buyPrice: 12.00, sellPrice: 16.50, qty: 18, lowAlert: 6 },
{ id: genId(), name: 'Cadbury Slab', category: 'snacks', buyPrice: 8.50, sellPrice: 12.00, qty: 30, lowAlert: 8 },
{ id: genId(), name: 'Sunlight Soap', category: 'household', buyPrice: 6.00, sellPrice: 9.50, qty: 15, lowAlert: 4 },
{ id: genId(), name: 'Milk 1L', category: 'drinks', buyPrice: 11.00, sellPrice: 14.50, qty: 3, lowAlert: 5 },
{ id: genId(), name: 'Noodles 2min', category: 'food', buyPrice: 3.50, sellPrice: 5.50, qty: 42, lowAlert: 10 },
{ id: genId(), name: 'Vaseline Jelly', category: 'personal', buyPrice: 18.00, sellPrice: 25.00, qty: 8, lowAlert: 3 },
{ id: genId(), name: 'RG Cigarettes', category: 'other', buyPrice: 22.00, sellPrice: 28.00, qty: 2, lowAlert: 5 },
];
setDB(DB_KEYS.items, demoItems);
const demoSuppliers = [
{ id: genId(), name: 'Soweto Bread Distributors', phone: '072 123 4567', owed: 350.00 },
{ id: genId(), name: 'Gauteng Beverages', phone: '083 987 6543', owed: 1200.00 },
{ id: genId(), name: 'Metro Wholesale', phone: '071 555 3344', owed: 0 },
];
setDB(DB_KEYS.suppliers, demoSuppliers);
const demoStaff = [
{ id: genId(), name: 'Thabo Mokoena', role: 'cashier', wage: 850 },
{ id: genId(), name: 'Nomsa Dlamini', role: 'assistant', wage: 700 },
];
setDB(DB_KEYS.staff, demoStaff);
// Add some demo sales for the past week
const sales = [];
const now = new Date();
for (let d = 6; d >= 0; d--) {
const date = new Date(now);
date.setDate(date.getDate() - d);
const dateStr = date.toISOString().split('T')[0];
const numSales = Math.floor(Math.random() * 5) + 2;
for (let s = 0; s < numSales; s++) {
const item = demoItems[Math.floor(Math.random() * demoItems.length)];
const qty = Math.floor(Math.random() * 3) + 1;
sales.push({
id: genId(),
itemId: item.id,
itemName: item.name,
qty: qty,
sellPrice: item.sellPrice,
buyPrice: item.buyPrice,
total: qty * item.sellPrice,
profit: qty * (item.sellPrice - item.buyPrice),
date: dateStr,
time: `${8 + Math.floor(Math.random() * 12)}:${String(Math.floor(Math.random() * 60)).padStart(2, '0')}`
});
}
}
setDB(DB_KEYS.sales, sales);
}
}
// --- Navigation ---
let currentPage = 'dashboard';
function navigate(page) {
currentPage = page;
document.querySelectorAll('.page-section').forEach(s => s.classList.add('hidden'));
const target = document.getElementById('page-' + page);
if (target) {
target.classList.remove('hidden');
// Re-trigger animation
target.style.animation = 'none';
target.offsetHeight; // trigger reflow
target.style.animation = '';
}
// Update nav active states
document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.mob-nav-btn').forEach(b => b.classList.remove('active'));
const sideNav = document.querySelector(`[data-nav="${page}"]`);
const mobNav = document.querySelector(`[data-mobnav="${page}"]`);
if (sideNav) sideNav.classList.add('active');
if (mobNav) mobNav.classList.add('active');
// Page title
const titles = {
dashboard: 'Dashboard',
inventory: 'Inventory',
sales: 'Record Sale',
suppliers: 'Suppliers',
staff: 'Staff & Payroll',
pnl: 'Profit & Loss'
};
const titleEl = document.getElementById('page-title');
if (titleEl) titleEl.textContent = titles[page] || page;
// Refresh page data
refreshPage(page);
}
function refreshPage(page) {
switch(page) {
case 'dashboard': renderDashboard(); break;
case 'inventory': renderInventory(); break;
case 'sales': renderSalesPage(); break;
case 'suppliers': renderSuppliers(); break;
case 'staff': renderStaff(); break;
case 'pnl': renderPnL(); break;
}
lucide.createIcons();
}
// --- Mobile Menu ---
function toggleMobileMenu() {
const menu = document.getElementById('mobile-menu');
menu.classList.toggle('hidden');
}
// --- Modal Management ---
let currentModalContext = {};
function showModal(name) {
const modal = document.getElementById('modal-' + name);
if (modal) {
modal.classList.remove('hidden');
lucide.createIcons();
}
}
function closeModal(name) {
const modal = document.getElementById('modal-' + name);
if (modal) modal.classList.add('hidden');
}
// --- Toast Notifications ---
function showToast(message, type = 'success') {
const container = document.getElementById('toast-container');
const colors = {
success: 'bg-earth-600 text-white',
error: 'bg-red-500 text-white',
info: 'bg-brand-500 text-white',
warning: 'bg-yellow-500 text-white'
};
const icons = {
success: 'check-circle',
error: 'alert-circle',
info: 'info',
warning: 'alert-triangle'
};
const toast = document.createElement('div');
toast.className = `toast-item flex items-center gap-2 px-4 py-3 rounded-xl shadow-lg text-sm font-medium ${colors[type]}`;
toast.innerHTML = `${message}`;
container.appendChild(toast);
lucide.createIcons();
setTimeout(() => {
toast.style.opacity = '0';
toast.style.transform = 'translateX(20px)';
toast.style.transition = 'all 0.3s';
setTimeout(() => toast.remove(), 300);
}, 3000);
}
// --- Add Notification ---
function addNotification(text) {
const notifs = getDB(DB_KEYS.notifications);
notifs.unshift({ id: genId(), text, date: todayStr(), time: new Date().toLocaleTimeString('en-ZA', { hour: '2-digit', minute: '2-digit' }), read: false });
if (notifs.length > 20) notifs.pop();
setDB(DB_KEYS.notifications, notifs);
updateNotifBadge();
}
function updateNotifBadge() {
const notifs = getDB(DB_KEYS.notifications).filter(n => !n.read);
const badge = document.getElementById('notif-badge');
if (notifs.length > 0) {
badge.classList.remove('hidden');
badge.classList.add('flex');
badge.textContent = notifs.length;
} else {
badge.classList.add('hidden');
badge.classList.remove('flex');
}
}
function toggleNotif() {
const dropdown = document.getElementById('notif-dropdown');
dropdown.classList.toggle('hidden');
const notifs = getDB(DB_KEYS.notifications);
const list = document.getElementById('notif-list');
if (notifs.length === 0) {
list.innerHTML = '
No notifications yet
';
} else {
list.innerHTML = notifs.slice(0, 10).map(n => `
${n.text}
${n.date} ${n.time}
`).join('');
}
// Mark all as read
notifs.forEach(n => n.read = true);
setDB(DB_KEYS.notifications, notifs);
setTimeout(() => updateNotifBadge(), 2000);
}
// =====================================================
// DASHBOARD
// =====================================================
function renderDashboard() {
const sales = getDB(DB_KEYS.sales);
const items = getDB(DB_KEYS.items);
const today = todayStr();
// Greeting
const hour = new Date().getHours();
const greetEl = document.getElementById('greeting-time');
if (greetEl) greetEl.textContent = hour < 12 ? 'morning' : hour < 17 ? 'afternoon' : 'evening';
// Date
const dateEl = document.getElementById('today-date');
if (dateEl) dateEl.textContent = new Date().toLocaleDateString('en-ZA', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
// Today's stats
const todaySales = sales.filter(s => s.date === today);
const todayRevenue = todaySales.reduce((sum, s) => sum + s.total, 0);
const todayProfit = todaySales.reduce((sum, s) => sum + s.profit, 0);
const lowStockItems = items.filter(i => i.qty <= i.lowAlert && i.qty > 0);
const outOfStock = items.filter(i => i.qty === 0);
document.getElementById('stat-today-sales').textContent = todayRevenue.toFixed(2);
document.getElementById('stat-today-profit').textContent = todayProfit.toFixed(2);
document.getElementById('stat-low-stock').textContent = lowStockItems.length + outOfStock.length;
document.getElementById('stat-transactions').textContent = todaySales.length;
// Yesterday comparison
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const yStr = yesterday.toISOString().split('T')[0];
const ySales = sales.filter(s => s.date === yStr);
const yRevenue = ySales.reduce((sum, s) => sum + s.total, 0);
const yProfit = ySales.reduce((sum, s) => sum + s.profit, 0);
const changeEl = document.getElementById('stat-sales-change');
const profitChangeEl = document.getElementById('stat-profit-change');
if (yRevenue > 0) {
const pct = ((todayRevenue - yRevenue) / yRevenue * 100).toFixed(0);
changeEl.textContent = `${pct > 0 ? '+' : ''}${pct}% vs yesterday`;
changeEl.className = `text-[11px] mt-1 font-medium ${pct >= 0 ? 'text-earth-600' : 'text-red-500'}`;
} else {
changeEl.textContent = '— no data yesterday';
}
if (yProfit > 0) {
const pct = ((todayProfit - yProfit) / yProfit * 100).toFixed(0);
profitChangeEl.textContent = `${pct > 0 ? '+' : ''}${pct}% vs yesterday`;
profitChangeEl.className = `text-[11px] mt-1 font-medium ${pct >= 0 ? 'text-brand-600' : 'text-red-500'}`;
} else {
profitChangeEl.textContent = '— no data yesterday';
}
// 7-Day Cash Flow Chart
renderCashFlowChart(sales);
// Low Stock List
const lowList = document.getElementById('low-stock-list');
const alertItems = [...lowStockItems, ...outOfStock].slice(0, 5);
if (alertItems.length === 0) {
lowList.innerHTML = 'All items well stocked ✅
';
} else {
lowList.innerHTML = alertItems.map(i => `
${getCategoryEmoji(i.category)}
${i.name}
${i.qty === 0 ? 'OUT' : i.qty + ' left'}
`).join('');
}
// Recent Sales
const recentList = document.getElementById('recent-sales-list');
const recent = todaySales.slice(-5).reverse();
if (recent.length === 0) {
recentList.innerHTML = 'No sales today yet
';
} else {
recentList.innerHTML = recent.map(s => `
${s.time}
${s.itemName}
x${s.qty}
R${s.total.toFixed(2)}
`).join('');
}
}
function renderCashFlowChart(sales) {
const chart = document.getElementById('cash-flow-chart');
const labels = document.getElementById('cash-flow-labels');
if (!chart || !labels) return;
const days = [];
const dayLabels = [];
const dayData = [];
const now = new Date();
for (let d = 6; d >= 0; d--) {
const date = new Date(now);
date.setDate(date.getDate() - d);
const dateStr = date.toISOString().split('T')[0];
days.push(dateStr);
dayLabels.push(date.toLocaleDateString('en-ZA', { weekday: 'short' }));
const daySales = sales.filter(s => s.date === dateStr);
dayData.push(daySales.reduce((sum, s) => sum + s.total, 0));
}
const maxVal = Math.max(...dayData, 1);
chart.innerHTML = dayData.map((val, i) => {
const h = Math.max((val / maxVal) * 100, 4);
const isToday = i === 6;
return `
${val > 0 ? 'R' + Math.round(val) : ''}
`;
}).join('');
labels.innerHTML = dayLabels.map((l, i) => `${l}`).join('');
}
// =====================================================
// INVENTORY
// =====================================================
function renderInventory() {
const items = getDB(DB_KEYS.items);
const search = (document.getElementById('inv-search')?.value || '').toLowerCase();
const filter = document.getElementById('inv-filter')?.value || 'all';
let filtered = items;
if (search) filtered = filtered.filter(i => i.name.toLowerCase().includes(search));
if (filter === 'low') filtered = filtered.filter(i => i.qty <= i.lowAlert && i.qty > 0);
if (filter === 'out') filtered = filtered.filter(i => i.qty === 0);
document.getElementById('inventory-count').textContent = `${items.length} items`;
const list = document.getElementById('inventory-list');
if (filtered.length === 0) {
list.innerHTML = 'No items found
';
} else {
list.innerHTML = filtered.map(item => {
const stockClass = item.qty === 0 ? 'stock-out' : item.qty <= item.lowAlert ? 'stock-low' : item.qty <= item.lowAlert * 2 ? 'stock-medium' : 'stock-high';
const stockLabel = item.qty === 0 ? 'Out of Stock' : item.qty <= item.lowAlert ? 'Low Stock' : 'In Stock';
const stockBg = item.qty === 0 ? 'bg-red-50 text-red-600' : item.qty <= item.lowAlert ? 'bg-yellow-50 text-yellow-700' : 'bg-earth-50 text-earth-700';
return `
${getCategoryEmoji(item.category)}
${item.name}
${stockLabel}
Buy: R${item.buyPrice.toFixed(2)}
Sell: R${item.sellPrice.toFixed(2)}
Margin: R${(item.sellPrice - item.buyPrice).toFixed(2)}
`;
}).join('');
}
lucide.createIcons();
}
function addItem() {
const name = document.getElementById('item-name').value.trim();
const category = document.getElementById('item-category').value;
const buyPrice = parseFloat(document.getElementById('item-buy-price').value) || 0;
const sellPrice = parseFloat(document.getElementById('item-sell-price').value) || 0;
const qty = parseInt(document.getElementById('item-qty').value) || 0;
const lowAlert = parseInt(document.getElementById('item-low-alert').value) || 5;
if (!name) { showToast('Please enter a product name', 'error'); return; }
if (sellPrice <= 0) { showToast('Please enter a sell price', 'error'); return; }
const items = getDB(DB_KEYS.items);
items.push({ id: genId(), name, category, buyPrice, sellPrice, qty, lowAlert });
setDB(DB_KEYS.items, items);
closeModal('add-item');
showToast(`${name} added to inventory`);
addNotification(`Added "${name}" to inventory`);
// Clear form
document.getElementById('item-name').value = '';
document.getElementById('item-buy-price').value = '';
document.getElementById('item-sell-price').value = '';
document.getElementById('item-qty').value = '0';
renderInventory();
}
function deleteItem(id) {
if (!confirm('Delete this item? This cannot be undone.')) return;
let items = getDB(DB_KEYS.items);
const item = items.find(i => i.id === id);
items = items.filter(i => i.id !== id);
setDB(DB_KEYS.items, items);
showToast(`${item?.name || 'Item'} deleted`, 'info');
addNotification(`Deleted "${item?.name}" from inventory`);
renderInventory();
}
function openRestock(id) {
const items = getDB(DB_KEYS.items);
const item = items.find(i => i.id === id);
if (!item) return;
currentModalContext.restockItemId = id;
document.getElementById('restock-item-name').textContent = item.name;
document.getElementById('restock-item-qty').textContent = item.qty;
document.getElementById('restock-cost').value = item.buyPrice;
document.getElementById('restock-qty').value = 1;
showModal('restock');
}
function restockItem() {
const id = currentModalContext.restockItemId;
const addQty = parseInt(document.getElementById('restock-qty').value) || 0;
const costPerUnit = parseFloat(document.getElementById('restock-cost').value) || 0;
if (addQty <= 0) { showToast('Enter a valid quantity', 'error'); return; }
const items = getDB(DB_KEYS.items);
const idx = items.findIndex(i => i.id === id);
if (idx === -1) return;
items[idx].qty += addQty;
if (costPerUnit > 0) items[idx].buyPrice = costPerUnit;
setDB(DB_KEYS.items, items);
// Track restock cost
const restocks = getDB(DB_KEYS.restocks);
restocks.push({
id: genId(),
itemId: id,
itemName: items[idx].name,
qty: addQty,
costPerUnit,
totalCost: addQty * costPerUnit,
date: todayStr()
});
setDB(DB_KEYS.restocks, restocks);
closeModal('restock');
showToast(`Restocked ${addQty} x ${items[idx].name}`);
addNotification(`Restocked ${items[idx].name}: +${addQty} units (R${(addQty * costPerUnit).toFixed(2)})`);
renderInventory();
}
// =====================================================
// SALES
// =====================================================
function renderSalesPage() {
const items = getDB(DB_KEYS.items);
const select = document.getElementById('sale-product');
const currentVal = select.value;
select.innerHTML = '' +
items.filter(i => i.qty > 0).map(i =>
``
).join('');
if (currentVal) select.value = currentVal;
// Auto-fill sell price
select.addEventListener('change', function() {
const opt = this.options[this.selectedIndex];
if (opt && opt.dataset.sell) {
document.getElementById('sale-price').value = opt.dataset.sell;
}
});
renderTodaySales();
}
function recordSale() {
const itemId = document.getElementById('sale-product').value;
const qty = parseInt(document.getElementById('sale-qty').value) || 0;
const sellPrice = parseFloat(document.getElementById('sale-price').value) || 0;
if (!itemId) { showToast('Please select a product', 'error'); return; }
if (qty <= 0) { showToast('Enter a valid quantity', 'error'); return; }
if (sellPrice <= 0) { showToast('Enter a valid sell price', 'error'); return; }
const items = getDB(DB_KEYS.items);
const item = items.find(i => i.id === itemId);
if (!item) return;
if (qty > item.qty) { showToast(`Only ${item.qty} units in stock`, 'error'); return; }
// Update inventory
item.qty -= qty;
setDB(DB_KEYS.items, items);
// Record sale
const sales = getDB(DB_KEYS.sales);
const now = new Date();
const sale = {
id: genId(),
itemId: item.id,
itemName: item.name,
qty,
sellPrice,
buyPrice: item.buyPrice,
total: qty * sellPrice,
profit: qty * (sellPrice - item.buyPrice),
date: todayStr(),
time: now.toLocaleTimeString('en-ZA', { hour: '2-digit', minute: '2-digit' })
};
sales.push(sale);
setDB(DB_KEYS.sales, sales);
showToast(`Sold ${qty} x ${item.name} for R${sale.total.toFixed(2)}`);
addNotification(`Sale: ${qty} x ${item.name} = R${sale.total.toFixed(2)}`);
// Low stock notification
if (item.qty > 0 && item.qty <= item.lowAlert) {
addNotification(`⚠️ Low stock: ${item.name} — only ${item.qty} left`);
}
if (item.qty === 0) {
addNotification(`🔴 Out of stock: ${item.name}`);
}
// Reset form
document.getElementById('sale-product').value = '';
document.getElementById('sale-qty').value = '1';
document.getElementById('sale-price').value = '';
renderSalesPage();
}
function renderTodaySales() {
const sales = getDB(DB_KEYS.sales);
const today = todayStr();
const todaySales = sales.filter(s => s.date === today);
const list = document.getElementById('sales-today-list');
const totalDiv = document.getElementById('sales-today-total');
if (todaySales.length === 0) {
list.innerHTML = 'No sales recorded yet
';
totalDiv.style.display = 'none';
} else {
list.innerHTML = todaySales.slice().reverse().map(s => `
${s.time}
${s.itemName}
x${s.qty}
R${s.total.toFixed(2)}
+R${s.profit.toFixed(2)}
`).join('');
totalDiv.style.display = 'flex';
document.getElementById('sales-today-total-amount').textContent = 'R' + todaySales.reduce((sum, s) => sum + s.total, 0).toFixed(2);
}
}
// =====================================================
// SUPPLIERS
// =====================================================
function renderSuppliers() {
const suppliers = getDB(DB_KEYS.suppliers);
const payments = getDB(DB_KEYS.supplierPayments);
document.getElementById('supplier-count').textContent = `${suppliers.length} suppliers`;
const list = document.getElementById('suppliers-list');
if (suppliers.length === 0) {
list.innerHTML = 'No suppliers added yet
';
} else {
list.innerHTML = suppliers.map(s => `
${s.name}
📞 ${s.phone || 'No phone'}
Owed
R${s.owed.toFixed(2)}
`).join('');
}
// Payments list
const payList = document.getElementById('supplier-payments-list');
const recentPayments = payments.slice(-10).reverse();
if (recentPayments.length === 0) {
payList.innerHTML = 'No payments recorded
';
} else {
payList.innerHTML = recentPayments.map(p => `
${p.supplierName}
${p.date}
-R${p.amount.toFixed(2)}
`).join('');
}
lucide.createIcons();
}
function addSupplier() {
const name = document.getElementById('sup-name').value.trim();
const phone = document.getElementById('sup-phone').value.trim();
const owed = parseFloat(document.getElementById('sup-owed').value) || 0;
if (!name) { showToast('Please enter a supplier name', 'error'); return; }
const suppliers = getDB(DB_KEYS.suppliers);
suppliers.push({ id: genId(), name, phone, owed });
setDB(DB_KEYS.suppliers, suppliers);
closeModal('add-supplier');
showToast(`${name} added as supplier`);
addNotification(`Added supplier: ${name}${owed > 0 ? ' (Owed: R' + owed.toFixed(2) + ')' : ''}`);
document.getElementById('sup-name').value = '';
document.getElementById('sup-phone').value = '';
document.getElementById('sup-owed').value = '0';
renderSuppliers();
}
function openPaySupplier(id) {
const suppliers = getDB(DB_KEYS.suppliers);
const sup = suppliers.find(s => s.id === id);
if (!sup) return;
currentModalContext.paySupplierId = id;
document.getElementById('pay-sup-name').textContent = sup.name;
document.getElementById('pay-sup-owed').textContent = 'R' + sup.owed.toFixed(2);
document.getElementById('pay-sup-amount').value = sup.owed.toFixed(2);
showModal('pay-supplier');
}
function paySupplier() {
const id = currentModalContext.paySupplierId;
const amount = parseFloat(document.getElementById('pay-sup-amount').value) || 0;
if (amount <= 0) { showToast('Enter a valid amount', 'error'); return; }
const suppliers = getDB(DB_KEYS.suppliers);
const sup = suppliers.find(s => s.id === id);
if (!sup) return;
if (amount > sup.owed) { showToast('Amount exceeds what is owed', 'error'); return; }
sup.owed -= amount;
setDB(DB_KEYS.suppliers, suppliers);
const payments = getDB(DB_KEYS.supplierPayments);
payments.push({ id: genId(), supplierId: id, supplierName: sup.name, amount, date: todayStr() });
setDB(DB_KEYS.supplierPayments, payments);
closeModal('pay-supplier');
showToast(`Paid R${amount.toFixed(2)} to ${sup.name}`);
addNotification(`Payment: R${amount.toFixed(2)} to ${sup.name}`);
renderSuppliers();
}
function deleteSupplier(id) {
if (!confirm('Delete this supplier?')) return;
let suppliers = getDB(DB_KEYS.suppliers);
const sup = suppliers.find(s => s.id === id);
suppliers = suppliers.filter(s => s.id !== id);
setDB(DB_KEYS.suppliers, suppliers);
showToast(`${sup?.name || 'Supplier'} deleted`, 'info');
renderSuppliers();
}
// =====================================================
// STAFF & PAYROLL
// =====================================================
function renderStaff() {
const staff = getDB(DB_KEYS.staff);
const payroll = getDB(DB_KEYS.payroll);
document.getElementById('staff-count').textContent = `${staff.length} staff members`;
const list = document.getElementById('staff-list');
if (staff.length === 0) {
list.innerHTML = 'No staff members added yet
';
} else {
const roleLabels = { cashier: 'Cashier', packer: 'Shelf Packer', assistant: 'Shop Assistant', manager: 'Manager' };
list.innerHTML = staff.map(s => `
${s.name}
${roleLabels[s.role] || s.role}
Weekly wage
R${s.wage.toFixed(2)}
`).join('');
}
// Payroll history
const payList = document.getElementById('payroll-list');
const recentPay = payroll.slice(-10).reverse();
if (recentPay.length === 0) {
payList.innerHTML = 'No payroll records
';
} else {
payList.innerHTML = recentPay.map(p => `
${p.staffName}
${p.date}
R${p.amount.toFixed(2)}
`).join('');
}
lucide.createIcons();
}
function addStaff() {
const name = document.getElementById('staff-name').value.trim();
const role = document.getElementById('staff-role').value;
const wage = parseFloat(document.getElementById('staff-wage').value) || 0;
if (!name) { showToast('Please enter a name', 'error'); return; }
if (wage <= 0) { showToast('Please enter a wage', 'error'); return; }
const staff = getDB(DB_KEYS.staff);
staff.push({ id: genId(), name, role, wage });
setDB(DB_KEYS.staff, staff);
closeModal('add-staff');
showToast(`${name} added to staff`);
addNotification(`Added staff: ${name}`);
document.getElementById('staff-name').value = '';
document.getElementById('staff-wage').value = '';
renderStaff();
}
function openPayStaff(id) {
const staff = getDB(DB_KEYS.staff);
const s = staff.find(st => st.id === id);
if (!s) return;
currentModalContext.payStaffId = id;
document.getElementById('pay-staff-name').textContent = s.name;
document.getElementById('pay-staff-wage').textContent = 'R' + s.wage.toFixed(2);
document.getElementById('pay-staff-amount').value = s.wage.toFixed(2);
showModal('pay-staff');
}
function payStaff() {
const id = currentModalContext.payStaffId;
const amount = parseFloat(document.getElementById('pay-staff-amount').value) || 0;
if (amount <= 0) { showToast('Enter a valid amount', 'error'); return; }
const staff = getDB(DB_KEYS.staff);
const s = staff.find(st => st.id === id);
if (!s) return;
const payroll = getDB(DB_KEYS.payroll);
payroll.push({ id: genId(), staffId: id, staffName: s.name, amount, date: todayStr() });
setDB(DB_KEYS.payroll, payroll);
closeModal('pay-staff');
showToast(`Paid R${amount.toFixed(2)} to ${s.name}`);
addNotification(`Payroll: R${amount.toFixed(2)} to ${s.name}`);
renderStaff();
}
function deleteStaff(id) {
if (!confirm('Remove this staff member?')) return;
let staff = getDB(DB_KEYS.staff);
const s = staff.find(st => st.id === id);
staff = staff.filter(st => st.id !== id);
setDB(DB_KEYS.staff, staff);
showToast(`${s?.name || 'Staff member'} removed`, 'info');
renderStaff();
}
// =====================================================
// PROFIT & LOSS
// =====================================================
let pnlPeriod = 'today';
function setPnlPeriod(period) {
pnlPeriod = period;
document.querySelectorAll('.pnl-period-btn').forEach(b => {
b.classList.remove('bg-brand-500', 'text-white');
b.classList.add('bg-gray-100', 'text-gray-600');
});
const active = document.querySelector(`[data-period="${period}"]`);
if (active) {
active.classList.remove('bg-gray-100', 'text-gray-600');
active.classList.add('bg-brand-500', 'text-white');
}
renderPnL();
}
function renderPnL() {
const sales = getDB(DB_KEYS.sales);
const restocks = getDB(DB_KEYS.restocks);
const supplierPayments = getDB(DB_KEYS.supplierPayments);
const payroll = getDB(DB_KEYS.payroll);
const now = new Date();
const today = todayStr();
let filteredSales, filteredCosts;
switch(pnlPeriod) {
case 'today':
filteredSales = sales.filter(s => s.date === today);
filteredCosts = restocks.filter(r => r.date === today);
break;
case 'week': {
const weekAgo = new Date(now);
weekAgo.setDate(weekAgo.getDate() - 7);
const weekStr = weekAgo.toISOString().split('T')[0];
filteredSales = sales.filter(s => s.date >= weekStr);
filteredCosts = restocks.filter(r => r.date >= weekStr);
break;
}
case 'month': {
const monthAgo = new Date(now);
monthAgo.setMonth(monthAgo.getMonth() - 1);
const monthStr = monthAgo.toISOString().split('T')[0];
filteredSales = sales.filter(s => s.date >= monthStr);
filteredCosts = restocks.filter(r => r.date >= monthStr);
break;
}
default:
filteredSales = sales;
filteredCosts = restocks;
}
const revenue = filteredSales.reduce((sum, s) => sum + s.total, 0);
const cogs = filteredSales.reduce((sum, s) => sum + (s.buyPrice * s.qty), 0);
const restockCosts = filteredCosts.reduce((sum, r) => sum + r.totalCost, 0);
const totalCosts = cogs + restockCosts;
const grossProfit = revenue - totalCosts;
document.getElementById('pnl-revenue').textContent = revenue.toFixed(2);
document.getElementById('pnl-costs').textContent = totalCosts.toFixed(2);
document.getElementById('pnl-profit').textContent = grossProfit.toFixed(2);
// Breakdown by product
const breakdown = {};
filteredSales.forEach(s => {
if (!breakdown[s.itemName]) breakdown[s.itemName] = { revenue: 0, cost: 0, qty: 0 };
breakdown[s.itemName].revenue += s.total;
breakdown[s.itemName].cost += s.buyPrice * s.qty;
breakdown[s.itemName].qty += s.qty;
});
const breakdownEl = document.getElementById('pnl-breakdown');
const breakdownEntries = Object.entries(breakdown).sort((a, b) => b[1].revenue - a[1].revenue);
if (breakdownEntries.length === 0) {
breakdownEl.innerHTML = 'No data for this period
';
} else {
const maxRev = Math.max(...breakdownEntries.map(([, v]) => v.revenue), 1);
breakdownEl.innerHTML = breakdownEntries.map(([name, data]) => {
const pct = (data.revenue / maxRev * 100).toFixed(0);
const profit = data.revenue - data.cost;
return `
${name}
+R${profit.toFixed(2)}
${data.qty} sold
R${data.revenue.toFixed(2)}
`;
}).join('');
}
// Daily summary
const dailyMap = {};
sales.forEach(s => {
if (!dailyMap[s.date]) dailyMap[s.date] = { revenue: 0, cost: 0 };
dailyMap[s.date].revenue += s.total;
dailyMap[s.date].cost += s.buyPrice * s.qty;
});
const dailyEl = document.getElementById('pnl-daily-list');
const dailyEntries = Object.entries(dailyMap).sort((a, b) => b[0].localeCompare(a[0])).slice(0, 14);
if (dailyEntries.length === 0) {
dailyEl.innerHTML = 'No daily summaries yet
';
} else {
dailyEl.innerHTML = dailyEntries.map(([date, data]) => {
const profit = data.revenue - data.cost;
return `
${formatDate(date)}
R${data.revenue.toFixed(0)}
${profit >= 0 ? '+' : ''}R${profit.toFixed(0)}
`;
}).join('');
}
}
function formatDate(dateStr) {
try {
return new Date(dateStr + 'T00:00:00').toLocaleDateString('en-ZA', { month: 'short', day: 'numeric' });
} catch { return dateStr; }
}
// =====================================================
// VOICE COMMANDS
// =====================================================
let recognition = null;
let isListening = false;
function initVoice() {
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SpeechRecognition) {
console.log('Voice not supported in this browser');
return;
}
recognition = new SpeechRecognition();
recognition.continuous = false;
recognition.interimResults = true;
recognition.lang = 'en-ZA'; // Default, will try local languages
recognition.onresult = function(event) {
const transcript = event.results[0][0].transcript;
document.getElementById('voice-transcript').textContent = transcript;
if (event.results[0].isFinal) {
processVoiceCommand(transcript.toLowerCase());
setTimeout(() => toggleVoice(), 1000);
}
};
recognition.onerror = function(event) {
console.log('Voice error:', event.error);
showToast('Voice error: ' + event.error, 'error');
stopVoice();
};
recognition.onend = function() {
if (isListening) stopVoice();
};
}
function toggleVoice() {
if (!recognition) {
initVoice();
if (!recognition) {
showToast('Voice commands not supported in this browser', 'warning');
return;
}
}
if (isListening) {
stopVoice();
} else {
startVoice();
}
}
function startVoice() {
isListening = true;
document.getElementById('voice-overlay').classList.remove('hidden');
document.getElementById('voice-btn').classList.add('listening');
document.getElementById('voice-indicator').classList.remove('hidden');
document.getElementById('voice-transcript').textContent = '';
// Try cycling through languages
const languages = ['zu-ZA', 'st-ST', 'en-ZA'];
const randomLang = languages[Math.floor(Math.random() * languages.length)];
try {
recognition.lang = randomLang;
const langLabels = { 'zu-ZA': 'isiZulu', 'st-ST': 'Sesotho', 'en-ZA': 'English' };
document.getElementById('voice-lang-label').textContent = `Speaking in ${langLabels[randomLang]}...`;
} catch {
recognition.lang = 'en-ZA';
}
try {
recognition.start();
} catch (e) {
console.log('Voice start error:', e);
}
}
function stopVoice() {
isListening = false;
document.getElementById('voice-overlay').classList.add('hidden');
document.getElementById('voice-btn').classList.remove('listening');
document.getElementById('voice-indicator').classList.add('hidden');
try {
recognition.stop();
} catch {}
}
function processVoiceCommand(text) {
const items = getDB(DB_KEYS.items);
const sales = getDB(DB_KEYS.sales);
// Navigate commands
if (text.includes('dashboard') || text.includes('home') || text.includes('khaya')) {
navigate('dashboard');
showToast('Voice: Go to Dashboard', 'info');
return;
}
if (text.includes('inventory') || text.includes('stock') || text.includes('isitokhwe')) {
navigate('inventory');
showToast('Voice: Go to Inventory', 'info');
return;
}
if (text.includes('sell') || text.includes('sale') || text.includes('thengisa')) {
navigate('sales');
showToast('Voice: Go to Sales', 'info');
return;
}
if (text.includes('supplier') || text.includes('umhlinzeki')) {
navigate('suppliers');
showToast('Voice: Go to Suppliers', 'info');
return;
}
if (text.includes('staff') || text.includes('payroll') || text.includes('basebenzi')) {
navigate('staff');
showToast('Voice: Go to Staff', 'info');
return;
}
if (text.includes('profit') || text.includes('loss') || text.includes('inzuzo')) {
navigate('pnl');
showToast('Voice: Go to P&L', 'info');
return;
}
// Sell commands: "sell 2 bread" or "thengisa 2 bread"
const sellMatch = text.match(/(?:sell|thengisa)\s+(\d+)\s+(.+)/);
if (sellMatch) {
const qty = parseInt(sellMatch[1]);
const searchName = sellMatch[2].trim();
const item = items.find(i => i.name.toLowerCase().includes(searchName) && i.qty >= qty);
if (item) {
// Quick sell
item.qty -= qty;
setDB(DB_KEYS.items, items);
const sale = {
id: genId(),
itemId: item.id,
itemName: item.name,
qty,
sellPrice: item.sellPrice,
buyPrice: item.buyPrice,
total: qty * item.sellPrice,
profit: qty * (item.sellPrice - item.buyPrice),
date: todayStr(),
time: new Date().toLocaleTimeString('en-ZA', { hour: '2-digit', minute: '2-digit' })
};
sales.push(sale);
setDB(DB_KEYS.sales, sales);
showToast(`Voice: Sold ${qty} x ${item.name} = R${sale.total.toFixed(2)}`, 'success');
addNotification(`🎤 Voice sale: ${qty} x ${item.name} = R${sale.total.toFixed(2)}`);
navigate('sales');
} else {
showToast(`Could not find "${searchName}" in stock`, 'error');
}
return;
}
// Check stock: "check stock bread" or "bheka isitokhwe bread"
const checkMatch = text.match(/(?:check stock|check|bheka isitokhwe|isitokhwe)\s+(.+)/);
if (checkMatch) {
const searchName = checkMatch[1].trim();
const item = items.find(i => i.name.toLowerCase().includes(searchName));
if (item) {
showToast(`${item.name}: ${item.qty} units in stock (R${item.sellPrice.toFixed(2)} each)`, 'info');
} else {
showToast(`No item found matching "${searchName}"`, 'error');
}
return;
}
showToast(`Voice: "${text}" — Command not recognized`, 'warning');
}
// =====================================================
// HELPERS
// =====================================================
function getCategoryEmoji(cat) {
const emojis = { food: '🍞', drinks: '🥤', snacks: '🍬', household: '🧹', personal: '🧴', other: '📦' };
return emojis[cat] || '📦';
}
// =====================================================
// INITIALIZATION
// =====================================================
document.addEventListener('DOMContentLoaded', function() {
// Seed demo data
seedIfEmpty();
// Initialize icons
lucide.createIcons();
// Splash screen
const splashBar = document.getElementById('splash-bar');
const splash = document.getElementById('splash');
const app = document.getElementById('app');
splashBar.style.width = '30%';
setTimeout(() => splashBar.style.width = '60%', 300);
setTimeout(() => splashBar.style.width = '85%', 600);
setTimeout(() => splashBar.style.width = '100%', 900);
setTimeout(() => {
splash.style.opacity = '0';
app.style.opacity = '1';
setTimeout(() => {
splash.style.display = 'none';
navigate('dashboard');
}, 500);
}, 1200);
// Initialize voice
initVoice();
// Notification badge
updateNotifBadge();
// Close dropdowns on outside click
document.addEventListener('click', function(e) {
const notifDropdown = document.getElementById('notif-dropdown');
if (!e.target.closest('[onclick*="toggleNotif"]') && !e.target.closest('#notif-dropdown')) {
notifDropdown.classList.add('hidden');
}
});
// Keyboard shortcut for voice
document.addEventListener('keydown', function(e) {
if (e.key === 'v' && e.ctrlKey) {
e.preventDefault();
toggleVoice();
}
});
});