Download script.js from Navaneethvarma18/deepsite-project-itll1: direct link, hf CLI and curl.
- Browser
- Download file 46.9 kB
-
https://huggingface.co/spaces/Navaneethvarma18/deepsite-project-itll1/resolve/main/script.js
- Command line
-
hf download hf://spaces/Navaneethvarma18/deepsite-project-itll1/script.js
-
curl -L -o script.js https://huggingface.co/spaces/Navaneethvarma18/deepsite-project-itll1/resolve/main/script.js
46.9 kB
| // ===================================================== | |
| // 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 = `<i data-lucide="${icons[type]}" class="w-4 h-4 shrink-0"></i><span>${message}</span>`; | |
| 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 = '<p class="text-xs text-gray-400 text-center py-4">No notifications yet</p>'; | |
| } else { | |
| list.innerHTML = notifs.slice(0, 10).map(n => ` | |
| <div class="flex items-start gap-2 p-2 rounded-lg ${n.read ? 'opacity-60' : 'bg-brand-50'}"> | |
| <div class="w-1.5 h-1.5 rounded-full ${n.read ? 'bg-gray-300' : 'bg-brand-500'} mt-1.5 shrink-0"></div> | |
| <div> | |
| <p class="text-xs text-gray-700">${n.text}</p> | |
| <p class="text-[10px] text-gray-400 mt-0.5">${n.date} ${n.time}</p> | |
| </div> | |
| </div> | |
| `).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 = '<p class="text-sm text-gray-400 text-center py-3">All items well stocked β </p>'; | |
| } else { | |
| lowList.innerHTML = alertItems.map(i => ` | |
| <div class="flex items-center justify-between py-2 px-3 rounded-xl bg-gray-50"> | |
| <div class="flex items-center gap-2"> | |
| <span class="text-xs">${getCategoryEmoji(i.category)}</span> | |
| <span class="text-sm font-medium">${i.name}</span> | |
| </div> | |
| <span class="text-xs font-bold ${i.qty === 0 ? 'text-red-500' : 'text-brand-500'}">${i.qty === 0 ? 'OUT' : i.qty + ' left'}</span> | |
| </div> | |
| `).join(''); | |
| } | |
| // Recent Sales | |
| const recentList = document.getElementById('recent-sales-list'); | |
| const recent = todaySales.slice(-5).reverse(); | |
| if (recent.length === 0) { | |
| recentList.innerHTML = '<p class="text-sm text-gray-400 text-center py-3">No sales today yet</p>'; | |
| } else { | |
| recentList.innerHTML = recent.map(s => ` | |
| <div class="flex items-center justify-between py-2 px-3 rounded-xl bg-gray-50"> | |
| <div class="flex items-center gap-2"> | |
| <span class="text-xs font-medium text-gray-500">${s.time}</span> | |
| <span class="text-sm font-medium">${s.itemName}</span> | |
| <span class="text-xs text-gray-400">x${s.qty}</span> | |
| </div> | |
| <span class="text-sm font-bold text-earth-600">R${s.total.toFixed(2)}</span> | |
| </div> | |
| `).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 ` | |
| <div class="flex-1 flex flex-col items-center justify-end h-full"> | |
| <span class="text-[10px] font-semibold ${isToday ? 'text-brand-600' : 'text-gray-400'} mb-1">${val > 0 ? 'R' + Math.round(val) : ''}</span> | |
| <div class="w-full rounded-t-lg chart-bar ${isToday ? 'bg-brand-500' : 'bg-brand-200'}" style="height:${h}%; min-height:4px;"></div> | |
| </div> | |
| `; | |
| }).join(''); | |
| labels.innerHTML = dayLabels.map((l, i) => `<span class="${i === 6 ? 'text-brand-600 font-bold' : ''}">${l}</span>`).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 = '<p class="text-sm text-gray-400 text-center py-8">No items found</p>'; | |
| } 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 ` | |
| <div class="bg-white rounded-xl p-4 border border-gray-100 shadow-sm"> | |
| <div class="flex items-start justify-between"> | |
| <div class="flex-1"> | |
| <div class="flex items-center gap-2"> | |
| <span class="text-sm">${getCategoryEmoji(item.category)}</span> | |
| <h4 class="font-semibold text-gray-900">${item.name}</h4> | |
| <span class="text-[10px] px-1.5 py-0.5 rounded-full ${stockBg} font-semibold">${stockLabel}</span> | |
| </div> | |
| <div class="flex items-center gap-4 mt-2 text-xs text-gray-500"> | |
| <span>Buy: <strong class="text-gray-700">R${item.buyPrice.toFixed(2)}</strong></span> | |
| <span>Sell: <strong class="text-gray-700">R${item.sellPrice.toFixed(2)}</strong></span> | |
| <span>Margin: <strong class="text-earth-600">R${(item.sellPrice - item.buyPrice).toFixed(2)}</strong></span> | |
| </div> | |
| </div> | |
| <div class="text-right"> | |
| <p class="text-2xl font-bold ${stockClass}">${item.qty}</p> | |
| <p class="text-[10px] text-gray-400">units</p> | |
| </div> | |
| </div> | |
| <div class="flex items-center gap-2 mt-3"> | |
| <button onclick="openRestock('${item.id}')" class="flex-1 h-8 bg-brand-50 text-brand-600 rounded-lg text-xs font-semibold hover:bg-brand-100 transition-all flex items-center justify-center gap-1"> | |
| <i data-lucide="package-plus" class="w-3 h-3"></i> Restock | |
| </button> | |
| <button onclick="deleteItem('${item.id}')" class="h-8 px-3 bg-red-50 text-red-500 rounded-lg text-xs font-semibold hover:bg-red-100 transition-all flex items-center justify-center gap-1"> | |
| <i data-lucide="trash-2" class="w-3 h-3"></i> | |
| </button> | |
| </div> | |
| </div> | |
| `; | |
| }).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 = '<option value="">Select product...</option>' + | |
| items.filter(i => i.qty > 0).map(i => | |
| `<option value="${i.id}" data-sell="${i.sellPrice}" data-buy="${i.buyPrice}">${getCategoryEmoji(i.category)} ${i.name} (R${i.sellPrice.toFixed(2)}) β ${i.qty} in stock</option>` | |
| ).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 = '<p class="text-sm text-gray-400 text-center py-3">No sales recorded yet</p>'; | |
| totalDiv.style.display = 'none'; | |
| } else { | |
| list.innerHTML = todaySales.slice().reverse().map(s => ` | |
| <div class="flex items-center justify-between py-2 px-3 rounded-xl bg-gray-50"> | |
| <div class="flex items-center gap-2"> | |
| <span class="text-xs font-medium text-gray-400 w-12">${s.time}</span> | |
| <span class="text-sm font-medium">${s.itemName}</span> | |
| <span class="text-xs text-gray-400">x${s.qty}</span> | |
| </div> | |
| <div class="text-right"> | |
| <span class="text-sm font-bold text-gray-900">R${s.total.toFixed(2)}</span> | |
| <span class="text-[10px] ${s.profit >= 0 ? 'text-earth-600' : 'text-red-500'} block">+R${s.profit.toFixed(2)}</span> | |
| </div> | |
| </div> | |
| `).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 = '<p class="text-sm text-gray-400 text-center py-8">No suppliers added yet</p>'; | |
| } else { | |
| list.innerHTML = suppliers.map(s => ` | |
| <div class="bg-white rounded-xl p-4 border border-gray-100 shadow-sm"> | |
| <div class="flex items-start justify-between"> | |
| <div> | |
| <h4 class="font-semibold text-gray-900">${s.name}</h4> | |
| <p class="text-xs text-gray-500 mt-0.5">π ${s.phone || 'No phone'}</p> | |
| </div> | |
| <div class="text-right"> | |
| <p class="text-xs text-gray-400">Owed</p> | |
| <p class="text-lg font-bold ${s.owed > 0 ? 'text-red-500' : 'text-earth-600'}">R${s.owed.toFixed(2)}</p> | |
| </div> | |
| </div> | |
| <div class="flex items-center gap-2 mt-3"> | |
| <button onclick="openPaySupplier('${s.id}')" class="flex-1 h-8 bg-earth-50 text-earth-700 rounded-lg text-xs font-semibold hover:bg-earth-100 transition-all flex items-center justify-center gap-1" ${s.owed <= 0 ? 'disabled style="opacity:0.4"' : ''}> | |
| <i data-lucide="banknote" class="w-3 h-3"></i> Pay | |
| </button> | |
| <button onclick="deleteSupplier('${s.id}')" class="h-8 px-3 bg-red-50 text-red-500 rounded-lg text-xs font-semibold hover:bg-red-100 transition-all flex items-center justify-center gap-1"> | |
| <i data-lucide="trash-2" class="w-3 h-3"></i> | |
| </button> | |
| </div> | |
| </div> | |
| `).join(''); | |
| } | |
| // Payments list | |
| const payList = document.getElementById('supplier-payments-list'); | |
| const recentPayments = payments.slice(-10).reverse(); | |
| if (recentPayments.length === 0) { | |
| payList.innerHTML = '<p class="text-sm text-gray-400 text-center py-3">No payments recorded</p>'; | |
| } else { | |
| payList.innerHTML = recentPayments.map(p => ` | |
| <div class="flex items-center justify-between py-2 px-3 rounded-xl bg-gray-50"> | |
| <div> | |
| <span class="text-sm font-medium">${p.supplierName}</span> | |
| <span class="text-xs text-gray-400 block">${p.date}</span> | |
| </div> | |
| <span class="text-sm font-bold text-earth-600">-R${p.amount.toFixed(2)}</span> | |
| </div> | |
| `).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 = '<p class="text-sm text-gray-400 text-center py-8">No staff members added yet</p>'; | |
| } else { | |
| const roleLabels = { cashier: 'Cashier', packer: 'Shelf Packer', assistant: 'Shop Assistant', manager: 'Manager' }; | |
| list.innerHTML = staff.map(s => ` | |
| <div class="bg-white rounded-xl p-4 border border-gray-100 shadow-sm"> | |
| <div class="flex items-start justify-between"> | |
| <div> | |
| <h4 class="font-semibold text-gray-900">${s.name}</h4> | |
| <p class="text-xs text-gray-500 mt-0.5">${roleLabels[s.role] || s.role}</p> | |
| </div> | |
| <div class="text-right"> | |
| <p class="text-xs text-gray-400">Weekly wage</p> | |
| <p class="text-lg font-bold text-earth-600">R${s.wage.toFixed(2)}</p> | |
| </div> | |
| </div> | |
| <div class="flex items-center gap-2 mt-3"> | |
| <button onclick="openPayStaff('${s.id}')" class="flex-1 h-8 bg-earth-50 text-earth-700 rounded-lg text-xs font-semibold hover:bg-earth-100 transition-all flex items-center justify-center gap-1"> | |
| <i data-lucide="banknote" class="w-3 h-3"></i> Pay | |
| </button> | |
| <button onclick="deleteStaff('${s.id}')" class="h-8 px-3 bg-red-50 text-red-500 rounded-lg text-xs font-semibold hover:bg-red-100 transition-all flex items-center justify-center gap-1"> | |
| <i data-lucide="trash-2" class="w-3 h-3"></i> | |
| </button> | |
| </div> | |
| </div> | |
| `).join(''); | |
| } | |
| // Payroll history | |
| const payList = document.getElementById('payroll-list'); | |
| const recentPay = payroll.slice(-10).reverse(); | |
| if (recentPay.length === 0) { | |
| payList.innerHTML = '<p class="text-sm text-gray-400 text-center py-3">No payroll records</p>'; | |
| } else { | |
| payList.innerHTML = recentPay.map(p => ` | |
| <div class="flex items-center justify-between py-2 px-3 rounded-xl bg-gray-50"> | |
| <div> | |
| <span class="text-sm font-medium">${p.staffName}</span> | |
| <span class="text-xs text-gray-400 block">${p.date}</span> | |
| </div> | |
| <span class="text-sm font-bold text-earth-600">R${p.amount.toFixed(2)}</span> | |
| </div> | |
| `).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 = '<p class="text-sm text-gray-400 text-center py-3">No data for this period</p>'; | |
| } 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 ` | |
| <div class="py-2"> | |
| <div class="flex items-center justify-between mb-1"> | |
| <span class="text-sm font-medium">${name}</span> | |
| <span class="text-xs ${profit >= 0 ? 'text-earth-600' : 'text-red-500'} font-semibold">+R${profit.toFixed(2)}</span> | |
| </div> | |
| <div class="w-full h-2 bg-gray-100 rounded-full overflow-hidden"> | |
| <div class="h-full bg-brand-500 rounded-full transition-all duration-500" style="width:${pct}%"></div> | |
| </div> | |
| <div class="flex justify-between mt-0.5"> | |
| <span class="text-[10px] text-gray-400">${data.qty} sold</span> | |
| <span class="text-[10px] text-gray-400">R${data.revenue.toFixed(2)}</span> | |
| </div> | |
| </div> | |
| `; | |
| }).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 = '<p class="text-sm text-gray-400 text-center py-3">No daily summaries yet</p>'; | |
| } else { | |
| dailyEl.innerHTML = dailyEntries.map(([date, data]) => { | |
| const profit = data.revenue - data.cost; | |
| return ` | |
| <div class="flex items-center justify-between py-2 px-3 rounded-xl bg-gray-50"> | |
| <div class="flex items-center gap-3"> | |
| <span class="text-sm font-medium text-gray-700">${formatDate(date)}</span> | |
| </div> | |
| <div class="flex items-center gap-4"> | |
| <span class="text-xs text-gray-500">R${data.revenue.toFixed(0)}</span> | |
| <span class="text-sm font-bold ${profit >= 0 ? 'text-earth-600' : 'text-red-500'}">${profit >= 0 ? '+' : ''}R${profit.toFixed(0)}</span> | |
| </div> | |
| </div> | |
| `; | |
| }).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(); | |
| } | |
| }); | |
| }); |