// ===================================================== // 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)}

${item.qty}

units

`; }).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(); } }); });