| |
| |
| |
|
|
| |
| 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]; |
| } |
|
|
| |
| 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); |
|
|
| |
| 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); |
| } |
| } |
|
|
| |
| 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'); |
| |
| target.style.animation = 'none'; |
| target.offsetHeight; |
| target.style.animation = ''; |
| } |
|
|
| |
| 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'); |
|
|
| |
| 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; |
|
|
| |
| 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(); |
| } |
|
|
| |
| function toggleMobileMenu() { |
| const menu = document.getElementById('mobile-menu'); |
| menu.classList.toggle('hidden'); |
| } |
|
|
| |
| 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'); |
| } |
|
|
| |
| 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); |
| } |
|
|
| |
| 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(''); |
| } |
| |
| notifs.forEach(n => n.read = true); |
| setDB(DB_KEYS.notifications, notifs); |
| setTimeout(() => updateNotifBadge(), 2000); |
| } |
|
|
| |
| |
| |
|
|
| function renderDashboard() { |
| const sales = getDB(DB_KEYS.sales); |
| const items = getDB(DB_KEYS.items); |
| const today = todayStr(); |
|
|
| |
| const hour = new Date().getHours(); |
| const greetEl = document.getElementById('greeting-time'); |
| if (greetEl) greetEl.textContent = hour < 12 ? 'morning' : hour < 17 ? 'afternoon' : 'evening'; |
|
|
| |
| const dateEl = document.getElementById('today-date'); |
| if (dateEl) dateEl.textContent = new Date().toLocaleDateString('en-ZA', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }); |
|
|
| |
| 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; |
|
|
| |
| 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'; |
| } |
|
|
| |
| renderCashFlowChart(sales); |
|
|
| |
| 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(''); |
| } |
|
|
| |
| 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(''); |
| } |
|
|
| |
| |
| |
|
|
| 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`); |
|
|
| |
| 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); |
|
|
| |
| 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(); |
| } |
|
|
| |
| |
| |
|
|
| 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; |
|
|
| |
| 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; } |
|
|
| |
| item.qty -= qty; |
| setDB(DB_KEYS.items, items); |
|
|
| |
| 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)}`); |
|
|
| |
| 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}`); |
| } |
|
|
| |
| 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); |
| } |
| } |
|
|
| |
| |
| |
|
|
| 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(''); |
| } |
|
|
| |
| 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(); |
| } |
|
|
| |
| |
| |
|
|
| 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(''); |
| } |
|
|
| |
| 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(); |
| } |
|
|
| |
| |
| |
|
|
| 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); |
|
|
| |
| 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(''); |
| } |
|
|
| |
| 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; } |
| } |
|
|
| |
| |
| |
|
|
| 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'; |
|
|
| 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 = ''; |
|
|
| |
| 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); |
|
|
| |
| 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; |
| } |
|
|
| |
| 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) { |
| |
| 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; |
| } |
|
|
| |
| 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'); |
| } |
|
|
| |
| |
| |
|
|
| function getCategoryEmoji(cat) { |
| const emojis = { food: 'π', drinks: 'π₯€', snacks: 'π¬', household: 'π§Ή', personal: 'π§΄', other: 'π¦' }; |
| return emojis[cat] || 'π¦'; |
| } |
|
|
| |
| |
| |
|
|
| document.addEventListener('DOMContentLoaded', function() { |
| |
| seedIfEmpty(); |
|
|
| |
| lucide.createIcons(); |
|
|
| |
| 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); |
|
|
| |
| initVoice(); |
|
|
| |
| updateNotifBadge(); |
|
|
| |
| 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'); |
| } |
| }); |
|
|
| |
| document.addEventListener('keydown', function(e) { |
| if (e.key === 'v' && e.ctrlKey) { |
| e.preventDefault(); |
| toggleVoice(); |
| } |
| }); |
| }); |