// ═══════════════════════════════════════════════════════════════════════ // CUSTOMER MANAGEMENT // ═══════════════════════════════════════════════════════════════════════ var CUSTOMERS = []; var CUST_CURRENT_KEY = ''; // "name|phone" var currentcustomerId = ''; // legacy alias // ── storage helpers ────────────────────────────────────────────────── function custGetManual() { try { return JSON.parse(storageGet('bd_manual_customers') || '[]'); } catch(e) { return []; } } function custSaveManual(list) { try { storageSet('bd_manual_customers', JSON.stringify(list)); } catch(e) {} } // ── event wiring (called once on DOMContentLoaded) ─────────────────── var _custEventsInitialized = false; function custInitEvents() { if (_custEventsInitialized) return; // prevent duplicate binding _custEventsInitialized = true; // Back button var backBtn = document.getElementById('cust-back-btn'); if (backBtn) backBtn.addEventListener('click', function() { closeSubpage(); }); // Add Customer button var addBtn = document.getElementById('cust-add-btn'); if (addBtn) addBtn.addEventListener('click', function() { custOpenForm(null); }); // Search var searchEl = document.getElementById('cust-search'); if (searchEl) searchEl.addEventListener('input', function() { custRenderList(); }); // Customer list - event delegation (no inline onclick) var listEl = document.getElementById('cust-list'); if (listEl) { listEl.addEventListener('click', function(e) { var card = e.target.closest('[data-cust-key]'); if (card) { var key = card.getAttribute('data-cust-key'); custViewDetail(key); } // retry button if (e.target.closest('[data-cust-retry]')) { loadCustomers(); } }); } // Form modal buttons var formCloseBtn = document.getElementById('cust-form-close-btn'); if (formCloseBtn) formCloseBtn.addEventListener('click', function() { closeModal('customer-form'); }); var formCancelBtn = document.getElementById('cust-form-cancel-btn'); if (formCancelBtn) formCancelBtn.addEventListener('click', function() { closeModal('customer-form'); }); var formSaveBtn = document.getElementById('cust-form-save-btn'); if (formSaveBtn) formSaveBtn.addEventListener('click', function() { custSave(); }); // Detail modal buttons var detailCloseBtn = document.getElementById('cust-detail-close-btn'); if (detailCloseBtn) detailCloseBtn.addEventListener('click', function() { closeModal('customer-detail'); }); var detailClose2Btn = document.getElementById('cust-detail-close2-btn'); if (detailClose2Btn) detailClose2Btn.addEventListener('click', function() { closeModal('customer-detail'); }); var detailEditBtn = document.getElementById('cust-detail-edit-btn'); if (detailEditBtn) detailEditBtn.addEventListener('click', function() { custEditCurrent(); }); } // ── load from server ───────────────────────────────────────────────── function loadCustomers() { var el = document.getElementById('cust-list'); if (!el) return; el.innerHTML = '
'; http('GET', 'invoices?limit=1000', null, function(err, res) { if (err || !res) { el.innerHTML = '
Network error.
Tap to retry
'; return; } if (!res.ok) { var msg = (res.msg || res.message || 'Error'); el.innerHTML = '
Failed: ' + xss(msg) + '
Tap to retry
'; return; } // Build customer map from invoices var invoices = (res.data && res.data.invoices) ? res.data.invoices : []; var custMap = {}; for (var i = 0; i < invoices.length; i++) { var inv = invoices[i]; if (!inv.cust_name) continue; var key = inv.cust_name + '|' + (inv.cust_phone || ''); if (!custMap[key]) { custMap[key] = { name: inv.cust_name, phone: inv.cust_phone || '', due: 0, total_bills: 0, total_spent: 0, manual: false }; } custMap[key].total_bills++; custMap[key].total_spent += parseFloat(inv.total || 0); if (inv.pay_status === 'due' || inv.pay_status === 'partial') { var dueAmt = (inv.due_amount != null) ? parseFloat(inv.due_amount) : (parseFloat(inv.total || 0) - parseFloat(inv.paid_amount || 0)); custMap[key].due += (dueAmt > 0 ? dueAmt : 0); } } // Merge manually added customers var manual = custGetManual(); for (var mi = 0; mi < manual.length; mi++) { var m = manual[mi]; if (!m.name) continue; var mkey = m.name + '|' + (m.phone || ''); if (!custMap[mkey]) { custMap[mkey] = { name: m.name, phone: m.phone || '', address: m.address || '', note: m.note || '', due: 0, total_bills: 0, total_spent: 0, manual: true }; } else { // enrich with manual metadata custMap[mkey].address = m.address || custMap[mkey].address || ''; custMap[mkey].note = m.note || custMap[mkey].note || ''; } } CUSTOMERS = []; for (var k in custMap) CUSTOMERS.push(custMap[k]); CUSTOMERS.sort(function(a, b) { return b.total_spent - a.total_spent; }); // Update stats var tot = document.getElementById('cust-total'); var dueT = document.getElementById('cust-due-total'); if (tot) tot.textContent = CUSTOMERS.length; var totalDue = 0; for (var ci = 0; ci < CUSTOMERS.length; ci++) totalDue += CUSTOMERS[ci].due; if (dueT) dueT.textContent = cur(totalDue); custRenderList(); }); } // ── render list ────────────────────────────────────────────────────── function custRenderList() { var el = document.getElementById('cust-list'); if (!el) return; var q = ''; var searchEl = document.getElementById('cust-search'); if (searchEl) q = searchEl.value.toLowerCase().trim(); var list = CUSTOMERS; if (q) { list = []; for (var i = 0; i < CUSTOMERS.length; i++) { var c = CUSTOMERS[i]; if (c.name.toLowerCase().indexOf(q) !== -1 || c.phone.indexOf(q) !== -1) { list.push(c); } } } if (list.length === 0) { el.innerHTML = '
' + '
' + '
No customers yet
' + '
Invoice-এ customer name দিলে এখানে দেখাবে
অথবা "+ Add Customer" বাটনে ক্লিক করুন
' + '
'; return; } var html = ''; for (var j = 0; j < list.length; j++) { var c = list[j]; var ckey = c.name + '|' + c.phone; var initial = (c.name && c.name.length > 0) ? c.name[0].toUpperCase() : '?'; html += '
' + '
' + xss(initial) + '
' + '
' + '
' + xss(c.name) + '
' + (c.phone ? '
' + xss(c.phone) + '
' : '') + '
' + c.total_bills + ' bills · Spent ' + cur(c.total_spent) + '
' + '
' + (c.due > 0 ? '
' + '
DUE
' + '
' + cur(c.due) + '
' + '
' : '') + '
'; } el.innerHTML = html; } // legacy alias used elsewhere in the file function filterCustomers() { custRenderList(); } function rendercustomerList() { custRenderList(); } // ── view customer detail ────────────────────────────────────────────── function custViewDetail(key) { var parts = key.split('|'); var name = parts[0] || ''; var phone = parts.slice(1).join('|'); // phone may be empty CUST_CURRENT_KEY = key; // legacy alias currentcustomerId = key; var titleEl = document.getElementById('cust-detail-name'); var bodyEl = document.getElementById('cust-detail-body'); if (titleEl) titleEl.textContent = name; if (bodyEl) bodyEl.innerHTML = '
Loading...
'; openModal('customer-detail'); http('GET', 'invoices?limit=1000', null, function(err, res) { if (!res || !res.ok) { if (bodyEl) bodyEl.innerHTML = '
Failed to load data.
'; return; } var allInv = (res.data && res.data.invoices) ? res.data.invoices : []; var invs = []; for (var i = 0; i < allInv.length; i++) { var inv = allInv[i]; // BUG FIX: this used to match on `phone === '' || inv.cust_phone === phone`, // which meant that for any customer with no phone number, EVERY invoice // with the same name matched regardless of its own phone — merging the // purchase history/due totals of unrelated customers who share a name // (e.g. multiple walk-in "Rahim" customers with different numbers). // Match on the exact same "name|phone" key used to build the customer // list instead, so only invoices belonging to this specific customer show. var invKey = inv.cust_name + '|' + (inv.cust_phone || ''); if (invKey === key) { invs.push(inv); } } var totalSpent = 0, totalDue = 0; for (var j = 0; j < invs.length; j++) { totalSpent += parseFloat(invs[j].total || 0); if (invs[j].pay_status === 'due' || invs[j].pay_status === 'partial') { var dAmt = (invs[j].due_amount != null) ? parseFloat(invs[j].due_amount) : (parseFloat(invs[j].total || 0) - parseFloat(invs[j].paid_amount || 0)); totalDue += (dAmt > 0 ? dAmt : 0); } } var html = '
' + '
Total Bills
' + invs.length + '
' + '
Total Spent
' + cur(totalSpent) + '
' + (totalDue > 0 ? '
Total Due
' + cur(totalDue) + '
' : '') + '
'; if (phone) { html += '
' + ' Phone' + '' + xss(phone) + '' + '
'; } html += '
Purchase History
'; if (invs.length === 0) { html += '
No invoices found
'; } else { for (var k = 0; k < invs.length; k++) { var inv2 = invs[k]; var sc = inv2.pay_status === 'paid' ? 'var(--g)' : (inv2.pay_status === 'due' ? 'var(--r)' : 'var(--y)'); html += '
' + '
' + '
INV #' + xss(String(inv2.invoice_no || inv2.id)) + '
' + '
' + xss(inv2.created_at || '') + '
' + '
' + '
' + '
' + cur(inv2.total) + '
' + '
' + xss(inv2.pay_status) + '
' + '
' + '
'; } } if (bodyEl) bodyEl.innerHTML = html; }); } // legacy alias function viewCustomer(name, phone) { var key = name + '|' + (phone || ''); custViewDetail(key); } // ── open Add/Edit form ──────────────────────────────────────────────── function custOpenForm(editKey) { var editIdEl = document.getElementById('cust-edit-id'); var nameEl = document.getElementById('cust-name'); var phoneEl = document.getElementById('cust-phone'); var addressEl = document.getElementById('cust-address'); var noteEl = document.getElementById('cust-note'); var titleEl = document.getElementById('cust-form-title'); if (!editIdEl || !nameEl) { showToast('Form not ready'); return; } if (editKey) { // Edit mode var found = null; for (var i = 0; i < CUSTOMERS.length; i++) { var ck = CUSTOMERS[i].name + '|' + (CUSTOMERS[i].phone || ''); if (ck === editKey) { found = CUSTOMERS[i]; break; } } var parts = editKey.split('|'); editIdEl.value = editKey; nameEl.value = parts[0] || ''; phoneEl.value = parts.slice(1).join('|') || ''; addressEl.value = found ? (found.address || '') : ''; noteEl.value = found ? (found.note || '') : ''; if (titleEl) titleEl.textContent = 'Edit Customer'; if (found && !found.manual) { showToast('Note: came from invoice — will be saved as a contact card'); } } else { // Add mode editIdEl.value = ''; nameEl.value = ''; phoneEl.value = ''; addressEl.value = ''; noteEl.value = ''; if (titleEl) titleEl.textContent = 'Add Customer'; } openModal('customer-form'); // Focus name field after modal opens setTimeout(function() { if (nameEl) nameEl.focus(); }, 300); } // legacy aliases function openCustomerForm(id) { custOpenForm(null); } // ── save customer ───────────────────────────────────────────────────── function custSave() { var nameEl = document.getElementById('cust-name'); var phoneEl = document.getElementById('cust-phone'); var addressEl = document.getElementById('cust-address'); var noteEl = document.getElementById('cust-note'); var editIdEl = document.getElementById('cust-edit-id'); if (!nameEl) return; var name = nameEl.value.trim(); var phone = phoneEl ? phoneEl.value.trim() : ''; var address = addressEl ? addressEl.value.trim() : ''; var note = noteEl ? noteEl.value.trim() : ''; var editKey = editIdEl ? editIdEl.value.trim() : ''; if (!name) { showToast('Customer name লিখুন'); nameEl.focus(); return; } var manual = custGetManual(); if (editKey) { // Edit: find by key and update var found = false; for (var i = 0; i < manual.length; i++) { var mk = manual[i].name + '|' + (manual[i].phone || ''); if (mk === editKey) { manual[i] = {name: name, phone: phone, address: address, note: note}; found = true; break; } } if (!found) { // not in manual list yet (invoice-based customer) — add as new manual entry manual.push({name: name, phone: phone, address: address, note: note}); } } else { // Add: check duplicate var dup = false; for (var j = 0; j < manual.length; j++) { if (manual[j].name.toLowerCase() === name.toLowerCase() && (manual[j].phone || '') === phone) { dup = true; break; } } if (dup) { showToast('এই customer ইতিমধ্যে আছে'); return; } manual.push({name: name, phone: phone, address: address, note: note}); } custSaveManual(manual); showToast(' Customer saved'); closeModal('customer-form'); loadCustomers(); } // legacy alias function saveCustomer() { custSave(); } // ── edit current customer (from detail modal) ───────────────────────── function custEditCurrent() { var key = CUST_CURRENT_KEY || currentcustomerId; closeModal('customer-detail'); setTimeout(function() { custOpenForm(String(key)); }, 200); } // legacy alias function editCurrentCustomer() { custEditCurrent(); } // ── init ────────────────────────────────────────────────────────────── (function() { if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', custInitEvents); } else { custInitEvents(); } })(); // ═══════════════════════════════════════════════════════════════════════ // PURCHASE MANAGEMENT // ═══════════════════════════════════════════════════════════════════════ var PURCHASE_HISTORY = []; function openPurchasePage() { try { openStockSubpage('sub-stock-purchase'); // Always refresh from server so product list is current http('GET', 'products', null, function(err, res) { if (res && res.ok) { PRODUCTS = res.data.products || []; STOCK_PRODUCTS = res.data.products || []; } loadPurchasePage(); }); } catch (e) { showToast('Could not open Purchase page: ' + (e && e.message ? e.message : 'error')); } } function loadPurchasePage() { // Populate product dropdown var sel = document.getElementById('pur-product-sel'); if (sel && PRODUCTS.length > 0) { sel.innerHTML = ''; for (var i = 0; i < PRODUCTS.length; i++) { sel.innerHTML += ''; } sel.onchange = function() { var opt = sel.options[sel.selectedIndex]; var cost = opt ? opt.getAttribute('data-cost') : 0; var costEl = document.getElementById('pur-unit-cost'); if (costEl && cost) costEl.value = cost; calcPurchaseTotal(); }; } else if (sel) { sel.innerHTML = ''; } var qtyEl = document.getElementById('pur-qty'); var costEl2 = document.getElementById('pur-unit-cost'); if (qtyEl) qtyEl.oninput = calcPurchaseTotal; if (costEl2) costEl2.oninput = calcPurchaseTotal; loadPurchaseHistory(); } function calcPurchaseTotal() { var qty = parseFloat((document.getElementById('pur-qty') || {}).value || 0) || 0; var cost = parseFloat((document.getElementById('pur-unit-cost') || {}).value || 0) || 0; var tot = document.getElementById('pur-total-cost'); if (tot) tot.textContent = cur(qty * cost); } function loadPurchaseHistory() { // Load from stored purchase log (via expenses API using purchase category) var el = document.getElementById('purchase-history-list'); if (el) el.innerHTML = '
Loading...
'; http('GET', 'expenses', null, function(err, res) { var purchases = []; if (res && res.ok) { var exps = res.data.expenses || []; purchases = exps.filter(function(e) { return e.category === 'Stock Purchase' || e.note && e.note.indexOf('[PURCHASE]') !== -1; }); } PURCHASE_HISTORY = purchases; // Update stats var now = new Date(); var monthTotal = 0; var count = 0; for (var i = 0; i < purchases.length; i++) { var d = new Date(purchases[i].created_at || ''); if (d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth()) { monthTotal += parseFloat(purchases[i].amount || 0); } count++; } var mt = document.getElementById('purchase-month-total'); var tc = document.getElementById('purchase-total-count'); if (mt) mt.textContent = cur(monthTotal); if (tc) tc.textContent = count; var listEl = document.getElementById('purchase-history-list'); if (!listEl) return; if (purchases.length === 0) { listEl.innerHTML = '
No purchase entries yet
'; return; } var html = ''; for (var j = 0; j < purchases.length; j++) { var p = purchases[j]; html += '
' + '
' + '
' + xss(p.note || p.category) + '
' + '
' + xss(p.created_at || '') + '
' + '
' + cur(p.amount) + '
' + '
'; } listEl.innerHTML = html; }); } function savePurchaseEntry() { var prodSel = document.getElementById('pur-product-sel'); var prodId = prodSel ? prodSel.value : ''; var qty = parseFloat((document.getElementById('pur-qty') || {}).value || 0) || 0; var unitCost = parseFloat((document.getElementById('pur-unit-cost') || {}).value || 0) || 0; var supplier = (document.getElementById('pur-supplier') || {}).value || ''; var note = (document.getElementById('pur-note') || {}).value || ''; if (!prodId) { showToast('Select a product'); return; } if (qty <= 0) { showToast('Enter quantity'); return; } var prodName = ''; for (var i = 0; i < PRODUCTS.length; i++) { if (PRODUCTS[i].id == prodId) { prodName = PRODUCTS[i].name; break; } } var totalCost = qty * unitCost; var entryNote = '[PURCHASE] ' + prodName + ' x' + qty + (supplier ? ' from ' + supplier : '') + (note ? ' — ' + note : ''); function doStockIn() { var adjBody = { qty: Math.abs(qty), reason: 'stock_in', note: 'Purchase: ' + (supplier || 'unknown supplier') + (note ? ' — ' + note : ''), cost_price: unitCost }; http('POST', 'products/' + prodId + '/stock', adjBody, function(err2, res2) { if (err2 || !res2 || !res2.ok) { showToast('Error: ' + (res2 ? res2.msg : 'network error')); loadPurchaseHistory(); loadProducts(); loadStockPage(); return; } showToast('Purchase saved! Stock updated +' + qty); // Reset form if (document.getElementById('pur-qty')) document.getElementById('pur-qty').value = ''; if (document.getElementById('pur-unit-cost')) document.getElementById('pur-unit-cost').value = ''; if (document.getElementById('pur-supplier')) document.getElementById('pur-supplier').value = ''; if (document.getElementById('pur-note')) document.getElementById('pur-note').value = ''; if (document.getElementById('pur-total-cost')) document.getElementById('pur-total-cost').textContent = cur(0); loadPurchaseHistory(); // Reload products to update stock count loadProducts(); loadStockPage(); }); } // Only record an expense entry when a purchase price was actually given — // the expense API rejects a 0 amount, and price is intentionally optional here. if (totalCost > 0) { var expBody = { category: 'Stock Purchase', amount: totalCost, note: entryNote, expense_date: new Date().toISOString().split('T')[0] }; http('POST', 'expenses', expBody, function(err1, res1) { doStockIn(); }); } else { doStockIn(); } } // ═══════════════════════════════════════════════════════════════════════ // BUSINESS TYPE: CATEGORY AUTO-POPULATE // ═══════════════════════════════════════════════════════════════════════ // Override spe-category and spe-unit dropdowns based on business type function populateBizDropdowns() { // Sync BB_BIZ_CONFIG then call populateStockProductSelects (which is the canonical populate fn) var bizKey = S ? getBizKey(S.type) : 'general'; for (var i = 0; i < BIZ_TYPES.length; i++) { if (BIZ_TYPES[i].key === bizKey) { BB_BIZ_CONFIG = BIZ_TYPES[i]; break; } } populateStockProductSelects(); }