Welcome Back

Sign in with your registered mobile number to continue.

We'll verify your registered mobile number securely. Admin accounts get one extra security step.

'); printWindow.document.close(); printWindow.focus(); setTimeout(function() { printWindow.print(); }, 500); } function renderBranchListUI() { var ul = document.getElementById('branchListUI'); ul.innerHTML = ''; if (!cachedData || !cachedData.branches) return; cachedData.branches.forEach(function(br) { var wrap = document.createElement('li'); wrap.className = 'clay-item-wrap'; wrap.innerHTML = '' + '
' + '' + br + ' (Balance: ₹' + (cachedData.balances[br] || 0) + ')' + '
' + '' + '' + '
' + '
'; ul.appendChild(wrap); attachSwipeToDelete(wrap.querySelector('.clay-item')); }); } function createNewBranch() { var nameInput = document.getElementById('newBranchName'); var name = nameInput.value.trim(); if (!name) return showModal('Warning', 'Enter branch name', 'error'); google.script.run.withSuccessHandler(function(res) { showModal(res.success ? 'Success' : 'Failed', res.message, res.success ? 'success' : 'error'); if (res.success) { nameInput.value = ''; closeAddBranchModal(); loadData(); } }).withFailureHandler(function(err){ showModal('Error', err.toString(), 'error'); }).addNewBranch(user.phone, name); } function openAddBranchModal() { document.getElementById('newBranchName').value = ''; document.getElementById('addBranchModal').classList.remove('hidden'); setTimeout(function(){ document.getElementById('newBranchName').focus(); }, 50); } function closeAddBranchModal() { document.getElementById('addBranchModal').classList.add('hidden'); } function openBranchRenameModal(oldName) { document.getElementById('oldBranchNameHidden').value = oldName; document.getElementById('displayOldBranchName').value = oldName; document.getElementById('newBranchNameInput').value = oldName; document.getElementById('branchRenameModal').classList.remove('hidden'); } function closeBranchRenameModal() { document.getElementById('branchRenameModal').classList.add('hidden'); } // ===== Indian-format Amount inputs (Income / Expense / Edit transaction): "₹" prefix, 1,50,000.50 grouping, live amount-in-words ===== // The visible text is formatted, but the element's .value is overridden to ALWAYS return the pure number string ("150000.5": no ₹, no commas), and setting .value // (prefill on edit, clear after save, suggested salary) accepts a plain number and formats it. So every existing read/write of these inputs keeps working unchanged. var AMT_ONES = ['', 'One', 'Two', 'Three', 'Four', 'Five', 'Six', 'Seven', 'Eight', 'Nine', 'Ten', 'Eleven', 'Twelve', 'Thirteen', 'Fourteen', 'Fifteen', 'Sixteen', 'Seventeen', 'Eighteen', 'Nineteen']; var AMT_TENS = ['', '', 'Twenty', 'Thirty', 'Forty', 'Fifty', 'Sixty', 'Seventy', 'Eighty', 'Ninety']; function amtW99(n) { return n < 20 ? AMT_ONES[n] : AMT_TENS[Math.floor(n / 10)] + (n % 10 ? ' ' + AMT_ONES[n % 10] : ''); } function amtW999(n) { var h = Math.floor(n / 100), r = n % 100; return (h ? AMT_ONES[h] + ' Hundred' + (r ? ' ' : '') : '') + (r ? amtW99(r) : ''); } function amtIntWords(n) { // whole number -> Indian words (Crore / Lakh / Thousand / Hundred) if (n === 0) return 'Zero'; var cr = Math.floor(n / 1e7), lk = Math.floor(n / 1e5) % 100, th = Math.floor(n / 1e3) % 100, rest = n % 1000, p = []; if (cr) p.push(amtIntWords(cr) + ' Crore'); if (lk) p.push(amtW99(lk) + ' Lakh'); if (th) p.push(amtW99(th) + ' Thousand'); if (rest) p.push(amtW999(rest)); return p.join(' '); } // numberToWordsIndian(1250) -> "One Thousand Two Hundred Fifty Rupees Only"; ("150250.5") -> "... Rupees and Fifty Paise Only"; empty / not a number -> ''. function numberToWordsIndian(amount) { var s = String(amount == null ? '' : amount).replace(/[^0-9.]/g, ''); if (!s || s === '.') return ''; var parts = s.split('.'), rupees = Number(parts[0] || 0), paise = Number(((parts[1] || '') + '00').slice(0, 2)); if (!isFinite(rupees)) return ''; if (!rupees && !paise) return 'Zero Rupees Only'; var out = []; if (rupees) out.push(amtIntWords(rupees) + (rupees === 1 ? ' Rupee' : ' Rupees')); if (paise) out.push(amtIntWords(paise) + ' Paise'); return out.join(' and ') + ' Only'; } function amtGroup(intStr) { return intStr.length <= 3 ? intStr : intStr.slice(0, -3).replace(/\B(?=(\d{2})+(?!\d))/g, ',') + ',' + intStr.slice(-3); } // Text the user has -> clean "digits[.dd]" (one dot, max 2 decimals, max 12 integer digits, no leading zeros) function amtClean(t) { t = String(t == null ? '' : t).replace(/[^0-9.]/g, ''); var i = t.indexOf('.'), ip = (i < 0 ? t : t.slice(0, i)).replace(/^0+(?=\d)/, '').slice(0, 12), dp = i < 0 ? null : t.slice(i + 1).replace(/\./g, '').slice(0, 2); if (dp !== null && ip === '') ip = '0'; return dp === null ? ip : ip + '.' + dp; } function amtDisplay(clean) { var i = clean.indexOf('.'); return i < 0 ? amtGroup(clean) : amtGroup(clean.slice(0, i)) + clean.slice(i); } function enhanceAmountInput(el) { if (!el || el._amt) return; el._amt = true; var nativeVal = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value'); el.type = 'text'; el.setAttribute('inputmode', 'decimal'); el.setAttribute('autocomplete', 'off'); el.removeAttribute('min'); el.placeholder = '0'; var field = document.createElement('div'), box = document.createElement('div'), pre = document.createElement('span'), cap = document.createElement('div'); field.className = 'amt-field'; box.className = 'amt-box'; pre.className = 'amt-prefix'; pre.textContent = '₹'; cap.className = 'amt-words'; cap.setAttribute('aria-live', 'polite'); el.parentNode.insertBefore(field, el); field.appendChild(box); box.appendChild(pre); box.appendChild(el); field.appendChild(cap); function raw() { return nativeVal.get.call(el).replace(/,/g, '').replace(/\.$/, ''); } function paint() { var r = raw(), w = numberToWordsIndian(r); cap.textContent = w; cap.classList.toggle('hidden', !w); el.setCustomValidity(Number(r) > 0 ? '' : 'Enter an amount greater than 0'); // replaces the old min="1" check (that one also blocked paise) } Object.defineProperty(el, 'value', { configurable: true, get: function() { return raw(); }, set: function(v) { nativeVal.set.call(el, amtDisplay(amtClean(v))); paint(); } }); el.addEventListener('input', function() { var txt = nativeVal.get.call(el), caret = el.selectionStart == null ? txt.length : el.selectionStart; var left = txt.slice(0, caret).replace(/[^0-9.]/g, '').length; // characters that matter, left of the caret var clean = amtClean(txt), shown = amtDisplay(clean); if (/^\./.test(txt.replace(/[^0-9.]/g, ''))) left++; // a leading "." became "0." if (shown !== txt) nativeVal.set.call(el, shown); var pos = 0, seen = 0; // put the caret back after the same number of digits / dot while (pos < shown.length && seen < left) { if (shown[pos] !== ',') seen++; pos++; } while (pos < shown.length && shown[pos] === ',' && left === 0) pos++; try { el.setSelectionRange(pos, pos); } catch (e) {} paint(); }); paint(); } ['expAmount', 'incAmount', 'editTxAmount'].forEach(function(id) { enhanceAmountInput(document.getElementById(id)); }); // ===== Admin multi-bank split (Income / Expense in Bank mode) ===== // "All Banks / Split across Banks" is an extra option on the Admin's bank pickers. Picking it opens a drawer with one amount // box per bank. Total <-> boxes stay in sync both ways; on submit the server writes one ledger row per bank (addSplitTransaction_). var SPLIT_VAL = '__SPLIT__', SPLIT_LABEL = '🏦 All Banks / Split across Banks', splitBusy = false; function splitEl(id) { return document.getElementById(id); } function splitPrefix(txType) { return txType === 'Expense' ? 'exp' : 'inc'; } // the bank '; }).join(''); drawer.classList.remove('hidden'); var anyVal = splitInputs(txType).some(function(i) { return i.value; }); if (!anyVal && splitCents(splitEl(p + 'Amount').value) > 0) splitDistribute(txType); else splitSumLabel(txType); } function splitSumLabel(txType) { var p = splitPrefix(txType), c = 0; splitInputs(txType).forEach(function(i) { c += splitCents(i.value); }); splitEl(p + 'SplitTotal').innerText = formatIndianCurrency(c / 100); return c; } // Top-down: equal share per bank, odd paise go to the first banks so the parts add up EXACTLY to the total function splitDistribute(txType) { var ins = splitInputs(txType); if (!ins.length) return; var total = splitCents(splitEl(splitPrefix(txType) + 'Amount').value), base = Math.floor(total / ins.length), rem = total - base * ins.length; ins.forEach(function(i, k) { i.value = splitFmt(base + (k < rem ? 1 : 0)); }); splitSumLabel(txType); } // Bottom-up: a bank box was edited -> Total = sum of the boxes function onSplitInput(txType, el) { var v = el.value.replace(/[^\d.]/g, '').replace(/^(\d*\.?\d{0,2}).*$/, '$1'); var dot = v.indexOf('.'); if (dot !== -1) v = v.slice(0, dot + 1) + v.slice(dot + 1).replace(/\./g, ''); if (v !== el.value) el.value = v; var c = splitSumLabel(txType); splitBusy = true; splitEl(splitPrefix(txType) + 'Amount').value = splitFmt(c); splitBusy = false; } function onBankPickerChange(txType) { renderSplitDrawer(txType, true); } function collectSplit(txType) { return splitInputs(txType).map(function(i) { return { bank: i.getAttribute('data-bank'), amount: splitCents(i.value) / 100 }; }) .filter(function(x) { return x.amount > 0; }); } function resetSplit(txType) { splitInputs(txType).forEach(function(i) { i.value = ''; }); splitSumLabel(txType); } ['Expense', 'Income'].forEach(function(t) { splitEl(splitPrefix(t) + 'Amount').addEventListener('input', function() { if (!splitBusy && isSplitActive(t)) splitDistribute(t); // user typed the Total -> share it out }); }); // ===== Claymorphic Settings (d): styled confirm + swipe-to-delete, replacing native confirm() ===== var clayConfirmCallback = null; ; function clayConfirm(message, onYes, o) { // o (optional) {title, icon, yes, no}: defaults stay Confirm Delete / Cancel / Delete o = o || {}; var m = document.getElementById('clayConfirmModal'), bt = m.querySelectorAll('button'); m.querySelector('.modal-title').innerText = o.title || 'Confirm Delete'; m.querySelector('.modal-icon').innerText = o.icon || '🗑️'; bt[0].innerText = o.no || 'Cancel'; bt[1].innerText = o.yes || 'Delete'; document.getElementById('clayConfirmMsg').innerText = message; clayConfirmCallback = onYes; document.getElementById('clayConfirmModal').classList.remove('hidden'); } function closeClayConfirm() { document.getElementById('clayConfirmModal').classList.add('hidden'); clayConfirmCallback = null; } function clayConfirmYes() { var cb = clayConfirmCallback; closeClayConfirm(); if (cb) cb(); } // Attaches a left-swipe-to-reveal-delete gesture to a clay list item. Desktop/non-touch users still // get the visible 🗑️ delete button inside the card, so the gesture is additive, not the only way in. function attachSwipeToDelete(itemEl) { var startX = 0, startY = 0, dx = 0, dragging = false, decided = false, isHorizontal = false; itemEl.addEventListener('touchstart', function(e) { if (e.target.closest('.drag-handle')) return; // drag-to-reorder owns this zone startX = e.touches[0].clientX; startY = e.touches[0].clientY; dragging = true; decided = false; isHorizontal = false; }, { passive: true }); itemEl.addEventListener('touchmove', function(e) { if (!dragging) return; dx = e.touches[0].clientX - startX; var dy = e.touches[0].clientY - startY; if (!decided) { if (Math.abs(dx) > 8 || Math.abs(dy) > 8) { isHorizontal = Math.abs(dx) > Math.abs(dy); decided = true; } } if (isHorizontal) { itemEl.style.transition = 'none'; itemEl.style.transform = 'translateX(' + Math.max(-84, Math.min(0, dx)) + 'px)'; } }, { passive: true }); itemEl.addEventListener('touchend', function() { if (!dragging) return; dragging = false; itemEl.style.transition = ''; itemEl.style.transform = ''; if (isHorizontal && dx < -40) itemEl.classList.add('swiped'); else itemEl.classList.remove('swiped'); }); } function submitBranchRename() { var oldName = document.getElementById('oldBranchNameHidden').value; var newName = document.getElementById('newBranchNameInput').value.trim(); if (!newName) return showModal('Warning', 'Enter new name', 'error'); google.script.run.withSuccessHandler(function(res) { showModal(res.success ? 'Success' : 'Failed', res.message, res.success ? 'success' : 'error'); if (res.success) { closeBranchRenameModal(); loadData(); } }).withFailureHandler(function(err){ showModal('Error', err.toString(), 'error'); }).updateBranchName(user.phone, oldName, newName); } function deleteBranchAction(name) { clayConfirm('Are you sure you want to delete branch "' + name + '"?\n\nNote: The Google Sheet and all its records for this branch will be permanently deleted!', function() { google.script.run.withSuccessHandler(function(res) { showModal(res.success ? 'Success' : 'Failed', res.message, res.success ? 'success' : 'error'); if (res.success) loadData(); }).withFailureHandler(function(err){ showModal('Error', err.toString(), 'error'); }).removeBranch(user.phone, name); }); } // Bank-first: one card per master bank (data.bankList) with the branches mapped to it (data.branchBankMap is branch -> [banks]). var bankSetupData = null; function renderBranchBankSetupUI(data) { bankSetupData = data; var container = document.getElementById('branchBankSetupUI'); if (!container) return; var banks = (data.bankList || []).slice().sort(function(a, b) { return a.localeCompare(b); }); if (!banks.length) { container.innerHTML = '
No banks yet — tap “+ Add Bank”.
'; return; } container.innerHTML = banks.map(function(bk, i) { var brs = (data.branches || []).filter(function(br) { return (data.branchBankMap[br] || []).indexOf(bk) !== -1; }); return '
🏦 ' + escapeHtml(bk) + '' + '✏️🗑️
' + '
' + (brs.length ? brs.map(function(br) { return ''; }).join('') : 'Admin-level · no branch') + '
'; }).join(''); container.onclick = function(e) { var t = e.target.closest('[data-act]'); if (!t) return; var name = banks[+t.getAttribute('data-i')]; if (t.getAttribute('data-act') === 'edit') openBankModal(name); else deleteBankMaster(name); }; } function openBankModal(name) { var d = bankSetupData || cachedData || {}, mapped = {}; (d.branches || []).forEach(function(br) { if ((d.branchBankMap[br] || []).indexOf(name) !== -1) mapped[br] = 1; }); document.getElementById('bankModalOld').value = name; document.getElementById('bankModalTitle').innerText = name ? '🏦 Edit Bank — ' + name : '🏦 Add Bank'; var inp = document.getElementById('newBankName'); inp.value = name; inp.readOnly = !!name; document.getElementById('bankModalNote').innerText = name ? 'The name is fixed (past entries use it). Delete and re-add to rename.' : ''; document.getElementById('bankBranchChecks').innerHTML = (d.branches || []).map(function(br) { return ''; }).join(''); document.getElementById('addBankModal').classList.remove('hidden'); if (!name) setTimeout(function() { inp.focus(); }, 50); } function closeAddBankModal() { document.getElementById('addBankModal').classList.add('hidden'); } function saveBankModal() { var name = document.getElementById('newBankName').value.trim(); if (!name) return showModal('Warning', 'Enter bank name', 'error'); var brs = [].map.call(document.querySelectorAll('#bankBranchChecks input:checked'), function(c) { return c.value; }); google.script.run.withSuccessHandler(function(res) { showModal(res.success ? 'Success' : 'Failed', res.message, res.success ? 'success' : 'error'); if (res.success) { closeAddBankModal(); loadData(); } }).withFailureHandler(function(err) { showModal('Error', err.toString(), 'error'); }).saveBankMapping(user.phone, name, brs); } function deleteBankMaster(bankName) { clayConfirm('Delete this bank and all its branch mappings?\n\n' + bankName + '\n\nPast transactions are not changed.', function() { google.script.run.withSuccessHandler(function(res) { showModal(res.success ? 'Success' : 'Failed', res.message, res.success ? 'success' : 'error'); if (res.success) loadData(); }).withFailureHandler(function(err) { showModal('Error', err.toString(), 'error'); }).deleteBankMaster(user.phone, bankName); }); } var draggedCategoryElement = null; // Settings -> Categories: one clean card per category (name, type, branch summary, "Click to manage"). // All the real configuration (branches, staff mapping, default mode, transfer type) lives inside the // Category Settings popup (openCategorySettingsModal) instead of being shown inline here. function renderCategoryListUI() { var grid = document.getElementById('categoryListUI'); grid.innerHTML = ''; if (!cachedData) return; var type = document.getElementById('manageCatTypeSelect').value; var allBranches = cachedData.branches.concat(['Admin']); cachedData.allCategorySettings.forEach(function(cat) { if (cat.type !== type) return; var allowedArr = cat.allowed ? cat.allowed.split(',').map(function(s){ return s.trim(); }).filter(Boolean) : []; var branchSummary = allowedArr.length === 0 ? 'All Branches' : allowedArr.join(' + '); var pillClass = (type === 'IncomeCat') ? 'income' : 'expense'; var pillText = (type === 'IncomeCat') ? 'Income' : 'Expense'; var xferFlowLabel = cat.transferFlow === 'BankToBank' ? ' (Bank to Bank)' : (cat.transferFlow === 'UPIToBank' ? ' (UPI to Bank)' : (cat.transferFlow === 'BankToCash' ? ' (Bank to Cash)' : '')); var xferTag = (cat.transferType === 'BankTransfer') ? '
Bank enabled' + xferFlowLabel + '
' : ''; var card = document.createElement('div'); card.className = 'cat-card draggable-item'; card.draggable = true; card.setAttribute('data-name', cat.name); card.setAttribute('data-type', type); card.innerHTML = '⠿' + '
' + cat.name + '
' + '' + pillText + '' + '
Branch: ' + branchSummary + '
' + xferTag + ''; card.addEventListener('click', function(e) { if (e.target.closest('.drag-handle')) return; openCategorySettingsModal(type, cat.name); }); card.addEventListener('dragstart', handleDragStart); card.addEventListener('dragover', handleDragOver); card.addEventListener('drop', handleDrop); card.addEventListener('dragend', handleDragEnd); attachTouchDragEvents(card); grid.appendChild(card); }); } var catSettingsCtx = null; // { type, name } of the category currently open in the popup function openCategorySettingsModal(type, name) { var cat = cachedData.allCategorySettings.find(function(c) { return c.type === type && c.name.trim() === name.trim(); }); if (!cat) return; catSettingsCtx = { type: type, name: cat.name }; document.getElementById('catSetModalName').innerText = cat.name; document.getElementById('catSetNameDisplay').innerText = cat.name; document.getElementById('catSetTypeLabel').innerText = (type === 'IncomeCat') ? 'Income' : 'Expense'; var allowedArr = cat.allowed ? cat.allowed.split(',').map(function(s){ return s.trim(); }).filter(Boolean) : []; var allBranches = cachedData.branches.concat(['Admin']); var chkHtml = ''; allBranches.forEach(function(br) { var checked = (allowedArr.length === 0 || allowedArr.indexOf(br) !== -1) ? 'checked' : ''; chkHtml += ''; }); document.getElementById('catSetBranchesCheckboxes').innerHTML = chkHtml; document.getElementById('catSetDefaultMode').value = (cat.defaultMode || (cat.name === 'Bank' ? 'Bank' : 'Cash')).trim(); document.getElementById('catSetLinkType').value = (cat.linkType || '').trim(); document.getElementById('catSetTransferType').value = (cat.transferType || '').trim(); document.getElementById('catSetTransferFlow').value = (cat.transferFlow || 'CashToBank').trim(); onCatSetTransferTypeChange(); invCatInit('catSet', type === 'IncomeCat', (cat.linkType || '').trim() === 'Inventory', cat.invItemId || ''); retCatInit('catSet', type === 'ExpenseCat', (cat.linkType || '').trim() === 'STBReturn'); document.getElementById('categorySettingsModal').classList.remove('hidden'); } // Transfer Flow sub-field only makes sense once Transfer Type = Bank Transfer; hidden/disabled otherwise. function onCatSetTransferTypeChange() { var isBankTransfer = document.getElementById('catSetTransferType').value === 'BankTransfer'; var row = document.getElementById('catSetTransferFlowRow'); var flowSel = document.getElementById('catSetTransferFlow'); if (row) row.classList.toggle('hidden', !isBankTransfer); if (flowSel) flowSel.disabled = !isBankTransfer; } function closeCategorySettingsModal() { document.getElementById('categorySettingsModal').classList.add('hidden'); catSettingsCtx = null; } function renameFromSettingsModal() { if (!catSettingsCtx) return; closeCategorySettingsModal(); editCatPrompt(catSettingsCtx.type, catSettingsCtx.name); } function deleteCategoryFromSettingsModal() { if (!catSettingsCtx) return; var ctx = catSettingsCtx; closeCategorySettingsModal(); deleteCatPrompt(ctx.type, ctx.name); } function saveCategorySettingsModal() { if (!catSettingsCtx) return; var cat = cachedData.allCategorySettings.find(function(c) { return c.type === catSettingsCtx.type && c.name.trim() === catSettingsCtx.name.trim(); }); if (!cat) return; var chkBoxes = document.querySelectorAll('#catSetBranchesCheckboxes input[name="catSetBranch"]'); var allBranches = cachedData.branches.concat(['Admin']); var selBranches = []; chkBoxes.forEach(function(c) { if (c.checked) selBranches.push(c.value); }); // All branches checked === "All Branches" (empty string, same convention toggleCategoryBranch already used) var allowedStr = (selBranches.length === allBranches.length) ? '' : selBranches.join(','); var defaultMode = document.getElementById('catSetDefaultMode').value; var linkType = document.getElementById('catSetLinkType').value; var inv = invCatRead('catSet'); if (inv.err) return showModal('Warning', inv.err, 'error'); if (inv.on) linkType = 'Inventory'; if (retCatRead('catSet')) { if (linkType) return showModal('Warning', 'Set the Staff/Rent mapping to None to use this as the STB Return Expense category.', 'error'); linkType = 'STBReturn'; } var transferType = document.getElementById('catSetTransferType').value; var transferFlow = (transferType === 'BankTransfer') ? document.getElementById('catSetTransferFlow').value : ''; google.script.run.withSuccessHandler(function(res) { showModal(res.success ? 'Success' : 'Failed', res.message, res.success ? 'success' : 'error'); if (res.success) { closeCategorySettingsModal(); loadData(); } }).withFailureHandler(function(err) { showModal('Error', err.toString(), 'error'); }).updateCategoryConfig(user.phone, catSettingsCtx.type, catSettingsCtx.name, allowedStr, defaultMode, linkType, transferType, transferFlow, inv.on ? inv.item : ''); } function handleDragStart(e) { draggedCategoryElement = this; this.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; } function handleDragOver(e) { if (e.preventDefault) e.preventDefault(); e.dataTransfer.dropEffect = 'move'; var currentHoverItem = this; if (currentHoverItem !== draggedCategoryElement) { var bounding = currentHoverItem.getBoundingClientRect(); var offset = bounding.y + (bounding.height / 2); if (e.clientY - offset > 0) { currentHoverItem.after(draggedCategoryElement); } else { currentHoverItem.before(draggedCategoryElement); } } return false; } function handleDrop(e) { if (e.stopPropagation) e.stopPropagation(); return false; } function handleDragEnd() { this.classList.remove('dragging'); draggedCategoryElement = null; saveNewDragOrder(); } function attachTouchDragEvents(el) { var touchY = 0; var handle = el.querySelector('.drag-handle'); if (!handle) return; handle.addEventListener('touchstart', function(e) { touchY = e.touches[0].clientY; el.classList.add('dragging'); draggedCategoryElement = el; }, { passive: true }); handle.addEventListener('touchmove', function(e) { var currentY = e.touches[0].clientY; var elementUnderTouch = document.elementFromPoint(e.touches[0].clientX, currentY); if (elementUnderTouch) { var targetLi = elementUnderTouch.closest('.draggable-item'); if (targetLi && targetLi !== draggedCategoryElement && targetLi.parentNode === el.parentNode) { var bounding = targetLi.getBoundingClientRect(); var offset = bounding.y + (bounding.height / 2); if (currentY > offset) { targetLi.after(draggedCategoryElement); } else { targetLi.before(draggedCategoryElement); } } } }, { passive: true }); handle.addEventListener('touchend', function() { if (el.classList.contains('dragging')) { el.classList.remove('dragging'); draggedCategoryElement = null; saveNewDragOrder(); } }); } function saveNewDragOrder() { var ul = document.getElementById('categoryListUI'); var items = ul.querySelectorAll('.draggable-item'); var type = document.getElementById('manageCatTypeSelect').value; var orderedNames = []; items.forEach(function(item) { orderedNames.push(item.getAttribute('data-name')); }); if (orderedNames.length <= 1) return; var otherCats = cachedData.allCategorySettings.filter(function(c){ return c.type !== type; }); var mapObj = {}; cachedData.allCategorySettings.forEach(function(c){ if(c.type === type) mapObj[c.name] = c; }); var reordered = []; orderedNames.forEach(function(name){ if(mapObj[name]) reordered.push(mapObj[name]); }); cachedData.allCategorySettings = otherCats.concat(reordered); if (type === 'ExpenseCat') cachedData.expenseCats = orderedNames.slice(); else cachedData.incomeCats = orderedNames.slice(); google.script.run.withSuccessHandler(function(res) { // Saved order silently }).saveBatchCategoryOrder(user.phone, type, orderedNames); } // (Branch/Default Mode/Staff-Rent Mapping/Transfer Type are all edited together now, inside the // Category Settings popup — see openCategorySettingsModal/saveCategorySettingsModal above.) // அட்மின் அனலிடிக்ஸ் டேஷ்போர்டு சார்ட் (வரவு, செலவு, இருப்பு காட்டும் வகையில் மேம்படுத்தப்பட்டது) // Single-Ring Gradient Style — Income: Emerald→Cyan, Expense: Rose→Coral, Balance: Indigo→Violet (Branch Donut Chart-க்கு மட்டும் Scoped, Rest of App Semantic Colors மாறாது) // ---- one-time cleanup of setup-fee income entries posted by older versions (preview first, then confirm) function stbPurgeScan(scope) { var out = document.getElementById('stbPurgeOut'); out.classList.remove('hidden'); out.innerText = 'Scanning…'; google.script.run.withSuccessHandler(function(r) { if (!r || !r.success) { out.innerText = (r && r.message) || 'Failed'; return; } if (!r.count) { out.innerHTML = '✅ No STB Setup Fee entries found for ' + (scope === 'all' ? 'any month' : 'this month') + '.'; return; } out.innerHTML = '' + r.count + ' entr' + (r.count === 1 ? 'y' : 'ies') + ' · ' + ieMoney(r.total) + ' (' + (scope === 'all' ? 'all months' : 'this month') + ')
' + Object.keys(r.byBranch).map(function(b) { return escapeHtml(b) + ': ' + r.byBranch[b].n + ' · ' + ieMoney(r.byBranch[b].amt); }).join('
') + '
'; }).withFailureHandler(function(e) { showModal('Error', String(e), 'error'); }).stbPurgeSetupFees(user.phone, false, scope); } function stbPurgeRun(scope) { clayConfirm('Remove these STB Setup Fee entries from the Passbook and Income ledger? Totals will drop by the amount shown.', function() { google.script.run.withSuccessHandler(function(r) { document.getElementById('stbPurgeOut').innerText = (r && r.message) || 'Failed'; showModal(r && r.success ? 'Done' : 'Error', (r && r.message) || 'Failed', r && r.success ? 'success' : 'error'); if (r && r.success && typeof loadData === 'function') try { loadData(); } catch (e) {} }).withFailureHandler(function(e) { showModal('Error', String(e), 'error'); }).stbPurgeSetupFees(user.phone, true, scope); }, { title: 'Remove setup-fee entries', yes: 'Remove', icon: '🗑' }); } ; // ===== WhatsApp Automation Profiles — the server holds the truth; this page renders it and sends edits. Tokens are write-only. ===== var WA_SOURCES = [ ['date', 'Report Date'], ['branch', 'Branch Name (or All Branches Consolidated)'], ['income', 'Details Income (category-wise)'], ['expense', 'Details Expense (category-wise)'], ['bank', 'Details Bank Transactions'], ['totalIncome', 'Total Income Amount'], ['totalExpense', 'Total Expense Amount'], ['net', 'Net Balance Amount'], ['static', 'Custom Static Text'] ]; var waProfiles = [], waBranches = [], waDefaultPhoneId = '1363700520163146'; function waEsc(t) { return String(t == null ? '' : t).replace(/[&<>"']/g, function(c) { return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; }); } function waSrcLabel(k) { for (var i = 0; i < WA_SOURCES.length; i++) if (WA_SOURCES[i][0] === k) return WA_SOURCES[i][1]; return k; } function waHourText(h) { return ('0' + (h % 12 || 12)).slice(-2) + ':00 ' + (h < 12 ? 'AM' : 'PM'); } function waBusy(id, busy, label) { var b = document.getElementById(id); if (!b) return; b.disabled = busy; if (label) b.innerText = busy ? 'Please wait…' : label; } function toggleWaTokenVisible() { var el = document.getElementById('waPToken'); el.type = el.type === 'password' ? 'text' : 'password'; } function waFail(err) { showModal('Error', String(err), 'error'); } var waProfAt = 0; // when waProfiles was last read from the server (the Reports tab's batched call refreshes it too) function loadWaProfilesUI(force) { if (typeof stbAlertLoad === 'function') stbAlertLoad(); var box = document.getElementById('waProfileList'); if (waProfAt) renderWaProfiles(); else box.innerHTML = new Array(3).join('
'); if (waProfAt && !force && Date.now() - waProfAt < 3e5) return; google.script.run.withSuccessHandler(function(res) { if (!res || !res.success) return; waProfiles = res.profiles; waBranches = res.branches || []; waDefaultPhoneId = res.defaultPhoneId || waDefaultPhoneId; waProfAt = Date.now(); renderWaProfiles(); }).withFailureHandler(waFail).getWhatsAppProfiles(user.phone); } function renderWaProfiles() { var box = document.getElementById('waProfileList'); if (!waProfiles.length) { box.innerHTML = '
No WhatsApp profiles yet. Tap “+ Add New WhatsApp Profile”.
'; return; } box.innerHTML = waProfiles.map(function(p) { var vars = p.vars.length ? p.vars.map(function(v) { return '{{' + v.idx + '}} ' + waEsc(v.source === 'static' ? '“' + v.text + '”' : waSrcLabel(v.source)) + ''; }).join('') : 'No variables'; var last = p.last ? '
' + (p.last.ok ? '✓ Last sent ' : '✕ Last attempt ') + waEsc(p.last.at) + ' — ' + waEsc(p.last.msg) + '
' : '
Not sent yet
'; return '
' + '
' + '
' + '
' + (p.isActive ? '' : '') + '
' + '
' + '' + '
' + '
' + vars + '
' + last + '
' + '' + '' + '' + '
'; }).join(''); } function addWaVarRow(source, text) { var row = document.createElement('div'); row.className = 'wa-var-row'; row.innerHTML = '' + '' + '
' + '
'; document.getElementById('waVarRows').appendChild(row); onWaVarSrc(row.querySelector('select')); renumberWaVars(); } // Rows are always numbered 1..n in order (Meta needs {{1}}, {{2}}… with no gaps), so add/remove/move renumbers them. function removeWaVarRow(btn) { var r = btn.closest('.wa-var-row'); r.parentNode.removeChild(r); renumberWaVars(); } function moveWaVarRow(btn, d) { var r = btn.closest('.wa-var-row'), o = d < 0 ? r.previousElementSibling : r.nextElementSibling; if (!o) return; r.parentNode.insertBefore(r, d < 0 ? o : o.nextSibling); renumberWaVars(); } function renumberWaVars() { document.querySelectorAll('#waVarRows .wa-var-row').forEach(function(r, i) { r.querySelector('.wa-var-idx').innerText = '{{' + (i + 1) + '}}'; }); } function onWaVarSrc(sel) { sel.parentNode.querySelector('.wa-var-text').classList.toggle('hidden', sel.value !== 'static'); } function onWaPActive() { document.getElementById('waPActiveText').innerText = document.getElementById('waPActive').checked ? 'Active — sends daily at the chosen time' : 'Inactive — skipped'; } function openWaProfileModal(id) { var p = null; waProfiles.forEach(function(x) { if (x.id === id) p = x; }); var $ = function(i) { return document.getElementById(i); }; $('waProfileModalTitle').innerText = p ? 'Edit WhatsApp Profile' : 'New WhatsApp Profile'; $('waPId').value = p ? p.id : ''; $('waPTitle').value = p ? p.title : ''; waSetPhone(p ? p.to : ''); $('waPPhoneId').value = p ? p.phoneId : waDefaultPhoneId; $('waPToken').value = ''; $('waPToken').type = 'password'; $('waPToken').placeholder = p && p.hasToken ? 'Saved ' + p.tokenHint + ' — type to replace' : 'Paste Meta System User token'; $('waPTemplate').value = p ? p.templateName : ''; $('waPLang').value = p ? p.lang : 'ta'; $('waPScope').innerHTML = '' + waBranches.map(function(b) { return ''; }).join(''); $('waPScope').value = p ? p.scope : 'ALL'; $('waVarRows').innerHTML = ''; (p ? p.vars : ['date', 'branch', 'income', 'expense', 'bank', 'totalIncome', 'totalExpense', 'net'].map(function(s) { return { source: s }; })) .forEach(function(v) { addWaVarRow(v.source, v.text); }); var h = p ? p.hour : 8; $('waPHour').value = String(h % 12 || 12); $('waPMer').value = h < 12 ? 'AM' : 'PM'; $('waPActive').checked = p ? p.isActive : false; onWaPActive(); $('waProfileModal').classList.remove('hidden'); } function closeWaProfileModal() { document.getElementById('waProfileModal').classList.add('hidden'); } // Phone = country-code select + strict 10-digit field; stored/sent as one number (91 + 9025659296 -> 919025659296). function waSetPhone(to) { var d = String(to || '').replace(/\D/g, ''), code = '91', num = d; if (d.length > 10) { code = d.slice(0, d.length - 10); num = d.slice(-10); } var sel = document.getElementById('waPCode'); if (!Array.prototype.some.call(sel.options, function(o) { return o.value === code; })) { var o = document.createElement('option'); o.value = code; o.text = '+' + code; sel.appendChild(o); } sel.value = code; sel.dispatchEvent(new Event('change')); // refresh the enhanced dropdown label document.getElementById('waPTo').value = num.slice(-10); } function waPhone() { var n = document.getElementById('waPTo').value.replace(/\D/g, ''); return n.length === 10 ? document.getElementById('waPCode').value + n : n; // short input goes through so the server explains it } function saveWaProfileUI() { var $ = function(i) { return document.getElementById(i); }; if (waPhone().length !== $('waPCode').value.length + 10) { showModal('Check number', 'Enter a valid 10-digit mobile number (digits only).', 'error'); return; } var vars = []; document.querySelectorAll('#waVarRows .wa-var-row').forEach(function(r, i) { vars.push({ idx: i + 1, source: r.querySelector('select').value, text: r.querySelector('.wa-var-text').value }); }); var h12 = parseInt($('waPHour').value, 10) % 12; var data = { id: $('waPId').value, title: $('waPTitle').value.trim(), to: waPhone(), phoneId: $('waPPhoneId').value.trim(), token: $('waPToken').value.trim(), templateName: $('waPTemplate').value.trim(), lang: $('waPLang').value, scope: $('waPScope').value, vars: vars, hour: $('waPMer').value === 'PM' ? h12 + 12 : h12, isActive: $('waPActive').checked }; waBusy('waPSaveBtn', true, '💾 Save Profile'); google.script.run.withSuccessHandler(function(res) { waBusy('waPSaveBtn', false, '💾 Save Profile'); if (res.success) { closeWaProfileModal(); wrDirty(); loadWaProfilesUI(true); } showModal(res.success ? 'Success' : 'Failed', res.message, res.success ? 'success' : 'error'); }).withFailureHandler(function(err) { waBusy('waPSaveBtn', false, '💾 Save Profile'); waFail(err); }).saveWhatsAppProfile(user.phone, data); } function toggleWaProfile(id, box) { var want = box.checked; box.disabled = true; google.script.run.withSuccessHandler(function(res) { wrDirty(); loadWaProfilesUI(true); showModal(res.success ? 'Success' : 'Failed', res.message, res.success ? 'success' : 'error'); }).withFailureHandler(function(err) { wrDirty(); loadWaProfilesUI(true); waFail(err); }).toggleWhatsAppProfile(user.phone, id, want); } function deleteWaProfile(id) { var p = null; waProfiles.forEach(function(x) { if (x.id === id) p = x; }); clayConfirm('Delete WhatsApp profile “' + (p ? p.title : '') + '”? Its recipient, token and schedule will be removed and it will stop sending.', function() { google.script.run.withSuccessHandler(function(res) { wrDirty(); loadWaProfilesUI(true); showModal(res.success ? 'Deleted' : 'Failed', res.message, res.success ? 'success' : 'error'); }).withFailureHandler(waFail).deleteWhatsAppProfile(user.phone, id); }); } function sendWaProfileTest(id, btn) { btn.disabled = true; google.script.run.withSuccessHandler(function(res) { btn.disabled = false; showModal(res.success ? 'Accepted by Meta' : 'Meta response', res.message, res.success ? 'success' : 'error'); }).withFailureHandler(function(err) { btn.disabled = false; waFail(err); }).sendWhatsAppProfileTest(user.phone, id); } ; // ===== WhatsApp Reports — a virtual date matrix (branch x day) from ONE batched server call per month+branch, cached for the session; Refresh / any send re-reads it. ===== var waBundles = {}; // 'YYYY-MM|branch' -> {data, at}; older than 5 min is redrawn from memory at once and refreshed in the background var wrIn = false; // the branch ' + escapeHtml(s.name) + ''; }).join('') : '
No active staff.
'; attBulkSync(); } function attBulkTick(el) { if (el.checked) attBulkSel[el.value] = true; else delete attBulkSel[el.value]; attBulkSync(); } function attBulkToggleAll(on) { Array.prototype.forEach.call(document.querySelectorAll('#attBulkList input'), function(c) { c.checked = on; if (on) attBulkSel[c.value] = true; else delete attBulkSel[c.value]; }); attBulkSync(); } function attBulkSync() { var all = document.querySelectorAll('#attBulkList input'), n = 0; Array.prototype.forEach.call(all, function(c) { if (c.checked) n++; }); document.getElementById('attBulkCount').innerText = n + ' selected'; var master = document.getElementById('attBulkAll'); master.checked = all.length > 0 && n === all.length; master.indeterminate = n > 0 && n < all.length; attBulkLabel(); } // Status list follows the target date: Present only for today / past. A future date offers planned leave only, and leave always needs a reason. function attBulkLabel() { var iso = document.getElementById('attBulkDate').value, future = !!iso && iso > todayISO(), sel = document.getElementById('attBulkStatus'); var want = [['Present', '🟢 PRESENT'], ['Paid Leave', '🟣 PAID LEAVE (PL)'], ['Leave', '🟡 UNPAID LEAVE (UPL)']].filter(function(o) { return !(future && o[0] === 'Present'); }); var sig = want.map(function(o) { return o[0]; }).join(); if (sel.dataset.sig !== sig) { var prev = sel.value; sel.innerHTML = want.map(function(o) { return ''; }).join(''); sel.dataset.sig = sig; sel.value = want.some(function(o) { return o[0] === prev; }) ? prev : want[0][0]; } document.getElementById('attBulkFuture').classList.toggle('hidden', !future); var leave = sel.value !== 'Present', rem = document.getElementById('attBulkRemarks'); rem.placeholder = leave ? 'Reason for leave (required) — e.g. Medical leave' : 'Remarks (optional)'; if (!leave) rem.classList.remove('att-req'); var n = Object.keys(attBulkSel).length, btn = document.getElementById('attBulkBtn'); if (!btn.disabled) btn.innerText = '✅ Apply Bulk Attendance' + (n ? ' (' + n + ')' : ''); } function submitBulkAttendance() { var ids = Object.keys(attBulkSel), iso = document.getElementById('attBulkDate').value, status = document.getElementById('attBulkStatus').value, remarks = document.getElementById('attBulkRemarks').value.trim(); if (!ids.length) { showModal('Select staff', 'Tick at least one staff member (or "Select All Staff").', 'error'); return; } if (!iso) { showModal('Pick a date', 'Choose the target date first.', 'error'); return; } var p = iso.split('-'), dmy = p[2] + '/' + p[1] + '/' + p[0], mk = p[1] + '/' + p[0], day = Number(p[2]); var future = iso > todayISO(), leave = status !== 'Present'; if (future && !leave) { showModal('Not allowed', "Cannot mark 'Present' in advance for future dates. Only planned Paid/Unpaid leaves with reasons are allowed.", 'error'); return; } if (leave && !remarks) { document.getElementById('attBulkRemarks').classList.add('att-req'); document.getElementById('attBulkRemarks').focus(); showModal('Reason required', future ? 'Planned leave on a future date needs a reason (e.g. Medical leave, Personal function, Prior permission).' : 'Leave entries need a reason. Please fill the Remarks box.', 'error'); return; } var snaps = {}, keys = []; ids.forEach(function(id) { var key = id + '_' + mk, res = attendanceOverviewClientCache[key]; attDropOthers(id, key); if (!res) return; snaps[key] = JSON.stringify(res); keys.push(key); attSetDay(res, day, status, remarks, true); if (attShowing(key)) { attendanceCalendarCache = res; } }); if (keys.some(attShowing)) renderAttendanceCalendar(); var btn = document.getElementById('attBulkBtn'); btn.disabled = true; btn.innerHTML = ' Saving…'; attPending++; var done = function() { attPending--; btn.disabled = false; attBulkLabel(); }; google.script.run.withSuccessHandler(function(r) { done(); if (!r || !r.success) { attRevert(snaps, (r && r.message) || 'Could not save bulk attendance'); attSilentRefresh(keys); return; } showModal('Bulk attendance saved', r.message, 'success'); attSilentRefresh(keys); }).withFailureHandler(function(err) { done(); attRevert(snaps, err.toString()); attSilentRefresh(keys); }).saveBulkAttendance(user.phone, dmy, ids, status, remarks); } // ===== Remarks: corner dot -> clay popover; summary card ===== function attBindRemarkPop(grid) { if (grid.dataset.bound) return; grid.dataset.bound = '1'; var pop = document.getElementById('attRemarkPop'), pinned = false; function show(cell) { var res = attendanceCalendarCache, day = cell.getAttribute('data-day'); if (!res || !res.remarksMap || !res.remarksMap[day]) return; var mp = res.monthKey.split('/'), at = res.remarksAt && res.remarksAt[day]; pop.innerHTML = '' + ('0' + day).slice(-2) + '/' + mp[0] + '/' + mp[1] + ' · ' + escapeHtml(res.days[day] || 'No status') + '
' + escapeHtml(res.remarksMap[day]) + '
' + (at ? '🕒 Logged ' + escapeHtml(at) : '🕒 Logged time not recorded (older entry)') + ''; pop.classList.remove('hidden'); var r = cell.getBoundingClientRect(), w = pop.offsetWidth, hgt = pop.offsetHeight; pop.style.left = Math.max(8, Math.min(window.innerWidth - w - 8, r.left + r.width / 2 - w / 2)) + 'px'; pop.style.top = (r.bottom + hgt + 12 > window.innerHeight ? Math.max(8, r.top - hgt - 8) : r.bottom + 8) + 'px'; } function hide() { pop.classList.add('hidden'); pinned = false; } grid.addEventListener('mouseover', function(e) { var c = e.target.closest('.att-cell'); if (c && c.querySelector('.att-dot') && !pinned) show(c); }); grid.addEventListener('mouseout', function(e) { if (!pinned && !(e.relatedTarget && grid.contains(e.relatedTarget) && e.relatedTarget.closest('.att-cell') === e.target.closest('.att-cell'))) pop.classList.add('hidden'); }); grid.addEventListener('click', function(e) { var dot = e.target.closest('.att-dot'); if (!dot) return; e.stopPropagation(); e.preventDefault(); pinned = !pinned; if (pinned) show(dot.parentNode); else hide(); }, true); // capture: the tap on the dot must not also open the status modal document.addEventListener('click', function(e) { if (pinned && !pop.contains(e.target)) hide(); }); } function renderAttendanceRemarks(res) { var days = Object.keys(res.remarksMap || {}).map(Number).sort(function(a, b) { return a - b; }), mp = res.monthKey.split('/'); document.getElementById('attRemarksCount').innerText = days.length; document.getElementById('attRemarksBody').innerHTML = days.length ? days.map(function(d) { var at = res.remarksAt && res.remarksAt[d], st = res.days[d], tk = { 'Present': 'ok', 'Absent': 'bad', 'Leave': 'wait', 'Paid Leave': 'purple', 'Work Out': 'info' }[st] || 'grey'; return rcRow({ tone: tk, date: ('0' + d).slice(-2) + '/' + mp[0] + '/' + mp[1], title: escapeHtml(res.staffName || '-'), pills: [[escapeHtml(String(st || '-')).toUpperCase(), tk]], meta: [['Remark', '' + escapeHtml(res.remarksMap[d]) + ''], ['Logged', at ? escapeHtml(at) : 'not recorded'], ['', '\u00a0']] }); }).join('') : 'No remarks this month.'; } // Client-side-ல் Previous Completed Month கண்டுபிடிக்கும் Helper (Server Logic-ஓட Match ஆகும்; Expense Salary Suggestion-க்கும் Use ஆகும்) function getPreviousCompletedMonthKeyClient() { var now = new Date(); var prevDate = new Date(now.getFullYear(), now.getMonth() - 1, 1); var mm = prevDate.getMonth() + 1; var yyyy = prevDate.getFullYear(); return (mm < 10 ? '0' + mm : mm) + '/' + yyyy; } // ================= TECHNICIAN DASHBOARD (VIEW-ONLY — Own Data மட்டும்) ================= ; // ===== SALARY HUB (R20) — Admin only. Server: getSalaryHub* in Hub.gs. Attendance no longer carries any salary UI. ===== var salHub = { staff: [], link: null, bonus: null }; function salHubKey() { var v = document.getElementById('salHubMonth').value; return v ? v.slice(5) + '/' + v.slice(0, 4) : ''; } // 'YYYY-MM' -> 'MM/YYYY' function salHubFail(e) { showModal('Error', String(e), 'error'); } function salHubOpen() { var m = document.getElementById('salHubMonth'); if (!m.value) { var k = getPreviousCompletedMonthKeyClient(); m.value = k.slice(3) + '-' + k.slice(0, 2); } // payroll being settled now = last month's work salHubLoad(); } function salHubShift(d) { var m = document.getElementById('salHubMonth'), p = (m.value || todayISO().slice(0, 7)).split('-'), x = new Date(+p[0], +p[1] - 1 + d, 1); m.value = x.getFullYear() + '-' + ('0' + (x.getMonth() + 1)).slice(-2); salHubLoad(); } function salHubLoad() { var mk = salHubKey(); if (!mk) return; document.getElementById('salHubCards').innerHTML = '
Loading…
'; var mm = +mk.slice(0, 2); var nowM = new Date().getMonth() + 1, fest = [10, 11].indexOf(mm) > -1 || [10, 11].indexOf(nowM) > -1; // Deepavali falls in Oct/Nov: highlight while the month viewed OR today is in that window document.getElementById('salHubFestive').classList.toggle('hidden', !fest); google.script.run.withSuccessHandler(function(r) { if (!r || !r.success) { document.getElementById('salHubCards').innerHTML = '
' + escapeHtml((r && r.message) || 'Failed') + '
'; return; } salHub.staff = r.staff; document.getElementById('salHubNote').innerHTML = 'Work of ' + monthLabelFromKey(r.monthKey) + ' · paid in ' + monthLabelFromKey(r.payMonthKey) + ' (around the 7th/8th). Payable Days sync from Attendance.'; var sel = document.getElementById('salHubHistStaff'), cur = sel.value; sel.innerHTML = '' + r.staff.map(function(s) { return ''; }).join(''); sel.value = cur; csTickSoon(); salHubRenderCards(r.staff, fest); salHubLoadHistory(); }).withFailureHandler(salHubFail).getSalaryHubOverview(user.phone, mk); } // Section 1: one floating 3D row-card per staff (same row-card system as the ledgers) function salHubRenderCards(list, festive) { var box = document.getElementById('salHubCards'); if (!list.length) { box.innerHTML = '
No active staff. Add staff in Settings → Staff & Office Management.
'; return; } box.innerHTML = list.map(function(s, i) { var ok = s.status === 'CREDITED', part = s.status === 'PARTIAL', bits = []; if (s.bonusTotal) bits.push('🪔 ' + s.bonus.map(function(b) { return escapeHtml(b.label) + ': ' + ieMoney(b.amount).replace('.00', ''); }).join(' + ') + ''); if (ok) bits.push('Total Paid ' + ieMoney(s.credited) + ' · ' + escapeHtml(s.banks.join(' + ') || '-') + ' · ' + escapeHtml(s.dates.join(', '))); else if (part) bits.push('Paid ' + ieMoney(s.credited) + ' · Balance ' + ieMoney(s.balance)); return rcCard({ tone: ok ? 'ok' : (part ? 'warn' : 'wait'), dp: { day: escapeHtml((s.name || '?').charAt(0).toUpperCase()), rest: escapeHtml(s.designation || 'Staff') }, title: escapeHtml(s.name), pills: [[ok ? 'SALARY CREDITED' : (part ? 'PART PAID' : 'PENDING'), ok ? 'ok' : 'wait']], sub: bits.join('  '), meta: [['Monthly Base Salary', ieMoney(s.base)], ['Payable Days', '' + s.payableDays + 'of ' + s.totalDays + ' days (Attendance)'], ['Net Salary Payable', '' + ieMoney(s.payable) + '' + (s.bonusTotal ? 'Net ' + ieMoney(s.net) + ' + Bonus ' + ieMoney(s.bonusTotal) + '' : '')]], acts: '' + '' }); }).join(''); } function salHubLoadHistory() { var mk = salHubKey(); if (!mk) return; var box = document.getElementById('salHubHistory'); box.innerHTML = '
Loading…
'; google.script.run.withSuccessHandler(function(r) { if (!r || !r.success) { box.innerHTML = '
' + escapeHtml((r && r.message) || 'Failed') + '
'; return; } if (!r.rows.length) { box.innerHTML = '
No salary history yet.
'; return; } box.innerHTML = r.rows.map(function(h) { var ok = h.status === 'CREDITED', part = h.status === 'PARTIAL'; var parts = h.parts.length ? '
' + h.parts.map(function(p) { return '
' + escapeHtml(p.date) + '' + escapeHtml(p.bank) + '' + escapeHtml(p.how) + '' + ieMoney(p.amount) + '' + (p.linkId ? '' : '') + '
'; }).join('') + '
Total credited' + ieMoney(h.credited) + '
' : ''; return '
' + rcCard({ tone: ok ? 'ok' : (part ? 'warn' : 'wait'), dp: rcMonthParts(h.monthKey), title: escapeHtml(h.name), pills: [ok ? ['SALARY CREDITED', 'ok'] : (part ? ['PART PAID', 'warn'] : ['PENDING', 'wait'])], sub: ok ? ieMoney(h.credited) + ' via ' + escapeHtml(h.banks.join(' + ')) : (h.bonusTotal ? 'Bonus: ' + ieMoney(h.bonusTotal).replace('.00', '') : ''), meta: [['Net Salary', '' + ieMoney(h.net) + ''], ['Total Payable', ieMoney(h.payable)], ['Credited', ieMoney(h.credited)]], acts: ok ? '' : '' }) + parts + '
'; }).join(''); }).withFailureHandler(salHubFail).getSalaryHubHistory(user.phone, mk, document.getElementById('salHubHistStaff').value, document.getElementById('salHubHistCount').value); } function salHubUnlink(id) { clayConfirm('Remove this payment link?', function() { google.script.run.withSuccessHandler(function(r) { showModal(r && r.success ? 'Done' : 'Error', (r && r.message) || 'Failed', r && r.success ? 'success' : 'error'); salHubLoad(); }).withFailureHandler(salHubFail).unlinkSalaryHubPayment(user.phone, id); }, { title: 'Unlink payment', yes: 'Unlink' }); } // ---- link modal (multi-select) function salHubOpenLink(i) { salHubOpenLinkFor(salHub.staff[i].staffId, salHubKey(), salHub.staff[i].name); } function salHubOpenLinkFor(staffId, mk, name) { google.script.run.withSuccessHandler(function(r) { if (!r || !r.success) return showModal('Error', (r && r.message) || 'Failed', 'error'); if (!r.data.length) return showModal('No payments found', 'There is no unlinked salary expense on or after ' + monthLabelFromKey(mk) + '. Record it in Expense first.', 'info'); salHub.link = { staffId: staffId, mk: mk, rows: r.data, sel: {} }; document.getElementById('salHubLinkTitle').innerText = '🔗 Link payments — ' + (name || '') + ' · ' + monthLabelFromKey(mk); document.getElementById('salHubLinkHint').innerText = 'Salary is paid the following month. Tick every payment that makes up this salary (e.g. two bank halves) — they add up to one credited total.'; document.getElementById('salHubLinkList').innerHTML = r.data.map(function(x, i) { var dp = rcDateParts(x.date); return ''; }).join(''); salHubLinkSum(); document.getElementById('salHubLinkModal').classList.remove('hidden'); }).withFailureHandler(salHubFail).getSalaryHubCandidates(user.phone, staffId, mk); } function salHubPick(el) { var i = el.getAttribute('data-i'), on = !salHub.link.sel[i]; if (on) salHub.link.sel[i] = 1; else delete salHub.link.sel[i]; el.classList.toggle('on', on); el.setAttribute('aria-checked', on); salHubLinkSum(); } function salHubLinkSum() { var l = salHub.link, ids = Object.keys(l.sel), t = ids.reduce(function(a, i) { return a + l.rows[i].amount; }, 0); document.getElementById('salHubLinkTotal').innerText = 'Selected: ' + ids.length + ' · ' + ieMoney(t); document.getElementById('salHubLinkConfirm').disabled = !ids.length; } function salHubCloseLink() { document.getElementById('salHubLinkModal').classList.add('hidden'); salHub.link = null; } function salHubConfirmLink() { var l = salHub.link; if (!l) return; var ids = Object.keys(l.sel).map(function(i) { return l.rows[i].id; }); var b = document.getElementById('salHubLinkConfirm'); b.disabled = true; google.script.run.withSuccessHandler(function(r) { if (r && r.success) { salHubCloseLink(); salHubLoad(); showModal('Payments linked', r.message, 'success'); } else { b.disabled = false; showModal('Error', (r && r.message) || 'Failed', 'error'); } }).withFailureHandler(function(e) { b.disabled = false; salHubFail(e); }).linkSalaryHubPayments(user.phone, l.staffId, l.mk, ids); } // ---- festive bonus function salHubOpenBonus(i) { var s = salHub.staff[i], b = s.bonus[0]; salHub.bonus = { staffId: s.staffId }; document.getElementById('salHubBonusTitle').innerText = '🪔 Bonus — ' + s.name; document.getElementById('salHubBonusLabel').value = b ? b.label : 'Deepavali Bonus'; document.getElementById('salHubBonusLabel').disabled = !!b; document.getElementById('salHubBonusAmt').value = b ? b.amount : ''; document.getElementById('salHubBonusModal').classList.remove('hidden'); } function salHubCloseBonus() { document.getElementById('salHubBonusModal').classList.add('hidden'); } function salHubSaveBonus() { var amt = document.getElementById('salHubBonusAmt').value; google.script.run.withSuccessHandler(function(r) { if (r && r.success) { salHubCloseBonus(); salHubLoad(); } else showModal('Error', (r && r.message) || 'Failed', 'error'); }).withFailureHandler(salHubFail).saveSalaryHubBonus(user.phone, salHub.bonus.staffId, salHubKey(), amt === '' ? 0 : amt, document.getElementById('salHubBonusLabel').value); } ; // ===== INVENTORY HUB (R20) — Admin only (getInventoryAdmin carries cost data). Staff sell through the Income form (getInventoryForSale: price+stock only). ===== var invHub = { items: [], editId: '' }; function invFail(e) { showModal('Error', String(e), 'error'); } function invDone(r, ok) { showModal(r && r.success ? (ok || 'Saved') : 'Error', (r && r.message) || 'Failed', r && r.success ? 'success' : 'error'); return !!(r && r.success); } function invHubOpen() { invHubLoad(); } function invHubLoad() { google.script.run.withSuccessHandler(function(r) { if (!r || !r.success) { document.getElementById('invItems').innerHTML = '
' + escapeHtml((r && r.message) || 'Failed') + '
'; return; } invHub.items = r.items; invHub.purchases = r.purchases; invLite = null; invRender(r); if (typeof refreshVerifyAlerts === 'function') refreshVerifyAlerts(); }).withFailureHandler(invFail).getInventoryAdmin(user.phone); } function invKpi(cls, ico, num, label, sub) { return '
' + num + '' + label + '' + sub + '
'; } function invPurRow(p) { var c = rcCard({ tone: p.margin >= 0 ? 'ok' : 'bad', date: p.date, title: escapeHtml(p.item) + ' · ' + escapeHtml(p.vendor || 'No vendor') + '', pills: [[(p.margin >= 0 ? 'MARGIN ' : 'LOSS ') + p.marginPct + '%', p.margin >= 0 ? 'ok' : 'bad']], sub: 'Admin Central · sells ' + ieMoney(p.price), meta: [['Bulk Qty', '' + p.qty + ''], ['Total Cost', ieMoney(p.total)], ['Unit Cost', ieMoney(p.unit) + ' (+' + ieMoney(p.margin) + ')']] }); return '
' + c + '
'; } var INV_ICO = ['📡', '📺']; function invBrIco(i) { return INV_ICO[i] || '🏬'; } function invRender(r) { invHub.branches = r.branches || []; invHub.sold = r.sold || {}; invRenderItems(); invSrInit(); document.getElementById('invPurchases').innerHTML = r.purchases.length ? r.purchases.map(invPurRow).join('') : '
No purchases recorded.
'; csTickSoon(); } // 3-tier stock status: RED at or below the item's minimum, ORANGE while approaching it (within max(2, half the minimum) units above), GREEN otherwise. function invTier(n, min) { var band = Math.max(2, min); return n <= min ? { k: 'bad', t: 'CRITICAL SHORTAGE' } : n <= min + band ? { k: 'warn', t: 'LOW STOCK WARNING' } : { k: 'ok', t: 'HEALTHY' }; } // 3 blocks share the search box: Master Saleable Items Directory (pricing), per-item stock cards, Low Stock Warnings function invRenderItems() { var q = (document.getElementById('invSearch').value || '').trim().toLowerCase(), brs = invHub.branches || []; var list = invHub.items.filter(function(i) { return !q || (i.name + ' ' + i.category).toLowerCase().indexOf(q) !== -1; }); document.getElementById('invItems').innerHTML = list.length ? list.map(function(i) { var id = escapeHtml(i.id); return '
🏷️
' + escapeHtml(i.name) + '' + escapeHtml(i.category) + '
' + '
Selling Price' + ieMoney(i.price) + '
Low-stock alert at≤ ' + i.min + '
'; }).join('') : '
' + (q ? 'No matching item.' : 'No items yet — tap + Add Item') + '
'; invRenderLocs(); } var INV_ACC = ['#1e3a8a', '#16a34a', '#f97316', '#0ea5e9', '#9333ea', '#dc2626']; // One card per location (Admin Central + every branch in Branch Master): coloured accent edge, live count per item, Transfer Stock function invLowList(l) { // items of one location at / below their threshold (or near it) return invHub.items.map(function(i) { var judged = l === 'Admin' ? i.adminMin != null : (i.locBr || []).indexOf(l) >= 0, min = l === 'Admin' ? i.adminMin : i.min, n = invLocQty(i, l); return judged && invTier(n, min).k !== 'ok' ? { i: i, n: n, min: min } : null; }).filter(Boolean); } // One card per location: Blue / Green / Red KPI strip, then every item with a Low Stock / PURCHASE pill and count badge, then Transfer Stock function invRenderLocs() { var items = invHub.items, locs = ['Admin'].concat(invHub.branches || []), sold = invHub.sold || {}; document.getElementById('invStock').innerHTML = locs.map(function(l, k) { var tot = 0, lows = invLowList(l), rows = items.map(function(i) { var n = invLocQty(i, l), judged = l === 'Admin' ? i.adminMin != null : (i.locBr || []).indexOf(l) >= 0, t = judged ? invTier(n, l === 'Admin' ? i.adminMin : i.min) : { k: 'na' }; tot += n; return '
  • ' + escapeHtml(i.name) + '' + (t.k === 'warn' ? 'Low Stock' : t.k === 'bad' ? 'PURCHASE' : '') + '' + n + '
  • '; }).join(''); var kp = '
    ' + tot + 'Total Stock
    ' + (sold[l] || 0) + 'Dispatched / Month
    ' + '
    '; return '
    ' + (l === 'Admin' ? 'Admin Central' : escapeHtml(l)) + '' + items.length + ' items
    ' + kp + '
    '; }).join(''); } function invLowOpen(l) { var lows = invLowList(l); document.getElementById('invLowTitle').innerText = 'Low Stock — ' + (l === 'Admin' ? 'Admin Central' : l); document.getElementById('invLowBody').innerHTML = lows.length ? '' + lows.map(function(x) { var id = escapeHtml(x.i.id), br = l === 'Admin' ? '' : escapeHtml(l); return ''; }).join('') + '
    ItemBalanceMin
    ' + escapeHtml(x.i.name) + '' + x.n + '' + x.min + '' + (l === 'Admin' ? '' : '') + '
    ' : '
    ✅ No low stock items here.
    '; document.getElementById('invLowModal').classList.remove('hidden'); } function invLowGo(mode, id, br) { document.getElementById('invLowModal').classList.add('hidden'); if (mode === 'p') { invOpenPurchase(); document.getElementById('invPurItem').value = id; invPurCalc(); } else invAllocOpen(id, 'Admin', br); } // ---- Sales Report: itemized feed (paid sales, STB Setup (Free), write-offs) by Date then Item, with the live balance after each move function invSrInit() { var m = document.getElementById('invSrMonth'); if (!m.value && !document.getElementById('invSrFrom').value) m.value = todayISO().slice(0, 7); var b = document.getElementById('invSrBr'), kb = b.value, it = document.getElementById('invSrItem'), ki = it.value; b.innerHTML = '' + (invHub.branches || []).map(function(x) { return ''; }).join(''); b.value = kb; it.innerHTML = '' + invHub.items.map(function(x) { return ''; }).join(''); it.value = ki; invSrLoad(); } function invSrLoad() { var g = function(i) { return document.getElementById(i).value; }, iso = function(v) { return v ? v.split('-').reverse().join('/') : ''; }; var p = { branch: g('invSrBr'), itemId: g('invSrItem') }; if (g('invSrFrom') || g('invSrTo')) { p.from = iso(g('invSrFrom') || g('invSrTo')); p.to = iso(g('invSrTo') || g('invSrFrom')); } else if (/^\d{4}-\d{2}$/.test(g('invSrMonth'))) p.month = g('invSrMonth').slice(5) + '/' + g('invSrMonth').slice(0, 4); else return; google.script.run.withSuccessHandler(function(r) { if (!r || !r.success) { document.getElementById('invSr').innerHTML = '
    ' + escapeHtml((r && r.message) || 'Failed') + '
    '; return; } invSrRender(r); }).withFailureHandler(invFail).getInventorySalesReport(user.phone, p); } function invSrRender(r) { var T = r.totals, bb = Object.keys(T.byBranch).filter(function(b) { return b !== 'Admin' || T.byBranch.Admin; }); document.getElementById('invSrKpis').innerHTML = '
    💰 Total Monthly Sales' + ieMoney(T.revenue) + 'Paid counter sales only' + (bb.length ? ' · ' + bb.map(function(b) { return (b === 'Admin' ? 'Admin Central' : escapeHtml(b)) + ' ' + ieMoney(T.byBranch[b]); }).join(' · ') : '') + '
    ' + '
    📤 Total Dispatched Units' + T.units + ' pcsPaid Sold ' + T.paidQty + ' · Complimentary STB Free ' + T.stbQty + '
    ' + '
    📦 Net Closing Stock Balance' + T.remaining + ' pcsLive on-hand, all branches
    '; var br = function(b) { return b === 'Admin' ? 'Admin Central' : escapeHtml(b); }, pcs = function(n) { return n + ' pc' + (n === 1 ? '' : 's'); }; document.getElementById('invSr').innerHTML = r.rows.length ? r.rows.map(function(x) { var who = x.kind === 'SALE' ? (x.customer ? escapeHtml(x.customer) : 'Counter buyer') : x.kind === 'STB' ? 'CID ' + escapeHtml(x.customerId || '-') + (x.customer ? ' · ' + escapeHtml(x.customer) : '') + ' · STB ' + escapeHtml(x.serial) : escapeHtml(x.reason || ''); var pill = x.kind === 'SALE' ? ['Paid Sale', 'ok'] : x.kind === 'STB' ? ['FREE (New STB Setup)', 'info'] : ['Write-Off', 'bad']; var rate = x.kind === 'SALE' ? ieMoney(x.unit) + ' ea · ' + ieMoney(x.amount) + '' : '₹0.00'; return rcCard({ tone: pill[1], date: x.date, title: escapeHtml(x.item) + ' ' + br(x.branch) + '', pills: [pill], sub: who + (x.time ? ' · ' + escapeHtml(x.time) : ''), meta: [['Qty', '' + pcs(x.qty) + ''], ['Rate / Total', rate], ['Closing Balance', '' + x.balance + ' pcs']] }); }).join('') : '
    No sales or outflow in this period.
    '; } function invSrDay(d) { var p = d.split('/'), n = +p[0], sf = n % 10 === 1 && n !== 11 ? 'st' : n % 10 === 2 && n !== 12 ? 'nd' : n % 10 === 3 && n !== 13 ? 'rd' : 'th'; return (n < 10 ? '0' : '') + n + ' ' + ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][+p[1] - 1] + ' ' + p[2]; } // ---- Bulk Transfer: many items, one source -> one destination, validated against the source's live stock, applied in one server call function invBulkOpen() { if (!invHub.items.length) return showModal('Error', 'Add an inventory item first.', 'error'); var locs = ['Admin'].concat(invHub.branches || []), opt = function(l) { return ''; }; document.getElementById('invBulkFrom').innerHTML = locs.map(opt).join(''); invBulkFromChg(); document.getElementById('invBulkRows').innerHTML = ''; invBulkAdd(); invBtns('inv-bulk-go', function(b) { b.disabled = false; }); document.getElementById('invBulkModal').classList.remove('hidden'); csTickSoon(); } function invBulkFromChg() { var f = document.getElementById('invBulkFrom').value, d = document.getElementById('invBulkTo'), keep = d.value; d.innerHTML = ['Admin'].concat(invHub.branches || []).filter(function(l) { return l !== f; }).map(function(l) { return ''; }).join(''); if (keep && keep !== f) d.value = keep; Array.prototype.forEach.call(document.querySelectorAll('#invBulkRows .inv-brow'), invBulkRowFill); invBulkCheck(); } function invBulkRowFill(row) { var f = document.getElementById('invBulkFrom').value, s = row.querySelector('select'), keep = s.value; s.innerHTML = invHub.items.map(function(i) { return ''; }).join(''); if (keep) s.value = keep; var it = invHub.items.filter(function(x) { return x.id === s.value; })[0]; row.querySelector('.inv-bstk').innerText = 'On hand: ' + (it ? invLocQty(it, f) : 0); } function invBulkAdd() { var row = document.createElement('div'); row.className = 'inv-brow'; row.innerHTML = ''; document.getElementById('invBulkRows').appendChild(row); invBulkRowFill(row); invBulkCheck(); } function invBulkLines() { return Array.prototype.map.call(document.querySelectorAll('#invBulkRows .inv-brow'), function(r) { return { itemId: r.querySelector('select').value, qty: Math.floor(+r.querySelector('input').value || 0) }; }).filter(function(l) { return l.qty > 0; }); } function invBulkCheck() { // returns an error string or '' var f = document.getElementById('invBulkFrom').value, tot = {}, err = '', el = document.getElementById('invBulkCalc'); invBulkLines().forEach(function(l) { tot[l.itemId] = (tot[l.itemId] || 0) + l.qty; }); Object.keys(tot).forEach(function(id) { var it = invHub.items.filter(function(x) { return x.id === id; })[0]; if (it && tot[id] > invLocQty(it, f) && !err) err = it.name + ': only ' + invLocQty(it, f) + ' on hand at the source!'; }); Array.prototype.forEach.call(document.querySelectorAll('#invBulkRows .inv-brow'), function(r) { var it = invHub.items.filter(function(x) { return x.id === r.querySelector('select').value; })[0], q = Math.floor(+r.querySelector('input').value || 0); r.classList.toggle('bad', !!(it && q > invLocQty(it, f))); }); el.innerHTML = err ? '' + escapeHtml(err) + '' : (Object.keys(tot).length ? Object.keys(tot).length + ' item(s), ' + invBulkLines().reduce(function(a, l) { return a + l.qty; }, 0) + ' pcs ready to move' : 'Pick items and quantities.'); return err; } function invBulkGo() { var lines = invBulkLines(), f = document.getElementById('invBulkFrom').value, t = document.getElementById('invBulkTo').value, err = invBulkCheck(); if (!t || t === f) return showModal('Error', 'Pick a different destination!', 'error'); if (!lines.length) return showModal('Error', 'Add at least one item with a quantity!', 'error'); if (err) return showModal('Error', err, 'error'); var lock = function(v) { invBtns('inv-bulk-go', function(b) { b.disabled = v; }); }; lock(true); google.script.run.withSuccessHandler(function(r) { if (invDone(r, 'Bulk transfer done')) { document.getElementById('invBulkModal').classList.add('hidden'); invHubLoad(); invSrLoad(); } else lock(false); }).withFailureHandler(function(e) { lock(false); invFail(e); }) .transferInvBulk(user.phone, { from: f, to: t, lines: lines }); } // ---- Deduct / Scrap Stock (Admin write-off) function invScrapOpen(l) { var items = invHub.items.filter(function(i) { return invLocQty(i, l) > 0; }); if (!items.length) return showModal('Error', 'No stock to deduct at this location.', 'error'); var b = document.getElementById('invScrapBr'); b.innerHTML = ''; document.getElementById('invScrapItem').innerHTML = items.map(function(i) { return ''; }).join(''); document.getElementById('invScrapQty').value = ''; document.getElementById('invScrapWhy').value = ''; invBtns('inv-scrap-go', function(x) { x.disabled = false; }); invScrapHint(); document.getElementById('invScrapModal').classList.remove('hidden'); csTickSoon(); } function invScrapTag(b) { document.getElementById('invScrapWhy').value = b.innerText; } function invScrapHint() { var l = document.getElementById('invScrapBr').value, it = invHub.items.filter(function(x) { return x.id === document.getElementById('invScrapItem').value; })[0], q = Math.floor(+document.getElementById('invScrapQty').value || 0), el = document.getElementById('invScrapCalc'); if (!it) { el.innerHTML = ''; return; } var have = invLocQty(it, l); el.innerHTML = !q ? 'Available: ' + have + '' : q > have ? 'Only ' + have + ' unit(s) available!' : escapeHtml(it.name) + ' ' + have + ' → ' + (have - q) + ' · no revenue / cash entry'; } function invScrapGo() { var g = function(i) { return document.getElementById(i).value; }, q = Math.floor(+g('invScrapQty') || 0), it = invHub.items.filter(function(x) { return x.id === g('invScrapItem'); })[0]; if (!it) return; if (q < 1) return showModal('Error', 'Enter the quantity to deduct!', 'error'); if (q > invLocQty(it, g('invScrapBr'))) return showModal('Error', 'Only ' + invLocQty(it, g('invScrapBr')) + ' unit(s) available!', 'error'); if (!g('invScrapWhy').trim()) return showModal('Error', 'Enter the deduction reason!', 'error'); var lock = function(v) { invBtns('inv-scrap-go', function(b) { b.disabled = v; }); }; lock(true); google.script.run.withSuccessHandler(function(r) { if (invDone(r, 'Stock deducted')) { document.getElementById('invScrapModal').classList.add('hidden'); invHubLoad(); } else lock(false); }).withFailureHandler(function(e) { lock(false); invFail(e); }) .scrapInv(user.phone, { branch: g('invScrapBr'), itemId: it.id, qty: q, reason: g('invScrapWhy') }); } // ---- ⇄ Transfer Stock: any location -> any other location (Admin custody <-> branches, branch <-> branch) function invAllocItem() { return invHub.items.filter(function(x) { return x.id === invHub.allocId; })[0]; } function invLocQty(it, l) { return l === 'Admin' ? it.admin : (it.branchQty[l] || 0); } function invAllocOpen(id, from, to) { var items = invHub.items; if (!items.length) return showModal('Error', 'Add an inventory item first.', 'error'); from = from || 'Admin'; var first = id ? items.filter(function(x) { return x.id === id; })[0] : (items.filter(function(x) { return invLocQty(x, from) > 0; })[0] || items[0]); invHub.allocId = first.id; var locs = ['Admin'].concat(invHub.branches || []), opt = function(l) { return ''; }; document.getElementById('invAllocItemSel').innerHTML = items.map(function(x) { return ''; }).join(''); document.getElementById('invAllocItemSel').value = first.id; document.getElementById('invAllocItemName').innerText = first.name; document.getElementById('invAllocFrom').innerHTML = locs.map(opt).join(''); document.getElementById('invAllocFrom').value = from; invAllocFromChg(); if (to) { document.getElementById('invAllocBr').value = to; invAllocHint(); } document.getElementById('invAllocQty').value = ''; invBtns('inv-alloc-go', function(b) { b.disabled = false; }); document.getElementById('invAllocModal').classList.remove('hidden'); csTickSoon(); } function invAllocItemChg() { invHub.allocId = document.getElementById('invAllocItemSel').value; var it = invAllocItem(); document.getElementById('invAllocItemName').innerText = it ? it.name : ''; invAllocHint(); } function invBtns(cls, fn) { Array.prototype.forEach.call(document.getElementsByClassName(cls), fn); } function invAllocFromChg() { var f = document.getElementById('invAllocFrom').value, d = document.getElementById('invAllocBr'), keep = d.value; d.innerHTML = ['Admin'].concat(invHub.branches || []).filter(function(l) { return l !== f; }).map(function(l) { return ''; }).join(''); if (keep && keep !== f) d.value = keep; invAllocHint(); } function invAllocClose() { document.getElementById('invAllocModal').classList.add('hidden'); } function invAllocHint() { var it = invAllocItem(); if (!it) return; var f = document.getElementById('invAllocFrom').value, t = document.getElementById('invAllocBr').value, q = Math.floor(+document.getElementById('invAllocQty').value || 0), have = invLocQty(it, f), el = document.getElementById('invAllocCalc'); document.getElementById('invAllocAdm').innerText = have; document.getElementById('invAllocFromLbl').innerText = 'Available Item Balance · ' + (f === 'Admin' ? 'Admin Central' : f); el.innerHTML = !q ? 'Enter the quantity to move.' : q > have ? 'Only ' + have + ' unit(s) at the source!' : escapeHtml(f) + ' ' + have + ' → ' + (have - q) + ' · ' + escapeHtml(t) + ' ' + invLocQty(it, t) + ' → ' + (invLocQty(it, t) + q) + ''; } function invAllocGo() { var it = invAllocItem(), q = Math.floor(+document.getElementById('invAllocQty').value || 0), f = document.getElementById('invAllocFrom').value, t = document.getElementById('invAllocBr').value; if (!it) return; if (!t || t === f) return showModal('Error', 'Pick a different destination!', 'error'); if (q < 1) return showModal('Error', 'Enter a quantity to move!', 'error'); if (q > invLocQty(it, f)) return showModal('Error', 'Only ' + invLocQty(it, f) + ' unit(s) at the source!', 'error'); var lock = function(v) { invBtns('inv-alloc-go', function(b) { b.disabled = v; }); }; lock(true); google.script.run.withSuccessHandler(function(r) { if (invDone(r, 'Stock transferred')) { invAllocClose(); invHubLoad(); invSrLoad(); } else lock(false); }).withFailureHandler(function(e) { lock(false); invFail(e); }) .transferInv(user.phone, { itemId: it.id, from: f, to: t, qty: q, reason: '' }); } // ---- + Add Item / Edit Item modal (tap an item card) function invOpenItem(id) { var i = id ? invHub.items.filter(function(x) { return x.id === id; })[0] : null; invHub.editId = id || ''; document.getElementById('invItemTitle').innerText = i ? '✏️ Edit Item' : '+ Add Item'; document.getElementById('invItStockLbl').innerText = i ? 'Admin custody stock (a change is logged as an adjustment)' : 'Opening Stock Count (Admin custody)'; document.getElementById('invItName').value = i ? i.name : ''; document.getElementById('invItCat').value = i ? i.category : 'Accessories'; document.getElementById('invItPrice').value = i ? i.price : ''; document.getElementById('invItStock').value = i ? i.admin : ''; document.getElementById('invItMin').value = i ? i.min : 3; document.getElementById('invItAdminMin').value = i && i.adminMin != null ? i.adminMin : ''; document.getElementById('invItDel').classList.toggle('hidden', !i); invBtns('inv-item-save', function(b) { b.disabled = false; }); document.getElementById('invItemModal').classList.remove('hidden'); csTickSoon(); } function invCloseItem() { document.getElementById('invItemModal').classList.add('hidden'); } function invSaveItem() { var g = function(i) { return document.getElementById(i).value; }, lock = function(v) { invBtns('inv-item-save', function(b) { b.disabled = v; }); }; if (!g('invItName').trim()) return showModal('Error', 'Item name is required!', 'error'); lock(true); google.script.run.withSuccessHandler(function(r) { if (invDone(r, 'Item saved')) { invCloseItem(); invHubLoad(); } else lock(false); }).withFailureHandler(function(e) { lock(false); invFail(e); }) .saveInvItem(user.phone, { id: invHub.editId, name: g('invItName'), category: g('invItCat'), price: g('invItPrice'), stock: g('invItStock'), minStock: g('invItMin'), adminMin: g('invItAdminMin') }); } function invDeleteItem() { // two confirmations: deleting a master item is the one action here that cannot be undone from the screen var id = invHub.editId, i = invHub.items.filter(function(x) { return x.id === id; })[0]; if (!i) return; clayConfirm('Delete "' + i.name + '" from the master list? Current stock: ' + i.total + ' units.', function() { clayConfirm('Final check — really delete "' + i.name + '"? Past purchases and sales history stay, but it can no longer be sold or issued.', function() { google.script.run.withSuccessHandler(function(r) { if (invDone(r, 'Deleted')) { invCloseItem(); invHubLoad(); } }).withFailureHandler(invFail).deleteInvItem(user.phone, id); }, { title: 'Delete item — final confirmation', yes: 'Yes, delete' }); }, { title: 'Delete item', yes: 'Continue' }); } // ---- + Bulk Purchase modal (new, or tap a past purchase to edit / delete) and the history ledger function invOpenPurchase(id) { var p = id ? invHub.purchases.filter(function(x) { return x.id === id; })[0] : null; invHub.purEdit = p ? p.id : ''; document.getElementById('invPurTitle').innerText = p ? 'Edit Purchase' : 'Bulk Purchase'; document.getElementById('invPurDate').value = p ? p.date.split('/').reverse().join('-') : todayISO(); document.getElementById('invPurVendor').value = p ? p.vendor : ''; var sel = document.getElementById('invPurItem'); sel.innerHTML = '' + invHub.items.map(function(i) { return ''; }).join(''); sel.value = p ? p.itemId : ''; sel.disabled = !!p; document.getElementById('invPurQty').value = p ? p.qty : ''; document.getElementById('invPurTotal').value = p ? p.total : ''; document.getElementById('invPurUnit').value = p ? p.unit : ''; invBtns('inv-pur-del', function(b) { b.classList.toggle('hidden', !p); }); invBtns('inv-pur-save', function(b) { b.disabled = false; b.innerText = p ? 'Save Changes' : 'Save Purchase'; }); invPurCalc(); document.getElementById('invPurModal').classList.remove('hidden'); csTickSoon(); } function invClosePurchase() { document.getElementById('invPurModal').classList.add('hidden'); } function invOpenHistory() { var e = document.getElementById('invLedger'); if (e) { e.scrollIntoView({ behavior: 'smooth', block: 'start' }); e.classList.add('flash'); setTimeout(function() { e.classList.remove('flash'); }, 1200); } } function invPurCalc(src) { var qe = document.getElementById('invPurQty'), te = document.getElementById('invPurTotal'), ue = document.getElementById('invPurUnit'), q = +qe.value; if (src === 'u' && q && +ue.value) te.value = (Math.round(+ue.value * q * 100) / 100); else if (q && +te.value) ue.value = (Math.round(+te.value / q * 100) / 100); var t = +te.value, id = document.getElementById('invPurItem').value; var it = invHub.items.filter(function(x) { return x.id === id; })[0], el = document.getElementById('invPurCalcBox'); if (!q || !t) { el.innerHTML = ''; return; } var u = t / q, m = it && it.price ? ((it.price - u) * 100 / it.price) : null; el.innerHTML = 'Unit cost ' + ieMoney(u) + '' + (it ? ' · sells ' + ieMoney(it.price) + ' · est. margin ' + ieMoney(it.price - u) + ' (' + m.toFixed(1) + '%)' : ''); } function invAddPurchase() { var g = function(i) { return document.getElementById(i).value; }, ed = invHub.purEdit, lock = function(v) { invBtns('inv-pur-save', function(b) { b.disabled = v; }); }; var bad = !g('invPurDate') ? 'Select the Purchase Date!' : !g('invPurVendor').trim() ? 'Enter the Vendor Name!' : (!ed && !g('invPurItem')) ? 'Select the Item!' : !(Math.floor(+g('invPurQty')) >= 1) ? 'Enter a Quantity of 1 or more!' : !(+g('invPurTotal') > 0) ? 'Enter the Total Amount!' : ''; if (bad) return showModal('Error', bad, 'error'); lock(true); var h = function(r) { if (invDone(r, ed ? 'Purchase updated' : 'Purchase recorded')) { invClosePurchase(); ['invPurQty', 'invPurTotal', 'invPurUnit', 'invPurVendor'].forEach(function(i) { document.getElementById(i).value = ''; }); invHubLoad(); } else lock(false); }; var f = function(e) { lock(false); invFail(e); }; if (ed) google.script.run.withSuccessHandler(h).withFailureHandler(f).editInvPurchase(user.phone, { id: ed, date: g('invPurDate'), vendor: g('invPurVendor'), qty: g('invPurQty'), total: g('invPurTotal'), unit: g('invPurUnit') }); else google.script.run.withSuccessHandler(h).withFailureHandler(f).addInvPurchase(user.phone, { date: g('invPurDate'), vendor: g('invPurVendor'), itemId: g('invPurItem'), qty: g('invPurQty'), total: g('invPurTotal') }); } function invDeletePurchase() { var id = invHub.purEdit; if (!id) return; clayConfirm('Delete this purchase? The units are removed from Admin stock again.', function() { google.script.run.withSuccessHandler(function(r) { if (invDone(r, 'Purchase deleted')) { invClosePurchase(); invHubLoad(); } }).withFailureHandler(invFail).deleteInvPurchase(user.phone, id); }, { title: 'Delete purchase', yes: 'Delete' }); } ; // ---- Income form: a category linked to inventory (Settings -> Category -> "Link to Inventory Stock"). Staff-safe data only: name, price, BRANCH stock. var invSale = { items: [], on: false, fixed: '' }; function invSaleCat() { var n = (document.getElementById('incCategory').value || '').trim(); var c = (typeof cachedData !== 'undefined' && cachedData && cachedData.allCategorySettings || []).filter(function(x) { return x.type === 'IncomeCat' && x.name.trim() === n; })[0]; return (c && (c.linkType || '').trim() === 'Inventory') ? c : null; } function invSaleSetup() { var row = document.getElementById('incInvRow'); if (!row) return; var c = invSaleCat(); invSale.on = !!c; invSale.fixed = c ? (c.invItemId || '') : ''; row.classList.toggle('hidden', !c); if (!c) return; var br = user.role === 'Admin' ? document.getElementById('incBranchSelect').value : ''; google.script.run.withSuccessHandler(function(r) { if (!r || !r.success) { document.getElementById('incInvHint').innerText = (r && r.message) || 'Could not load items'; return; } invSale.items = r.items; invSale.branch = r.branch; var sel = document.getElementById('incInvItem'), list = invSale.fixed ? r.items.filter(function(i) { return i.id === invSale.fixed; }) : r.items; sel.innerHTML = (invSale.fixed ? '' : '') + list.map(function(i) { return ''; }).join(''); if (invSale.fixed && list.length) sel.value = invSale.fixed; sel.disabled = !!invSale.fixed; csTickSoon(); document.getElementById('incInvQty').value = 1; document.getElementById('incInvHint').innerText = !list.length ? 'No inventory items yet — Admin adds them in the Inventory tab.' : ''; if (invSale.fixed) invSaleCalc(); }).withFailureHandler(function(e) { document.getElementById('incInvHint').innerText = String(e); }).getInventoryForSale(user.phone, br); } // Total = selling price x qty, written into the (still editable) Amount so a discount can simply overwrite it. function invSaleCalc() { var it = invSale.items.filter(function(x) { return x.id === document.getElementById('incInvItem').value; })[0], q = Math.max(Math.floor(+document.getElementById('incInvQty').value || 0), 0), h = document.getElementById('incInvHint'); if (!it) { h.innerText = ''; return; } document.getElementById('incAmount').value = it.price * q; h.innerText = q + ' × ' + ieMoney(it.price).replace('.00', '') + ' = ' + ieMoney(it.price * q).replace('.00', '') + (q > it.stock ? ' · only ' + it.stock + ' in stock here!' : ' · amount is editable for a discount'); } function invSalePayload(p) { if (!invSale.on || !invSaleCat()) return ''; var id = document.getElementById('incInvItem').value, q = Math.floor(+document.getElementById('incInvQty').value || 0); var it = invSale.items.filter(function(x) { return x.id === id; })[0]; if (!it) return invSale.fixed ? 'Out of stock at this branch!' : 'Select an item!'; if (q < 1) return 'Quantity must be at least 1!'; if (q > it.stock) return it.name + ' — only ' + it.stock + ' in stock at ' + (invSale.branch || 'this branch') + '!'; p.invItemId = id; p.invQty = q; if (!p.remarks) p.remarks = it.name + ' × ' + q; return ''; } function invSaleReset() { if (!invSale.on) return; document.getElementById('incInvQty').value = 1; document.getElementById('incInvHint').innerText = ''; invSaleSetup(); } // ---- Settings -> Category: "Link to Inventory Stock" toggle (Income only). Prefix: 'catSet' (edit popup) or 'newCat' (add popup). var invLite = null; function invCatInit(pre, show, on, item) { var sec = document.getElementById(pre + 'InvSection'); if (!sec) return; sec.classList.toggle('hidden', !show); document.getElementById(pre + 'InvToggle').checked = !!(show && on); onCatInvToggle(pre, item); } function onCatInvToggle(pre, item) { var on = document.getElementById(pre + 'InvToggle').checked; document.getElementById(pre + 'InvRow').classList.toggle('hidden', !on); if (!on) return; var fill = function(list) { var s = document.getElementById(pre + 'InvItem'); s.innerHTML = '' + list.map(function(i) { return ''; }).join(''); s.value = item || s.value || ''; csTickSoon(); }; if (invLite) return fill(invLite); google.script.run.withSuccessHandler(function(r) { invLite = (r && r.items) || []; fill(invLite); }).withFailureHandler(function() {}).getInventoryItemsLite(user.phone); } function invCatRead(pre) { var t = document.getElementById(pre + 'InvToggle'), sec = document.getElementById(pre + 'InvSection'); if (!t || !sec || sec.classList.contains('hidden') || !t.checked) return { on: false, item: '' }; var v = document.getElementById(pre + 'InvItem').value; return v ? { on: true, item: v } : { on: true, item: '', err: 'Select the inventory sale item to link!' }; } // ---- Settings -> Category: "STB Return Expense" switch (Expense only, default OFF) function retCatInit(pre, show, on) { var sec = document.getElementById(pre + 'RetSection'); if (!sec) return; sec.classList.toggle('hidden', !show); document.getElementById(pre + 'RetToggle').checked = !!(show && on); } function retCatRead(pre) { var t = document.getElementById(pre + 'RetToggle'), sec = document.getElementById(pre + 'RetSection'); return !!(t && sec && !sec.classList.contains('hidden') && t.checked); } ; // ===== STB STOCK & ACTIVATION (R20). Staff: own branch IN_STOCK only (server-filtered). Admin: import / assign / log on top. ===== var stbHub = { avail: [], pkgs: [], branches: [], bundle: [], bundles: {}, sel: {}, serial: '', branch: '', admin: null, scope: 'All', status: 'IN_STOCK', sources: [], editing: '' }; var STB_LOW = 3, STB_LOWS = {}; // overwritten by stbLowLoad() function stbLowOf(loc) { return STB_LOWS[loc] != null ? STB_LOWS[loc] : STB_LOW; } // a location with this many boxes (or fewer) in stock shows the Reorder warning function stbFail(e) { showModal('Error', String(e), 'error'); } function stbHubOpen() { var isAdmin = user.role === 'Admin'; document.getElementById('stbAdminBox').classList.toggle('hidden', !isAdmin); document.getElementById('stbAddBtn').classList.toggle('hidden', !isAdmin); document.getElementById('stbMoveBtn').classList.toggle('hidden', !isAdmin); document.getElementById('stbBulkBtn').classList.toggle('hidden', !isAdmin); document.getElementById('stbArchPanel').classList.toggle('hidden', !isAdmin); document.getElementById('stbHubTitle').innerHTML = isAdmin ? stbSvg('stb', 22) + ' STB Stock ' : stbSvg('stb', 22) + ' New STB '; document.getElementById('stbBranchTag').innerText = isAdmin ? 'All branches' : (user.branch || ''); google.script.run.withSuccessHandler(function(r) { if (!r || !r.success) { document.getElementById('stbAvail').innerHTML = '
    ' + escapeHtml((r && r.message) || 'Failed') + '
    '; return; } stbHub.avail = r.rows; stbHub.pkgs = r.packages; stbHub.branches = r.branches || []; stbHub.bundle = r.bundle || []; stbHub.bundles = r.bundles || {}; stbHub.sources = r.sources || []; stbHub.awaiting = r.awaiting || 0; var live = {}; r.rows.forEach(function(x) { live[x.serial] = 1; }); Object.keys(stbHub.sel).forEach(function(k) { if (!live[k]) delete stbHub.sel[k]; }); stbRenderAll(); }).withFailureHandler(stbFail).getStbStock(user.phone); if (isAdmin) { stbAdminLoad(); if (typeof refreshVerifyAlerts === "function") refreshVerifyAlerts(); } } function stbKpi(cls, ico, num, label, sub) { return '
    ' + num + '' + label + '' + sub + '
    '; } function stbRenderKpis() { // LIVE numbers only: unassigned, physically available boxes. No lifetime / dispatched counters. var el = document.getElementById('stbKpis'); if (user.role !== 'Admin') { el.innerHTML = stbKpi('k-gold', stbSvg('stb', 22), stbHub.avail.length, 'Live In-Stock STBs', escapeHtml(user.branch || '')); return; } var r = stbHub.admin; if (!r) { el.innerHTML = ''; return; } var s = r.summary, inS = function(k) { return (s[k] || {}).IN_STOCK || 0; }, all = 0; ['Admin'].concat(r.branches).forEach(function(k) { all += inS(k); }); var kp = function(cls, loc, ico, num, label, sub) { return '
    ' + num + '' + label + '' + sub + '
    '; }; var loc = function(name, ico, label, sub) { var n = inS(name), lim = stbLowOf(name), st = n <= lim ? 'bad' : (n === lim + 1 ? 'warn' : 'ok'); return kp(st === 'bad' ? 'k-bad' : st === 'warn' ? 'k-amber' : 'k-gold', name, ico, n, label, (st === 'bad' ? '🔴 LOW STOCK · alert ≤ ' + lim : st === 'warn' ? '🟠 Nearly low · alert ≤ ' + lim : sub + ' · alert ≤ ' + lim)); }; el.innerHTML = kp('', '', stbSvg('layers', 22), all, 'Total Live In-Stock STBs', 'Unassigned boxes · all locations · default alert ≤ ' + STB_LOW) + loc('Admin', stbSvg('building', 22), 'Admin Central Stock', 'Physical boxes held centrally') + r.branches.map(function(b, k) { return loc(b, stbSvg(k === 1 ? 'tv' : 'pin', 22), escapeHtml(b), 'Live in-stock'); }).join(''); } // ---- KPI card -> low-stock threshold pop-up ('' = default limit used by locations without their own) function stbThrOpen(loc) { if (user.role !== 'Admin') return; stbHub.thr = loc; var nm = loc === '' ? 'Total Boxes (default for all locations)' : (loc === 'Admin' ? 'Admin Custody' : loc); document.getElementById('stbThrTitle').innerText = '⚠️ ' + nm; document.getElementById('stbThrInfo').innerHTML = loc === '' ? 'This default applies to every location that has no limit of its own.' : 'Boxes in stock now: ' + (((stbHub.admin && stbHub.admin.summary[loc]) || {}).IN_STOCK || 0) + ''; document.getElementById('stbThrVal').value = loc === '' ? STB_LOW : stbLowOf(loc); document.getElementById('stbThrSave').disabled = false; document.getElementById('stbThrModal').classList.remove('hidden'); csTickSoon(); setTimeout(function() { try { document.getElementById('stbThrVal').select(); } catch (e) {} }, 30); } function stbThrClose() { document.getElementById('stbThrModal').classList.add('hidden'); } function stbThrSave() { var loc = stbHub.thr, v = document.getElementById('stbThrVal').value, b = document.getElementById('stbThrSave'); if (v === '' || isNaN(+v) || +v < 0) return showModal('Error', 'Enter a limit of 0 or more!', 'error'); b.disabled = true; google.script.run.withSuccessHandler(function(r) { if (r && r.success) { var n = Math.round(+v); if (loc === '') STB_LOW = n; else STB_LOWS[loc] = n; stbThrClose(); stbRenderKpis(); if (typeof refreshVerifyAlerts === 'function') refreshVerifyAlerts(); } else b.disabled = false; showModal(r && r.success ? 'Saved' : 'Error', (r && r.message) || 'Failed', r && r.success ? 'success' : 'error'); }).withFailureHandler(function(e) { b.disabled = false; stbFail(e); }).saveStbLowLimit(user.phone, loc, v); } function stbLowLoad() { google.script.run.withSuccessHandler(function(r) { if (!r || !r.success) return; STB_LOW = r.cfg.low != null ? r.cfg.low : 3; STB_LOWS = r.cfg.lows || {}; if (stbHub.admin) stbRenderKpis(); }).withFailureHandler(function() {}).getStbReturnCfg(user.phone); } function stbRenderAll() { stbRenderKpis(); stbRenderAvail(); } function stbRenderAvail() { var q = (document.getElementById('stbSearch').value || '').trim().toUpperCase(), isAdmin = user.role === 'Admin'; var src = isAdmin && stbHub.admin ? stbHub.admin.rows : stbHub.avail.map(function(x) { x.status = 'IN_STOCK'; return x; }); var locs = isAdmin ? ['Admin'].concat(stbHub.branches || []).filter(function(l) { return stbHub.scope === 'All' || stbHub.scope === l; }) : [src.length ? src[0].branch : user.branch]; var nm = function(l) { return l === 'Admin' ? 'Admin Central' : l; }; document.getElementById('stbAvail').innerHTML = locs.map(function(l, k) { var all = src.filter(function(x) { return x.branch === l && x.status === 'IN_STOCK'; }), list = all.filter(function(x) { return !q || (x.serial + ' ' + (x.model || '') + ' ' + (x.customer || '')).toUpperCase().indexOf(q) !== -1; }); var cards = list.map(function(x) { // one row per box: Transfer + Allocate act on THIS serial only var e = escapeHtml(x.serial); return '
    ' + e + '' + (isAdmin ? '' : '') + (x.model ? '' + escapeHtml(x.model) + '' : '') + '
    ' + 'IN STOCK' + '
    ' + (isAdmin ? '' : '') + '' + '
    '; }).join(''); return '
    ' + stbSvg(l === 'Admin' ? 'building' : 'pin', 18) + ' ' + escapeHtml(nm(l)) + '' + all.length + ' in stock
    ' + (cards ? '
    ' + cards + '
    ' : stbEmpty('stb', q ? 'No matching STB.' : 'No STB in stock here.' + (isAdmin ? ' Use + Add STB.' : ' Ask Admin to add boxes.'))) + '
    '; }).join('') || stbEmpty('stb', 'No STB here.'); } // ---- Admin: pencil on a box -> correct its serial number (all history follows) function stbRnOpen(serial) { if (user.role !== 'Admin') return; stbHub.rn = serial; document.getElementById('stbRnOld').value = serial; document.getElementById('stbRnNew').value = serial; document.getElementById('stbRnSave').disabled = false; document.getElementById('stbRnModal').classList.remove('hidden'); setTimeout(function() { try { document.getElementById('stbRnNew').select(); } catch (e) {} }, 30); } function stbRnClose() { document.getElementById('stbRnModal').classList.add('hidden'); } function stbRnSave() { var n = (document.getElementById('stbRnNew').value || '').trim().toUpperCase().replace(/\s+/g, ''), b = document.getElementById('stbRnSave'); if (n.length < 4) return showModal('Error', 'Enter the corrected serial number!', 'error'); if (n === stbHub.rn) return showModal('Error', 'That is already the serial number.', 'error'); b.disabled = true; google.script.run.withSuccessHandler(function(r) { if (r && r.success) { stbRnClose(); stbHubOpen(); } else b.disabled = false; showModal(r && r.success ? 'Serial updated' : 'Error', (r && r.message) || 'Failed', r && r.success ? 'success' : 'error'); }).withFailureHandler(function(e) { b.disabled = false; stbFail(e); }).stbRenameSerial(user.phone, stbHub.rn, n); } // ---- STB Assigned (Monthly Archive): Admin; defaults to the current month, one month at a time function stbArchLoad() { var el = document.getElementById('stbArchMon'); if (!el.value) el.value = todayISO().slice(0, 7); google.script.run.withSuccessHandler(function(r) { if (!r || !r.success) { document.getElementById('stbArchList').innerHTML = '
    ' + escapeHtml((r && r.message) || 'Failed') + '
    '; return; } if (r.month !== el.value) return; // a newer month was picked meanwhile stbArch.rows = r.rows; stbArch.r = r; stbArch.month = r.month; stbArchRender(); }).withFailureHandler(function() {}).getStbAssigned(user.phone, el.value); } function stbArchRender() { var q = (document.getElementById('stbArchSearch').value || '').trim().toLowerCase(), r = stbArch.r; if (!r) return; var l = stbArch.rows.filter(function(x) { return !q || [x.customerId, x.customer, x.mobile, x.serial].join(' ').toLowerCase().indexOf(q) !== -1; }), tot = l.reduce(function(a, x) { return a + (Number(x.fee) || 0); }, 0); document.getElementById('stbArchSum').innerHTML = '' + escapeHtml(stbMonthLabel(r.month)) + ': ' + l.length + ' box' + (l.length === 1 ? '' : 'es') + ' assigned · setup fees ' + ieMoney(tot) + ''; document.getElementById('stbArchList').innerHTML = l.length ? l.map(stbArchCard).join('') : '
    ' + (q ? 'No matching assignment.' : 'No STB assigned in this month yet.') + '
    '; } function stbDMY(s) { var m = /(\d{2})\/(\d{2})\/(\d{4})/.exec(s || ''); return m ? new Date(+m[3], +m[2] - 1, +m[1]) : null; } function stbTurn(a, b) { var d1 = stbDMY(a), d2 = stbDMY(b); if (!d1 || !d2) return ''; var n = Math.round((d2 - d1) / 864e5); return n <= 0 ? '0 Days (Same Day)' : n + (n === 1 ? ' Day' : ' Days'); } // calendar days between the two dates function stbArchCard(x) { var e = escapeHtml(x.serial), st = x.stage === 'ACT_PENDING' ? ['AWAITING ACTIVATION', 'warn'] : (x.install === 'PENDING' ? ['AWAITING INSTALLATION', 'info'] : (x.exch ? ['EXCHANGE COMPLETED', 'ok'] : ['INSTALLED', 'ok'])); var act = x.stage === 'ACT_PENDING' ? '' : (x.apprAt || x.at || ''), inst = x.install === 'DONE' ? (x.instAt || '') : '', tt = act && inst ? stbTurn(act, inst) : '', d = function(s) { return s ? escapeHtml(s.split(' ')[0]) : '—'; }; var pills = (x.exch ? [['EXCHANGE', 'info'], ['Replaced: ' + escapeHtml(x.exch), 'grey']] : []).concat([[st[0], st[1]], ['CID: ' + escapeHtml(x.customerId), 'grey'], [escapeHtml(x.pkg || 'No package') + (x.pkgPrice != null ? ' · ' + ieMoney(x.pkgPrice).replace('₹ ', '₹') + '/month' : ''), 'purple']]); if (x.exWhy) pills.push([escapeHtml(x.exWhy), 'warn']); if (tt) pills.push(['⚡ Turnaround: ' + tt, 'ok']); return rcCard({ tone: st[1] === 'ok' ? 'ok' : 'info', date: (x.at || '').split(' ')[0], title: escapeHtml(x.customer) + ' · STB ' + e + '', pills: pills, sub: escapeHtml(x.branch) + ' · ' + escapeHtml(x.mobile) + (x.referral ? ' · Ref ' + escapeHtml(x.referral) : ''), meta: [['Activation Date', d(act)], ['Installation Date', d(inst)], ['Package', escapeHtml(x.pkg || '-') + (x.pkgPrice != null ? ' · ' + ieMoney(x.pkgPrice) : '')], ['Collected', '' + ieMoney(x.fee) + '']], acts: rcBtn('gold', 'Edit', "stbActEditOpen('" + e + "','arch')") + rcBtn('gold', 'Move', "stbQmOpen('" + e + "')") + rcBtn('danger', 'Reject / Revert', "stbActDelete('" + e + "','queue')") }); } function stbQmOpen(serial) { var x = stbArch.rows.filter(function(r) { return r.serial === serial; })[0]; if (!x) return; stbArch.mv = serial; document.getElementById('stbQmTitle').innerText = 'Move STB ' + serial; document.getElementById('stbQmReason').value = ''; document.getElementById('stbQmAct').disabled = x.stage === 'ACT_PENDING'; document.getElementById('stbQmInst').disabled = x.stage !== 'ACT_PENDING' && x.install === 'PENDING'; document.getElementById('stbQmModal').classList.remove('hidden'); } function stbQmReasonChk() { var r = document.getElementById('stbQmReason').value; if (!r) showModal('Error', 'Select a reason for the move.', 'error'); return r; } function stbQmClose() { document.getElementById('stbQmModal').classList.add('hidden'); } function stbQmGo(target) { var why = stbQmReasonChk(); if (!why) return; google.script.run.withSuccessHandler(function(r) { if (r && r.success) { stbQmClose(); stbArchLoad(); stbBadgeRefresh(); } showModal(r && r.success ? 'Moved' : 'Error', (r && r.message) || 'Failed', r && r.success ? 'success' : 'error'); }) .withFailureHandler(function(e) { showModal('Error', String(e), 'error'); }).stbMoveQueue(user.phone, stbArch.mv, target, why); } // ---- Admin: edit / delete / revert one box function stbOpenEdit(serial) { var x = (stbHub.admin ? stbHub.admin.rows : []).filter(function(r) { return r.serial === serial; })[0]; if (!x) return; var asg = x.status !== 'IN_STOCK'; stbHub.editing = serial; document.getElementById('stbEdSerial').value = x.serial; document.getElementById('stbEdModel').value = x.model || ''; document.getElementById('stbEdBranch').innerHTML = ['Admin'].concat(stbHub.branches).map(function(b) { return ''; }).join(''); document.getElementById('stbEdBranch').value = x.branch; document.getElementById('stbEdBranch').disabled = asg; var inf = document.getElementById('stbEdInfo'); inf.classList.toggle('hidden', !asg); inf.innerHTML = asg ? '' + escapeHtml(x.customer) + ' · ' + escapeHtml(x.customerId) + ' · ' + escapeHtml(x.mobile) + '
    ' + escapeHtml(x.address || '') + '
    ' + escapeHtml(x.pkg || '-') + ' · ' + ieMoney(x.fee) + (x.acc ? ' · 🎁 ' + escapeHtml(x.acc) : '') + (x.govt ? '
    🪪 ID proof: ' + escapeHtml(x.govt) : '') : ''; document.getElementById('stbEdDel').classList.toggle('hidden', asg); document.getElementById('stbEdRev').classList.toggle('hidden', !asg); document.getElementById('stbEdSave').disabled = false; document.getElementById('stbEditTitle').innerText = '✏️ ' + serial; document.getElementById('stbEditModal').classList.remove('hidden'); csTickSoon(); } function stbCloseEdit() { document.getElementById('stbEditModal').classList.add('hidden'); } function stbDoneEdit(r, t) { if (r && r.success) { stbCloseEdit(); stbHub.sel = {}; stbHubOpen(); } showModal(r && r.success ? t : 'Error', (r && r.message) || 'Failed', r && r.success ? 'success' : 'error'); } function stbSaveEdit() { var b = document.getElementById('stbEdSave'); b.disabled = true; google.script.run.withSuccessHandler(function(r) { b.disabled = false; stbDoneEdit(r, 'Saved'); }).withFailureHandler(function(e) { b.disabled = false; stbFail(e); }) .stbEdit(user.phone, { serial: stbHub.editing, branch: document.getElementById('stbEdBranch').value, model: document.getElementById('stbEdModel').value }); } function stbDeleteBox() { var s = stbHub.editing; clayConfirm('Delete STB ' + s + ' permanently? Use this only for mistyped or faulty entries.', function() { google.script.run.withSuccessHandler(function(r) { stbDoneEdit(r, 'Deleted'); }).withFailureHandler(stbFail).stbDelete(user.phone, s); }, { title: 'Delete STB', yes: 'Delete' }); } function stbRevertBox() { var s = stbHub.editing; clayConfirm('Revert ' + s + ' to IN_STOCK? The customer link is cleared, the setup-fee income entry is reversed and the bundled accessories go back to the branch stock.', function() { google.script.run.withSuccessHandler(function(r) { stbDoneEdit(r, 'Reverted'); if (r && r.success && typeof loadData === 'function') try { loadData(); } catch (e) {} }).withFailureHandler(stbFail).stbRevert(user.phone, s); }, { title: 'Revert status', yes: 'Revert' }); } // ---- Admin activation alert settings (panel lives in WhatsApp Settings) function stbAlertLoad() { google.script.run.withSuccessHandler(function(r) { if (!r || !r.success) return; var c = r.cfg || {}; document.getElementById('stbAlProfile').innerHTML = '' + r.profiles.map(function(p) { return ''; }).join(''); document.getElementById('stbAlProfile').value = c.profileId || ''; document.getElementById('stbAlOn').checked = !!c.enabled; document.getElementById('stbAlMode').value = c.mode || 'text'; document.getElementById('stbAlTo').value = c.to || ''; document.getElementById('stbAlTpl').value = c.template || ''; document.getElementById('stbAlLang').value = c.lang || ''; csTickSoon(); }).withFailureHandler(stbFail).getStbAlertCfg(user.phone); } function stbSaveAlert() { var g = function(i) { return document.getElementById(i).value; }; google.script.run.withSuccessHandler(function(r) { showModal(r && r.success ? 'Saved' : 'Error', (r && r.message) || 'Failed', r && r.success ? 'success' : 'error'); }).withFailureHandler(stbFail) .saveStbAlertCfg(user.phone, { enabled: document.getElementById('stbAlOn').checked, profileId: g('stbAlProfile'), mode: g('stbAlMode'), template: g('stbAlTpl'), lang: g('stbAlLang'), to: g('stbAlTo') }); } function stbTestAlert() { google.script.run.withSuccessHandler(function(r) { showModal(r && r.success ? 'Test sent' : 'Not sent', (r && r.message) || 'Failed', r && r.success ? 'success' : 'error'); }).withFailureHandler(stbFail).testStbAlert(user.phone); } // ---- activation function stbOpenAct(serial) { var row = ((user.role === 'Admin' && stbHub.admin ? stbHub.admin.rows : stbHub.avail)).filter(function(x) { return x.serial === serial; })[0] || {}; stbHub.serial = serial; ['stbCid', 'stbCname', 'stbMobile', 'stbGovt', 'stbAddr', 'stbFee'].forEach(function(i) { document.getElementById(i).value = ''; }); stbPaySet(document.querySelector('#stbPay [data-v="Cash"]')); document.getElementById('stbSrc').value = ''; document.getElementById('stbActModelTag').innerText = row.model ? '· ' + row.model : ''; document.getElementById('stbActTitle').innerText = '📡 Customer Setup — ' + serial; var isA = user.role === 'Admin', bs = document.getElementById('stbActBrSel'), here = row.branch || user.branch || ''; document.getElementById('stbActSerial').value = serial; stbHub.actFrom = here; document.getElementById('stbActBranch').classList.toggle('hidden', isA); bs.classList.toggle('hidden', !isA); document.getElementById('stbActBrWarn').classList.add('hidden'); document.getElementById('stbActBranchLbl').innerText = isA ? 'Branch *' : 'Branch'; document.getElementById('stbActBranch').value = here; bs.innerHTML = ['Admin'].concat(stbHub.branches || []).map(function(b) { return ''; }).join(''); bs.value = here; document.getElementById('stbActDate').value = todayISO(); stbPkgRender(here, ''); stbHub.accSel = null; stbAccRender(here); document.getElementById('stbActBtn').disabled = false; csTickSoon(); document.getElementById('stbActModal').classList.remove('hidden'); } // accessory checklist follows the branch the box is being activated for ('' = Admin custody, branch not chosen yet -> shared defaults) function stbAccRender(loc) { var l = (stbHub.bundles && stbHub.bundles[loc]) || stbHub.bundle || [], keep = stbHub.accSel; // keep = the user's own picks, so switching branch never forces the defaults back document.getElementById('stbAccField').classList.toggle('hidden', !l.length); document.getElementById('stbAccBox').innerHTML = l.map(function(b) { var on = keep && keep[b.itemId] != null ? keep[b.itemId] : b.def !== false; return ''; }).join(''); } function stbAccKeep() { stbHub.accSel = stbHub.accSel || {}; Array.prototype.forEach.call(document.getElementsByName('stbAcc'), function(c) { stbHub.accSel[c.value] = c.checked; c.parentNode.classList.toggle('on', c.checked); }); } // package list = only packages mapped to the chosen branch (a package with no mapping is open to every branch) function stbPkgRender(br, keep) { var ok = (stbHub.pkgs || []).filter(function(p) { return !(p.branches || []).length || p.branches.indexOf(br) >= 0; }); document.getElementById('stbPkg').innerHTML = '' + ok.map(function(p) { return ''; }).join(''); if (keep && ok.some(function(p) { return p.name === keep; })) document.getElementById('stbPkg').value = keep; } function stbActBranchChange() { var s = document.getElementById('stbActBrSel').value; document.getElementById('stbActBrWarn').classList.add('hidden'); stbAccRender(s); stbPkgRender(s, document.getElementById('stbPkg').value); } function stbActToEdit() { var s = stbHub.serial; stbCloseAct(); stbOpenEdit(s); } function stbCloseAct() { document.getElementById('stbActModal').classList.add('hidden'); } function stbSubmitAct() { var g = function(i) { return document.getElementById(i).value; }; var acc = Array.prototype.filter.call(document.getElementsByName('stbAcc'), function(c) { return c.checked; }).map(function(c) { return c.value; }); var bs = document.getElementById('stbActBrSel'), needBr = !bs.classList.contains('hidden'); if (needBr && (!bs.value || bs.value === 'Admin')) { document.getElementById('stbActBrWarn').classList.remove('hidden'); bs.focus(); return; } var b = document.getElementById('stbActBtn'); b.disabled = true; google.script.run.withSuccessHandler(function(r) { if (r && r.success) { stbCloseAct(); showModal('Activated', r.message, 'success'); stbHubOpen(); stbBadgeRefresh(); if (typeof loadData === 'function') try { loadData(); } catch (e) {} } else { b.disabled = false; showModal('Error', (r && r.message) || 'Failed', 'error'); } }).withFailureHandler(function(e) { b.disabled = false; stbFail(e); }) .stbActivate(user.phone, { serial: stbHub.serial, customerId: g('stbCid'), customerName: g('stbCname'), mobile: g('stbMobile'), govtId: g('stbGovt'), address: g('stbAddr'), fee: g('stbFee') || 0, payMode: stbPayVal(), pkg: g('stbPkg'), referral: g('stbSrc'), accessories: acc, branch: needBr ? bs.value : '', date: g('stbActDate')}); } // ---- admin function stbAdminLoad() { google.script.run.withSuccessHandler(function(r) { if (!r || !r.success) return; stbHub.admin = r; stbRenderAll(); stbLowLoad(); stbArchLoad(); csTickSoon(); stbPkgLoad(); stbBundleRender(); }).withFailureHandler(stbFail).getStbAdmin(user.phone); } function stbLocOpts() { return ['Admin'].concat(stbHub.branches || []).map(function(l) { return ''; }).join(''); } // ---- Bulk STB Inward: type / scan into one box -> live numbered pills -> one atomic stbImport var stbBulk = { items: [], valid: 0 }; function stbSerialOk(s) { return /^[A-Z0-9][A-Z0-9\-_.\/]{3,39}$/.test(s); } function stbBulkTokens() { return document.getElementById('stbImportText').value.split(/[\s,;]+/).map(function(s) { return s.trim().toUpperCase(); }).filter(Boolean); } function stbOpenAdd() { var t = document.getElementById('stbImportText'); t.value = ''; document.getElementById('stbImportDate').value = todayISO(); document.getElementById('stbImportTo').innerHTML = stbLocOpts(); stbBulkRender(); document.getElementById('stbAddModal').classList.remove('hidden'); csTickSoon(); setTimeout(function() { t.focus(); }, 60); } function stbCloseAdd() { document.getElementById('stbAddModal').classList.add('hidden'); } function stbBulkClear() { document.getElementById('stbImportText').value = ''; stbBulkRender(); stbCloseAdd(); } // ✕ on a pill: drop that entry from the box (the box stays the single source of truth) function stbBulkDrop(i) { var t = stbBulkTokens(); t.splice(i, 1); document.getElementById('stbImportText').value = t.join('\n') + (t.length ? '\n' : ''); stbBulkRender(); document.getElementById('stbImportText').focus(); } function stbBulkRender() { var have = {}; ((stbHub.admin && stbHub.admin.rows) || []).forEach(function(x) { have[x.serial] = 1; }); var seen = {}, ok = 0, dup = 0, bad = 0; stbBulk.items = stbBulkTokens().map(function(s, i) { var st = 'ok', why = ''; if (!stbSerialOk(s)) { st = 'bad'; why = s.length < 4 ? 'Too short (min 4)' : s.length > 40 ? 'Too long (max 40)' : 'Invalid characters'; bad++; } else if (seen[s]) { st = 'dup'; why = 'Duplicate in this list'; dup++; } else if (have[s]) { st = 'dup'; why = 'Already in stock'; dup++; } else { ok++; seen[s] = 1; } return { n: i + 1, s: s, st: st, why: why }; }); var n = stbBulk.items.length; stbBulk.valid = ok; document.getElementById('stbBulkTotal').textContent = '🔢 Total STBs Entered: ' + n; document.getElementById('stbBulkChips').innerHTML = (dup ? '⚠️ Duplicate ' + dup + '' : '') + (bad ? '⚠️ Invalid ' + bad + '' : '') + (n && !dup && !bad ? 'No duplicates' : ''); document.getElementById('stbBulkList').innerHTML = stbBulk.items.map(function(x, i) { return '#' + x.n + ' ' + escapeHtml(x.s) + (x.why ? ' ' + x.why + '' : '') + ''; }).join(''); var b = document.getElementById('stbAddSave'); b.disabled = !ok; b.textContent = 'Confirm Bulk Import (' + ok + ' STB' + (ok === 1 ? '' : 's') + ')'; } function stbDoImport() { var b = document.getElementById('stbAddSave'), list = stbBulk.items.filter(function(x) { return x.st === 'ok'; }).map(function(x) { return x.s; }); if (!list.length) return; b.disabled = true; google.script.run.withSuccessHandler(function(r) { showModal(r && r.success ? 'STB added' : 'Error', (r && r.message) || 'Failed', r && r.success ? 'success' : 'error'); if (r && r.success) { stbBulkClear(); stbHubOpen(); } else b.disabled = false; }).withFailureHandler(function(e) { b.disabled = false; stbFail(e); }).stbImport(user.phone, list.join('\n'), document.getElementById('stbImportTo').value, '', { date: document.getElementById('stbImportDate').value }); } // ---- Add Single STB function stbOpenOne() { ['stbOneSerial', 'stbOneRemarks'].forEach(function(i) { document.getElementById(i).value = ''; }); document.getElementById('stbOneDate').value = todayISO(); document.getElementById('stbOneTo').innerHTML = stbLocOpts(); document.getElementById('stbOneSave').disabled = false; document.getElementById('stbOneModal').classList.remove('hidden'); csTickSoon(); setTimeout(function() { document.getElementById('stbOneSerial').focus(); }, 60); } function stbCloseOne() { document.getElementById('stbOneModal').classList.add('hidden'); } function stbPaySet(b) { [].forEach.call(document.querySelectorAll('#stbPay .hub-pill'), function(x) { var on = x === b; x.classList.toggle('on', on); x.setAttribute('aria-checked', on ? 'true' : 'false'); }); } function stbPayVal() { var b = document.querySelector('#stbPay .hub-pill.on'); return b ? b.getAttribute('data-v') : 'Cash'; } function stbDoOne() { var s = document.getElementById('stbOneSerial').value.trim().toUpperCase(), b = document.getElementById('stbOneSave'); if (!stbSerialOk(s)) return showModal('Error', 'Enter ONE valid serial number (4–40 letters / digits). Use Bulk STB Inward for several.', 'error'); b.disabled = true; google.script.run.withSuccessHandler(function(r) { showModal(r && r.success && r.added ? 'STB added' : 'Error', r && r.success && !r.added ? 'That serial already exists.' : ((r && r.message) || 'Failed'), r && r.success && r.added ? 'success' : 'error'); if (r && r.success && r.added) { stbCloseOne(); stbHubOpen(); } else b.disabled = false; }).withFailureHandler(function(e) { b.disabled = false; stbFail(e); }).stbImport(user.phone, s, document.getElementById('stbOneTo').value, '', { date: document.getElementById('stbOneDate').value, remarks: document.getElementById('stbOneRemarks').value }); } // ---- Complimentary Accessories: one card per location (Admin Central + every branch, new branches appear on their own) var STB_ACC_LOCS = function() { return ['Admin'].concat(stbHub.branches || []); }; function stbAccTitle(l, k) { return l === 'Admin' ? '🏢 Admin Central' : (k === 1 ? '📡 ' : k === 2 ? '📺 ' : '🏬 ') + escapeHtml(l); } function stbBundleRender() { var r = stbHub.admin; if (!r) return; stbHub.bundles = r.bundles || stbHub.bundles; var own = r.bundleOwn || {}; document.getElementById('stbBundleList').innerHTML = STB_ACC_LOCS().map(function(l, k) { var lines = (stbHub.bundles || {})[l] || [], q = escapeHtml(l); return '
    ' + stbAccTitle(l, k) + '
    ' + (own[l] ? '' : 'shared defaults') + (lines.length ? '
    AccessoryQtyDefault
    ' + lines.map(function(b) { return '
    ' + escapeHtml(b.name) + '' + '' + '
    '; }).join('') + '' : '
    Nothing added yet — tap + Add Accessories.
    ') + '
    '; }).join(''); } // ---- + Add Accessories: pick which master items are offered function stbAccPickOpen(loc) { var r = stbHub.admin; if (!r) return; stbHub.pickLoc = loc; var on = {}; ((r.accActive || {})[loc] || []).forEach(function(i) { on[i] = 1; }); document.getElementById('stbAccPickTitle').innerText = '+ Add Accessories — ' + (loc === 'Admin' ? 'Admin Central' : loc); document.getElementById('stbAccPickList').innerHTML = r.items.length ? r.items.map(function(i) { return ''; }).join('') : '
    Add items in the Inventory tab first.
    '; document.getElementById('stbAccPickSave').disabled = false; document.getElementById('stbAccPickModal').classList.remove('hidden'); } function stbAccPickClose() { document.getElementById('stbAccPickModal').classList.add('hidden'); } function stbAccPickSave() { var ids = Array.prototype.map.call(document.querySelectorAll('#stbAccPickList input:checked'), function(c) { return c.value; }), b = document.getElementById('stbAccPickSave'); b.disabled = true; google.script.run.withSuccessHandler(function(r) { if (r && r.success) { stbAccPickClose(); stbHubOpen(); } else b.disabled = false; showModal(r && r.success ? 'Saved' : 'Error', (r && r.message) || 'Failed', r && r.success ? 'success' : 'error'); }) .withFailureHandler(function(e) { b.disabled = false; stbFail(e); }).saveStbAccActive(user.phone, ids, stbHub.pickLoc); } function stbSaveBundle(loc) { var card = Array.prototype.filter.call(document.querySelectorAll('#stbBundleList .acc-branch'), function(c) { return c.getAttribute('data-loc') === loc; })[0]; if (!card) return; var list = []; card.querySelectorAll('.acc-line').forEach(function(l) { list.push({ itemId: l.getAttribute('data-item'), qty: Math.max(Math.round(+l.querySelector('.stb-qty').value) || 1, 1), def: l.querySelector('.stb-def').checked }); }); google.script.run.withSuccessHandler(function(r) { showModal(r && r.success ? 'Saved' : 'Error', (r && r.message) || 'Failed', r && r.success ? 'success' : 'error'); if (r && r.success) stbHubOpen(); }).withFailureHandler(stbFail).saveStbBundle(user.phone, list, loc); } // ---- ⇄ Move STB: any location -> any other location. Admin: Admin custody <-> branches <-> branches. Staff: own branch -> another branch. function stbMoveBoxes(loc) { var src = user.role === 'Admin' && stbHub.admin ? stbHub.admin.rows : stbHub.avail; return src.filter(function(x) { return x.branch === loc && (x.status || 'IN_STOCK') === 'IN_STOCK'; }); } function stbOpenMove(serial, loc) { var isA = user.role === 'Admin', locs = isA ? ['Admin'].concat(stbHub.branches || []) : [user.branch]; stbHub.sel = serial ? (function(o) { o[serial] = 1; return o; })({}) : (stbHub.preSel || {}); stbHub.preSel = null; var first = loc || (serial ? (stbHub.avail.concat(stbHub.admin ? stbHub.admin.rows : []).filter(function(x) { return x.serial === serial; })[0] || {}).branch : ''); var f = document.getElementById('stbMoveFrom'); f.innerHTML = locs.map(function(l) { return ''; }).join(''); f.value = first && locs.indexOf(first) >= 0 ? first : (locs.filter(function(l) { return stbMoveBoxes(l).length; })[0] || locs[0]); f.disabled = !isA; document.getElementById('stbMoveWhy').value = ''; document.getElementById('stbMoveDate').value = todayISO(); document.getElementById('stbMoveFind').value = ''; stbMovePick(false); document.getElementById('stbMoveModal').classList.remove('hidden'); csTickSoon(); setTimeout(function() { document.getElementById('stbMoveFind').focus(); }, 60); } function stbMovePick(reset) { if (reset) stbHub.sel = {}; // a different source branch: the old picks belong to another location var f = document.getElementById('stbMoveFrom').value, isA = user.role === 'Admin', d = document.getElementById('stbMoveTo'); d.innerHTML = (isA ? ['Admin'].concat(stbHub.branches || []) : (stbHub.branches || [])).filter(function(l) { return l !== f; }).map(function(l) { return ''; }).join(''); stbMoveList(); } // ---- R58 STB Box Exchange (row action) function stbExOpen(serial) { var row = (stbHub.admin ? stbHub.admin.rows : stbHub.avail).filter(function(x) { return x.serial === serial; })[0]; if (!row) return; var isA = user.role === 'Admin'; stbHub.exFrom = row.branch; document.getElementById('stbExNew').value = serial; var brs = isA ? (stbHub.branches || []) : [row.branch], sel = document.getElementById('stbExBr'); sel.innerHTML = brs.map(function(b) { return ''; }).join(''); sel.value = brs.indexOf(row.branch) >= 0 ? row.branch : (brs[0] || ''); sel.disabled = !isA; document.getElementById('stbExPkg').innerHTML = '' + (stbHub.pkgs || []).map(function(p) { return ''; }).join(''); ['stbExCid', 'stbExName', 'stbExOld', 'stbExPkg', 'stbExWhy'].forEach(function(i) { document.getElementById(i).value = ''; }); document.getElementById('stbExSave').disabled = false; document.getElementById('stbExModal').classList.remove('hidden'); csTickSoon(); } function stbExClose() { document.getElementById('stbExModal').classList.add('hidden'); } function stbExDo() { var g = function(i) { return document.getElementById(i).value.trim(); }, b = document.getElementById('stbExSave'); if (!g('stbExCid')) return showModal('Error', 'Enter the Customer ID!', 'error'); if (!g('stbExName')) return showModal('Error', 'Enter the Customer Name!', 'error'); if (!g('stbExOld')) return showModal('Error', 'Enter the old / defective STB serial!', 'error'); if (!g('stbExWhy')) return showModal('Error', 'Enter the remarks / reason!', 'error'); b.disabled = true; google.script.run.withSuccessHandler(function(r) { showModal(r && r.success ? 'Exchange done' : 'Error', (r && r.message) || 'Failed', r && r.success ? 'success' : 'error'); if (r && r.success) { stbExClose(); stbHubOpen(); } else b.disabled = false; }).withFailureHandler(function(e) { b.disabled = false; stbFail(e); }).stbExchange(user.phone, { serial: g('stbExNew'), branch: document.getElementById('stbExBr').value, customerId: g('stbExCid'), customerName: g('stbExName'), oldSerial: g('stbExOld'), pkg: g('stbExPkg'), reason: g('stbExWhy') }); } // ---- single-unit Transfer (row button): serial locked, no multi-select function stbTrOpen(serial) { var row = (stbHub.admin ? stbHub.admin.rows : stbHub.avail).filter(function(x) { return x.serial === serial; })[0]; if (!row) return; var nm = function(l) { return l === 'Admin' ? 'Admin Central' : l; }; document.getElementById('stbTrSerial').value = serial; document.getElementById('stbTrFrom').value = nm(row.branch); stbHub.trFrom = row.branch; document.getElementById('stbTrTo').innerHTML = ['Admin'].concat(stbHub.branches || []).filter(function(l) { return l !== row.branch; }).map(function(l) { return ''; }).join(''); document.getElementById('stbTrDate').value = todayISO(); document.getElementById('stbTrWhy').value = ''; document.getElementById('stbTrSave').disabled = false; document.getElementById('stbTrModal').classList.remove('hidden'); csTickSoon(); } function stbTrClose() { document.getElementById('stbTrModal').classList.add('hidden'); } function stbTrDo() { var ser = document.getElementById('stbTrSerial').value, to = document.getElementById('stbTrTo').value, b = document.getElementById('stbTrSave'); if (!to) return showModal('Error', 'Pick the destination!', 'error'); b.disabled = true; google.script.run.withSuccessHandler(function(r) { showModal(r && r.success ? 'Bulk transfer done' : 'Error', (r && r.message) || 'Failed', r && r.success ? 'success' : 'error'); if (r && r.success) { stbTrClose(); stbHubOpen(); } else b.disabled = false; // reload = both KPI counts + rows update }).withFailureHandler(function(e) { b.disabled = false; stbFail(e); }).stbMove(user.phone, [ser], to, document.getElementById('stbTrWhy').value, document.getElementById('stbTrDate').value); } // selection lives in stbHub.sel (not the DOM), so ticks survive every filter / scan function stbMoveList() { var f = document.getElementById('stbMoveFrom').value, q = (document.getElementById('stbMoveFind').value || '').trim().toUpperCase(), all = stbMoveBoxes(f); var list = all.filter(function(x) { return !q || x.serial.toUpperCase().indexOf(q) !== -1; }), shown = list.slice(0, 150); document.getElementById('stbMoveSerials').innerHTML = shown.length ? shown.map(function(x) { var e = escapeHtml(x.serial); return ''; }).join('') + (list.length > shown.length ? '
    +' + (list.length - shown.length) + ' more — keep typing to narrow down
    ' : '') : '
    ' + (all.length ? 'No in-stock serial matches' : 'No in-stock STBs here') + '
    '; stbMoveCnt(); } function stbMoveSel() { var ok = {}; stbMoveBoxes(document.getElementById('stbMoveFrom').value).forEach(function(x) { ok[x.serial] = 1; }); return Object.keys(stbHub.sel).filter(function(k) { return ok[k]; }); } function stbMoveTick(c) { if (c.checked) stbHub.sel[c.value] = 1; else delete stbHub.sel[c.value]; stbMoveCnt(); } function stbMoveDrop(s) { delete stbHub.sel[s]; stbMoveList(); } // scanner finishes with Enter: tick the exact match (or the only match) and clear the box for the next scan function stbMoveKey(ev) { if (ev.key !== 'Enter') return; ev.preventDefault(); var inp = document.getElementById('stbMoveFind'), q = inp.value.trim().toUpperCase(); if (!q) return; var all = stbMoveBoxes(document.getElementById('stbMoveFrom').value), hit = all.filter(function(x) { return x.serial.toUpperCase() === q; })[0] || (all.filter(function(x) { return x.serial.toUpperCase().indexOf(q) !== -1; }).length === 1 ? all.filter(function(x) { return x.serial.toUpperCase().indexOf(q) !== -1; })[0] : null); if (!hit) { var m = document.getElementById('stbMoveMsg'); m.textContent = q + ' is not an in-stock box in this branch.'; m.classList.remove('hidden'); clearTimeout(stbMoveKey.t); stbMoveKey.t = setTimeout(function() { m.classList.add('hidden'); }, 2500); return; } stbHub.sel[hit.serial] = 1; inp.value = ''; stbMoveList(); } function stbMoveCnt() { var ks = stbMoveSel(), n = ks.length; document.getElementById('stbMoveCount').textContent = 'Selected: ' + n + ' STB' + (n === 1 ? '' : 's'); document.getElementById('stbMoveSelBox').classList.toggle('hidden', !n); document.getElementById('stbMoveQueue').innerHTML = ks.map(function(k) { var e = escapeHtml(k); return '#' + (ks.indexOf(k) + 1) + ' ' + e + ''; }).join(''); var b = document.getElementById('stbMoveSave'); b.disabled = !n; b.textContent = 'Confirm Bulk Transfer (' + n + ' STB' + (n === 1 ? '' : 's') + ')'; } function stbCloseMove() { document.getElementById('stbMoveModal').classList.add('hidden'); } function stbDoMove() { var ser = stbMoveSel(), to = document.getElementById('stbMoveTo').value; if (!ser.length) return showModal('Select STBs', 'Tick at least one box to move.', 'info'); if (!to) return showModal('Error', 'Pick the destination!', 'error'); var b = document.getElementById('stbMoveSave'); b.disabled = true; google.script.run.withSuccessHandler(function(r) { showModal(r && r.success ? 'STB moved' : 'Error', (r && r.message) || 'Failed', r && r.success ? 'success' : 'error'); if (r && r.success) { stbHub.sel = {}; stbCloseMove(); stbHubOpen(); } else b.disabled = false; }).withFailureHandler(function(e) { b.disabled = false; stbFail(e); }).stbMove(user.phone, ser, to, document.getElementById('stbMoveWhy').value, document.getElementById('stbMoveDate').value); } // ---- Package Master (card deck: name + price) function stbPkgLoad() { google.script.run.withSuccessHandler(function(r) { var el = document.getElementById('stbPkgDeck'); if (!r || !r.success) { el.innerHTML = '
    ' + escapeHtml((r && r.message) || 'Failed') + '
    '; return; } stbHub.pkgs = r.packages; el.innerHTML = r.packages.length ? r.packages.map(function(p, i) { return '
    📺
    ' + escapeHtml(p.name) + '' + (p.branches && p.branches.length ? '📍 ' + p.branches.map(function(b) { return escapeHtml(b); }).join(', ') : '📍 All branches') + '
    ' + (p.price == null ? '—' : ieMoney(p.price)) + ' / month
    ' + '
    '; }).join('') : '
    No packages yet — tap + Add Package.
    '; csTickSoon(); }).withFailureHandler(stbFail).getStbPackages(user.phone); } function stbPkgBrUpd() { var all = document.querySelectorAll('#stbPkgBrList input'), on = Array.prototype.filter.call(all, function(c) { return c.checked; }); document.getElementById('stbPkgBrSum').innerText = !on.length || on.length === all.length ? 'All branches' : on.map(function(c) { return c.value === 'Admin' ? 'Admin' : c.value; }).join(', '); } function stbPkgOpen(i) { var p = i === '' ? null : stbHub.pkgs[i]; stbHub.pkgOld = p ? p.name : ''; document.getElementById('stbPkgTitle').innerText = p ? '✏️ Edit Package' : '+ Add Package'; document.getElementById('stbPkgName').value = p ? p.name : ''; document.getElementById('stbPkgPrice').value = p && p.price != null ? p.price : ''; var sel = p && p.branches && p.branches.length ? p.branches : null; // no mapping = every branch ticked document.getElementById('stbPkgBrList').innerHTML = ['Admin'].concat(stbHub.branches || []).map(function(b) { return ''; }).join(''); document.getElementById("stbPkgBr").open = false; stbPkgBrUpd(); document.getElementById('stbPkgSave').disabled = false; document.getElementById('stbPkgModal').classList.remove('hidden'); csTickSoon(); } function stbPkgClose() { document.getElementById('stbPkgModal').classList.add('hidden'); } function stbPkgSave() { var b = document.getElementById('stbPkgSave'); b.disabled = true; google.script.run.withSuccessHandler(function(r) { if (r && r.success) { stbPkgClose(); stbPkgLoad(); } else b.disabled = false; showModal(r && r.success ? 'Saved' : 'Error', (r && r.message) || 'Failed', r && r.success ? 'success' : 'error'); }) .withFailureHandler(function(e) { b.disabled = false; stbFail(e); }).saveStbPackage(user.phone, { oldName: stbHub.pkgOld, name: document.getElementById('stbPkgName').value, price: document.getElementById('stbPkgPrice').value, branches: Array.prototype.filter.call(document.querySelectorAll('#stbPkgBrList input'), function(c) { return c.checked; }).map(function(c) { return c.value; }) }); } function stbPkgDelete(i) { var p = stbHub.pkgs[i]; if (!p) return; clayConfirm('Delete package "' + p.name + '"? Customers already activated keep their package name.', function() { google.script.run.withSuccessHandler(function(r) { if (r && r.success) stbPkgLoad(); showModal(r && r.success ? 'Deleted' : 'Error', (r && r.message) || 'Failed', r && r.success ? 'success' : 'error'); }).withFailureHandler(stbFail).deleteStbPackage(user.phone, p.name); }, { title: 'Delete package', yes: 'Delete' }); } ; // ===== STB ACTIVATION (R33): Activation Queue (Admin) -> Installation Queue (staff + Admin) -> history. Referral dock + notepad (Admin). ===== var stbArch = { rows: [], r: null, month: '' }; function stbArchRefresh() { if (typeof stbAdminLoad === 'function' && stbHub && stbHub.admin) stbAdminLoad(); } var stbAct = { pending: [], history: [], actQueue: [], instQueue: [], referrals: [], isAdmin: false, r: null, edit: null, ref: null }; function stbBadge(id, n) { var b = document.getElementById(id); if (!b) return; b.innerText = n > 99 ? '99+' : n; b.classList.toggle('hidden', !n); } function stbBadgeRefresh() { if (typeof user === 'undefined' || !user || !user.phone || user.role === 'Technician') return; google.script.run.withSuccessHandler(function(r) { if (r && r.success) stbBadge('stbActBadge', r.isAdmin ? r.count : (r.installPending || 0) + (r.retPending || 0)); }).withFailureHandler(function() {}).getStbActivations(user.phone); google.script.run.withSuccessHandler(function(r) { if (r && r.success) stbBadge('stbRetBadge', r.pending); }).withFailureHandler(function() {}).getStbReturns(user.phone); } var stbBadgeTimer = null; function stbBadgeStart() { stbBadgeRefresh(); if (!stbBadgeTimer) stbBadgeTimer = setInterval(function() { if (!document.hidden) stbBadgeRefresh(); }, 90000); } // Auto-sync: no Refresh button. While this tab is on screen, a tiny stamp poll (the same one the dashboard uses) reloads the queues the moment anything changes anywhere. var stbActStamp = null, stbActPoll = null; function stbActAutoSync() { if (stbActPoll) return; stbActPoll = setInterval(function() { var t = document.getElementById('stbActTab'); if (document.hidden || !t || t.classList.contains('hidden') || !window.user) return; google.script.run.withSuccessHandler(function(r) { if (!r || r.authFailed) return; if (stbActStamp !== null && r.stamp !== stbActStamp) stbActOpen(true); stbActStamp = r.stamp; }).withFailureHandler(function() {}).getSyncStamp(user.phone); }, 12000); } function stbActOpen(silent) { stbActAutoSync(); if (silent !== true) stbActStamp = null; var isA = user.role === 'Admin'; document.getElementById('stbActBranchTag').innerText = isA ? 'All branches' : (user.branch || ''); document.getElementById('stbActTitleTxt').innerText = isA ? 'STB Activation' : 'STB Installation'; document.getElementById('stbActQPanel').classList.toggle('hidden', !isA); document.getElementById('stbActDock').classList.toggle('hidden', !isA); document.getElementById('stbActSplit').classList.toggle('solo', !isA); google.script.run.withSuccessHandler(function(r) { if (!r || !r.success) { document.getElementById('stbActPending').innerHTML = '
    ' + escapeHtml((r && r.message) || 'Failed') + '
    '; return; } stbAct.pending = r.pending; stbAct.history = r.history; stbAct.actQueue = r.actQueue || []; stbAct.instQueue = r.instQueue || []; stbAct.referrals = r.referrals || []; stbAct.faulty = r.faulty || []; stbAct.retQ = r.returnQueue || []; stbAct.isAdmin = r.isAdmin; stbAct.r = r; stbBadge('stbActBadge', r.isAdmin ? r.count : (r.installPending || 0) + (r.retPending || 0)); stbActRender(); stbDockFit(); }).withFailureHandler(function(e) { showModal('Error', String(e), 'error'); }).getStbActivations(user.phone); document.getElementById('stbFaultyPanel').classList.toggle('hidden', !isA); document.getElementById('stbHistPanel').classList.toggle('hidden', !isA); if (isA) { [0, 150, 300, 500].forEach(function(ms) { setTimeout(stbDockFit, ms); }); stbTplLoad(); stbNotesLoad(); stbHistLoad(); } } // R44: flat vector icons (stroke = currentColor) replace emoji badges; empty states carry a larger illustration + caption var STB_SVG = { check: '', edit: '', x: '', copy: '', phone: '', tv: '', trash: '', gift: '', note: '', queue: '', tool: '', return: '', box: '', building: '', pin: '', stb: '', swap: '', exch: '', rupee: '', layers: '', alert: '' }; function stbSvg(n, s) { return ''; } function stbEmpty(n, cap) { return '
    ' + stbSvg(n, 56) + '' + cap + '
    '; } function stbKpiCard(cls, ico, num, label, sub) { return '
    ' + num + '' + label + '' + sub + '
    '; } function stbActBadgeTxt(t, cls) { return '' + t + ''; } // One request card — fixed top-to-bottom order: branch | serial · name · customer ID · mobile · address · package · setup fee · referral · accessories · actions function stbCopy(btn) { var t = btn.getAttribute('data-copy') || '', done = function() { btn.classList.add('ok'); btn.innerHTML = stbSvg('check'); setTimeout(function() { btn.classList.remove('ok'); btn.innerHTML = stbSvg('copy'); }, 1200); }; if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(t).then(done, function() { stbCopyOld(t); done(); }); else { stbCopyOld(t); done(); } } // ---- Dynamic Action Templates: built-in presets + custom ones (Settings sheet via getStbTemplates); {STB_NUMBER} is swapped live var STB_TPL = [ { title: 'Channel Removal Request', body: 'Subject: Request to Remove A La Carte Channel from My Package\n\nDear Team,\nKindly remove the A LA CARTE PAY-TP channel from our package.\n\nSTB Number: {STB_NUMBER}\n\nThank you for your support.' }, { title: 'STB Transfer Request', body: 'Subject: Request for STB Transfer\n\nDear Team,\nKindly transfer the below STB to our account / new location as per our request.\n\nSTB Number: {STB_NUMBER}\n\nThank you for your support.' }, { title: 'Package Disconnection Request', body: 'Subject: Request for Package Disconnection\n\nDear Team,\nKindly disconnect the active package on the below STB.\n\nSTB Number: {STB_NUMBER}\n\nThank you for your support.' } ], stbTplCustom = [], stbTplEdit = null; // built-ins can be edited too: a saved template with the same title overrides the built-in text function stbTplAll() { var ov = {}, names = STB_TPL.map(function(t) { return t.title.toLowerCase(); }); stbTplCustom.forEach(function(t) { ov[t.title.toLowerCase()] = t; }); return STB_TPL.map(function(t) { var o = ov[t.title.toLowerCase()]; return { title: t.title, body: o ? o.body : t.body, builtin: true, saved: !!o }; }) .concat(stbTplCustom.filter(function(t) { return names.indexOf(t.title.toLowerCase()) < 0; }).map(function(t) { return { title: t.title, body: t.body, builtin: false, saved: true }; })); } function stbTplCur() { return stbTplAll()[Number(document.getElementById('stbTplSel').value) || 0]; } function stbTplPick(title) { var i = stbTplAll().map(function(t) { return t.title.toLowerCase(); }).indexOf(String(title || '').toLowerCase()); stbTplFill(i < 0 ? 0 : i); } function stbTplFill(keep) { var sel = document.getElementById('stbTplSel'); if (!sel) return; var prev = keep == null ? sel.value : keep; sel.innerHTML = stbTplAll().map(function(t, i) { return ''; }).join(''); sel.value = String(Math.min(Number(prev) || 0, stbTplAll().length - 1)); stbTplRender(); } function stbTplLoad() { stbTplFill(0); google.script.run.withSuccessHandler(function(r) { if (r && r.success) { stbTplCustom = r.templates || []; stbTplFill(); } }).withFailureHandler(function() {}).getStbTemplates(user.phone); } function stbChText() { var t = stbTplCur(); if (!t) return ''; var n = document.getElementById('stbChStb').value.trim(); return t.body.replace(/\{STB_NUMBER\}/g, n || '[STB Number]'); } // full message, never clipped; the injected STB number is highlighted (copy still uses the plain text from stbChText) function stbTplRender() { var t = stbTplCur(), n = document.getElementById('stbChStb').value.trim(); document.getElementById('stbChPrev').innerHTML = t ? t.body.split(/\{STB_NUMBER\}/).map(escapeHtml).join('' + escapeHtml(n || '[STB Number]') + '') : ''; } // dock = viewport height minus wherever it currently starts, so the template card always sits at the bottom edge (before and after the page scrolls) function stbDockFit() { var d = document.getElementById('stbActDock'); if (!d || d.offsetParent === null) return; d.style.height = innerWidth > 960 ? Math.max(360, innerHeight - Math.max(12, d.getBoundingClientRect().top) - 12) + 'px' : ''; } document.addEventListener('scroll', stbDockFit, true); window.addEventListener('resize', stbDockFit); if (window.ResizeObserver) new ResizeObserver(stbDockFit).observe(document.body); function stbChToggle() { var d = document.getElementById('stbChRm'), o = d.classList.toggle('open'); document.getElementById('stbChCaret').setAttribute('aria-expanded', o ? 'true' : 'false'); } function stbChCopy() { var t = stbChText(), tip = document.getElementById('stbChTip'), done = function() { tip.classList.remove('hidden'); clearTimeout(stbChCopy.t); stbChCopy.t = setTimeout(function() { tip.classList.add('hidden'); }, 1800); }; if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(t).then(done, function() { stbCopyOld(t); done(); }); else { stbCopyOld(t); done(); } } // edit=true -> modify the active template; false -> create a new one function stbTplOpen(edit) { var t = edit ? stbTplCur() : null, del = document.getElementById('stbTplDel'); stbTplEdit = t; document.getElementById('stbTplTitle').textContent = t ? '✏️ Edit Content' : '➕ Add Template'; document.getElementById('stbTplName').value = t ? t.title : ''; document.getElementById('stbTplBody').value = t ? t.body : ''; del.classList.toggle('hidden', !(t && t.saved)); del.textContent = t && t.builtin ? '↩ Reset to default' : '🗑 Delete'; document.getElementById('stbTplSave').disabled = false; document.getElementById('stbTplModal').classList.remove('hidden'); setTimeout(function() { document.getElementById('stbTplName').focus(); }, 60); } function stbTplClose() { document.getElementById('stbTplModal').classList.add('hidden'); } function stbTplInsert() { var b = document.getElementById('stbTplBody'), s = b.selectionStart == null ? b.value.length : b.selectionStart, e = b.selectionEnd == null ? s : b.selectionEnd; b.value = b.value.slice(0, s) + '{STB_NUMBER}' + b.value.slice(e); b.focus(); b.selectionStart = b.selectionEnd = s + 12; } function stbTplSave() { var t = document.getElementById('stbTplName').value.trim(), b = document.getElementById('stbTplBody').value.trim(), btn = document.getElementById('stbTplSave'), o = stbTplEdit; if (!t) return showModal('Error', 'Enter the template name!', 'error'); if (!b) return showModal('Error', 'Enter the template body!', 'error'); if ((!o || o.title.toLowerCase() !== t.toLowerCase()) && stbTplAll().some(function(x) { return x.title.toLowerCase() === t.toLowerCase(); })) return showModal('Error', 'A template named "' + t + '" already exists — pick another name, or select it and use Edit Content.', 'error'); btn.disabled = true; google.script.run.withSuccessHandler(function(r) { btn.disabled = false; if (!r || !r.success) return showModal('Error', (r && r.message) || 'Failed', 'error'); stbTplCustom = stbTplCustom.filter(function(x) { return x.title.toLowerCase() !== t.toLowerCase(); }).concat([{ title: t, body: b }]); if (o && !o.builtin && o.title.toLowerCase() !== t.toLowerCase()) { // renamed a custom template: drop the old row stbTplCustom = stbTplCustom.filter(function(x) { return x.title !== o.title; }); google.script.run.withFailureHandler(function() {}).stbTemplateRemove(user.phone, o.title); } stbTplClose(); stbTplPick(t); }).withFailureHandler(function(e) { btn.disabled = false; showModal('Error', String(e), 'error'); }).saveStbTemplate(user.phone, t, b); } function stbTplRemove() { var t = stbTplEdit; if (!t || !t.saved) return; clayConfirm(t.builtin ? '"' + t.title + '" goes back to its default wording for everyone.' : '"' + t.title + '" will be removed for everyone.', function() { google.script.run.withSuccessHandler(function(r) { if (r && r.success) { stbTplCustom = stbTplCustom.filter(function(x) { return x.title.toLowerCase() !== t.title.toLowerCase(); }); stbTplClose(); stbTplPick(t.builtin ? t.title : ''); } else showModal('Error', (r && r.message) || 'Failed', 'error'); }).withFailureHandler(function(e) { showModal('Error', String(e), 'error'); }).stbTemplateRemove(user.phone, t.title); }, { title: t.builtin ? 'Reset to default?' : 'Delete template?' }); } function stbCopyOld(t) { var e = document.createElement('textarea'); e.value = t; e.style.position = 'fixed'; e.style.opacity = '0'; document.body.appendChild(e); e.select(); try { document.execCommand('copy'); } catch (x) {} document.body.removeChild(e); } function stbCopyRow(label, val) { if (!String(val || '').trim()) return '
    ' + label + ':—
    '; return '
    ' + label + ':' + escapeHtml(val) + '
    '; } function stbActAccs(x) { return (x.accItems || []).length ? x.accItems.map(function(a) { return '' + stbSvg('check', 12) + ' ' + escapeHtml(a.name) + (a.qty > 1 ? ' × ' + a.qty : '') + ''; }).join('') : 'No accessories issued'; } // Stage 1 (Admin): copy-friendly label/value rows for typing into the MSO portal; stacked full-width actions function stbActCard1(x) { var e = escapeHtml(x.serial), price = x.pkgPrice != null ? ' - ' + ieMoney(x.pkgPrice) + ' / month' : ''; return '
    ' + escapeHtml(x.branch) + ' | Activation request' + (x.exch ? ' EXCHANGE' : '') + '
    ' + (x.exch ? '
    Replaces:' + escapeHtml(x.exch) + '
    ' : '') + stbCopyRow('STB', x.serial) + stbCopyRow('CID', x.customerId) + stbCopyRow('Name', x.customer) + stbCopyRow('Phone', x.mobile) + stbCopyRow('Address', (x.address || '').replace(/\s*\n\s*/g, ', ')) + stbCopyRow('ID Proof', x.govt) + (x.referral ? stbCopyRow('Referral CID', x.referral) : '') + '
    ' + stbSvg('tv') + ' ' + escapeHtml(x.pkg || 'No package') + price + 'Collected: ' + ieMoney(x.fee).replace('₹ ', '₹') + (x.payMode && x.fee > 0 ? ' · ' + (x.payMode === 'Cash' ? '💵 ' : '📱 ') + escapeHtml(x.payMode) : '') + '
    ' + stbActAccs(x) + '
    ' + '
    '; } function stbActCard(x, mode) { if (mode === 'act') return stbActCard1(x); var e = escapeHtml(x.serial), tel = String(x.mobile || '').replace(/[^\d+]/g, ''), price = x.pkgPrice != null ? ' ' + ieMoney(x.pkgPrice) + ' / month' : ''; var acc = (x.accItems || []).length ? x.accItems.map(function(a) { return '' + stbSvg('check', 12) + ' ' + escapeHtml(a.name) + (a.qty > 1 ? ' × ' + a.qty : '') + ''; }).join('') : 'No accessories issued'; var acts = ''; if (mode === 'act') acts = ''; else acts = ''; var del = stbAct.isAdmin ? '' : ''; return '
    ' + '
    ' + escapeHtml(x.branch) + ' | STB #' + e + (x.model ? ' ' + escapeHtml(x.model) + '' : '') + '
    ' + '
    ' + escapeHtml(x.customer) + '
    ' + '
    CID: ' + escapeHtml(x.customerId) + '' + (x.referral ? 'Ref: ' + escapeHtml(x.referral) + '' : '') + '
    ' + '' + '
    ' + (x.address ? escapeHtml(x.address) : 'No address') + '
    ' + '
    ' + stbSvg('tv') + ' ' + (x.pkg ? escapeHtml(x.pkg) : '-') + price + '
    ' + '
    Setup fee collected ' + ieMoney(x.fee) + '
    ' + '
    ' + acc + '
    ' + '
    ' + acts + del + '
    '; } function stbRank(html, n) { return html.replace('>', '>#' + n + ''); } // FIFO: the server sends the queues oldest-first function stbBrShort(b) { var p = String(b).split(' - '); return p.length > 1 ? p.slice(1).join(' - ') : b; } function stbRankList(l, mode) { return l.map(function(x, i) { return [x, i + 1]; }).filter(function(p) { return !stbAct.fBr || p[0].branch === stbAct.fBr; }).map(function(p) { return stbRank(stbActCard(p[0], mode), p[1]); }).join(''); } // rank = true FIFO position in the whole queue function stbActFilter(br) { stbAct.fBr = (br && stbAct.fBr !== br) ? br : ''; stbActRender(); var t = document.getElementById(stbAct.fBr ? 'stbActPending' : 'stbActKpis'); if (t && t.scrollIntoView) try { t.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } catch (e) {} } function stbActRender() { var q = stbAct.actQueue, iq = stbAct.instQueue, rf = stbAct.referrals; var r0 = stbAct.r || {}, bb = r0.byBranch || {}; // centred counters + a branch sub-dock on each; Completed* are THIS CALENDAR MONTH only (server resets them on the 1st) var mon = r0.monthKey ? stbMonthLabel(r0.monthKey.slice(3) + '-' + r0.monthKey.slice(0, 2)) : 'This month'; var dock = function(k) { var o = bb[k]; if (!o) return ''; var tot = o.total || 0; return '
    Total: ' + tot + '' + (r0.brList || []).map(function(br) { return ''; }).join('') + '
    '; }; var kc = function(cls, ico, num, label, sub, k) { return stbKpiCard(cls, ico, num, label, sub).replace(/<\/div>$/, dock(k) + ''); }; var fc = r0.faultyCount || 0; document.getElementById('stbActKpis').innerHTML = kc((r0.actCount || 0) ? 'k-gold' : '', stbSvg('queue', 26), r0.actCount != null ? r0.actCount : q.length, 'Pending Activations', 'Awaiting Admin approval', 'act') + kc(iq.length ? 'k-gold' : '', stbSvg('tool', 26), iq.length, 'In-Process Installations', 'Installation pending', 'inst') + kc('', stbSvg('check', 26), r0.doneActMonth || 0, 'Completed Activations', mon, 'doneAct') + kc(fc ? 'k-bad' : '', stbSvg('alert', 26), fc, 'Faulty Boxes', fc ? 'Unresolved · needs a decision' : 'None unresolved', 'faulty'); document.getElementById('stbActNotice').innerHTML = stbAct.fBr ? '
    Showing only ' + escapeHtml(stbAct.fBr) + '
    ' : ''; document.getElementById('stbActQueue').innerHTML = stbRankList(q, 'act') || stbEmpty('queue', stbAct.fBr ? 'No Pending Activations for this branch' : 'No Pending Activations'); document.getElementById('stbActPending').innerHTML = stbRankList(iq, 'inst') || stbEmpty('tool', stbAct.fBr ? 'No installations for this branch' : 'All Installations Up to Date'); document.getElementById('stbRetQueue').innerHTML = stbRetQDraw(stbAct.retQ || []); stbRefRender(); stbFaultyDraw(stbAct.faulty || []); csTickSoon(); } // ---- STB Return Processing Queue: customer handovers waiting to be settled (refund booked) or flagged defective (-> Faulty Box Pool). Final records land in the STB Return archive. function stbRetChecklist(x) { var ret = String(x.returned || '').split(',').map(function(s) { return s.trim(); }).filter(Boolean), miss = String(x.missing || ''), has = function(n) { return ret.indexOf(n) !== -1; }; var std = [['STB', has('STB'), true], ['Adapter', has('Adapter'), /adapter/i.test(miss)], ['Remote', has('Remote'), /remote/i.test(miss)], ['Cable', has('HDMI') || has('AV Cable'), /cable/i.test(miss)]]; var chips = std.filter(function(c) { return c[1] || c[2]; }).map(function(c) { return '' + stbSvg(c[1] ? 'check' : 'x', 12) + ' ' + c[0] + ''; }); ret.filter(function(n) { return ['STB', 'Adapter', 'Remote', 'HDMI', 'AV Cable'].indexOf(n) === -1; }).forEach(function(n) { chips.push('' + stbSvg('check', 12) + ' ' + escapeHtml(n) + ''); }); return '
    ' + chips.join('') + '
    '; } function stbRetQCard(x) { var e = escapeHtml(x.id), tel = String(x.mobile || '').replace(/[^\d+]/g, ''); return '
    ' + '
    ' + escapeHtml(x.branch) + ' | STB #' + escapeHtml(x.serial) + '
    ' + '
    ' + escapeHtml(x.customer) + '
    ' + '
    CID: ' + escapeHtml(x.customerId) + '' + (tel ? '' + stbSvg('phone') + ' ' + escapeHtml(x.mobile) + '' : '') + '
    ' + '
    ' + stbSvg('tv') + ' ' + (x.pkg ? escapeHtml(x.pkg) : 'No package') + '
    ' + '
    Return reason: ' + (x.reason ? escapeHtml(x.reason) : 'Not given') + '
    Date received: ' + escapeHtml(x.date || '-') + '
    ' + stbRetChecklist(x) + '
    Proposed refund ' + ieMoney(x.refund) + '' + (x.missing ? ' · deducted: ' + escapeHtml(x.missing) + '' : '') + '
    ' + '
    ' + '' + '
    '; } function stbRetQDraw(l) { l = l.map(function(x, i) { return [x, i + 1]; }).filter(function(p) { return !stbAct.fBr || p[0].branch === stbAct.fBr; }); return l.length ? l.map(function(p) { return stbRank(stbRetQCard(p[0]), p[1]); }).join('') : stbEmpty('box', stbAct.fBr ? 'No returns waiting for this branch' : 'No returns waiting to be processed'); } function stbRetQAct(id, kind) { var x = (stbAct.retQ || []).filter(function(r) { return r.id === id; })[0]; if (!x) return; var run = function(fn, args) { google.script.run.withSuccessHandler(function(r) { if (r && r.success) { stbActOpen(); stbBadgeRefresh(); if (typeof loadData === 'function') try { loadData(); } catch (e) {} } showModal(r && r.success ? 'Done' : 'Error', (r && r.message) || 'Failed', r && r.success ? 'success' : 'error'); }) .withFailureHandler(function(e) { showModal('Error', String(e), 'error'); })[fn].apply(null, [user.phone].concat(args)); }; if (kind === 'settle') clayConfirm('Return of ' + x.serial + ' (' + x.customer + '): refund ' + ieMoney(x.refund) + ' will be booked in the branch ledger and the box + accessories go to the Returned Stock Pool.', function() { run('stbReturnSettle', [id]); }, { title: 'Complete Return & Settle?', icon: '✅', yes: 'Settle' }); else if (kind === 'flag') clayConfirm(x.serial + ' goes to the Faulty Box Pool. No refund is booked for a defective return.', function() { run('stbReturnFlag', [id, x.reason ? 'Returned defective: ' + x.reason : '']); }, { title: 'Flag as defective?', icon: '⚠️', yes: 'Flag Defective' }); else clayConfirm('Cancel the return request for ' + x.serial + '? The box stays with the customer.', function() { run('stbReturnQueueCancel', [id]); }, { title: 'Cancel request?', yes: 'Cancel Request', no: 'Keep' }); } // ---- Faulty Box Pool (boxes swapped out through STB Exchange): Admin picks one of three dispositions; every decision lands in the history archive below var STB_FX = { OFFICE: { t: 'Move to Office / Testing', y: 'Move to Office', i: '🏢', kind: 'royal', d: 'The box stays in the office for internal testing / refurbishment.' }, COMPANY: { t: 'Return to Company / Vendor', y: 'Return to Vendor', i: '🏭', kind: 'gold', d: 'Logged as dispatched to the vendor for warranty / RMA. It leaves office inventory.' }, SCRAP: { t: 'Scrap / Discard', y: 'Scrap / Discard', i: '🗑️', kind: 'danger', d: 'Permanent hardware retirement. The box is removed from the database (audit line kept).' } }; function stbFaultyDraw(l) { var el = document.getElementById('stbFaultyList'); if (!el) return; l = l.filter(function(b) { return !stbAct.fBr || b.branch === stbAct.fBr; }); el.innerHTML = l.length ? l.map(function(b) { var e = escapeHtml(b.serial); return rcCard({ tone: 'bad', date: b.date || '', title: 'STB ' + e + (b.model ? ' · ' + escapeHtml(b.model) + '' : ''), pills: [['FAULTY', 'bad'], [b.replacedBy ? 'Replaced by ' + escapeHtml(b.replacedBy) : 'Customer return', 'grey']], sub: escapeHtml(b.branch), meta: [['Customer', escapeHtml(b.customerId) + ' · ' + escapeHtml(b.customer)], ['Defect / Reason', escapeHtml(b.reason || '-')], ['Mobile', escapeHtml(b.mobile || '-')]], acts: ['OFFICE', 'COMPANY', 'SCRAP'].map(function(a) { return rcBtn(STB_FX[a].kind, STB_FX[a].i + ' ' + (a === 'OFFICE' ? 'Move to Office / Testing' : a === 'COMPANY' ? 'Return to Company / Vendor' : 'Scrap / Discard'), "stbFaultyAsk('" + e + "','" + a + "')"); }).join('') }); }).join('') : stbEmpty('box', 'No faulty boxes waiting for a decision.'); } function stbFaultyAsk(serial, action) { var f = STB_FX[action]; stbAct.fx = { serial: serial, action: action }; document.getElementById('stbFaultyTitle').innerText = f.i + ' ' + f.t; document.getElementById('stbFaultyInfo').innerHTML = '' + escapeHtml(serial) + ' — ' + escapeHtml(f.d); document.getElementById('stbFaultyRemarks').value = ''; var g = document.getElementById('stbFaultyGo'); g.innerText = f.y; g.className = action === 'SCRAP' ? 'btn-danger3d' : 'btn btn-gold'; g.disabled = false; document.getElementById('stbFaultyModal').classList.remove('hidden'); } function stbFaultyClose() { document.getElementById('stbFaultyModal').classList.add('hidden'); } function stbFaultyGo() { var x = stbAct.fx; if (!x) return; var g = document.getElementById('stbFaultyGo'); g.disabled = true; google.script.run.withSuccessHandler(function(r) { g.disabled = false; if (r && r.success) { stbFaultyClose(); stbActOpen(); stbBadgeRefresh(); } showModal(r && r.success ? 'Done' : 'Error', (r && r.message) || 'Failed', r && r.success ? 'success' : 'error'); }) .withFailureHandler(function(e) { g.disabled = false; showModal('Error', String(e), 'error'); }).stbFaultyResolve(user.phone, x.serial, x.action, document.getElementById('stbFaultyRemarks').value.trim()); } // ---- Operations History & Archive (Admin): one compact card, two views, any month (defaults to the current one) function stbHistLoad() { var mEl = document.getElementById('stbHistMonth'), v = document.getElementById('stbHistView').value, body = document.getElementById('stbHistBody'); if (!mEl) return; var t = todayISO(), cur = t.slice(0, 7); if (!mEl.value) mEl.value = cur; mEl.max = cur; var ym = mEl.value.slice(5, 7) + '/' + mEl.value.slice(0, 4); google.script.run.withSuccessHandler(function(r) { if (!r || !r.success) { body.innerHTML = '
    ' + escapeHtml((r && r.message) || 'Failed') + '
    '; return; } var lab = stbMonthLabel(mEl.value), th = function(a) { return '' + a.map(function(x) { return '' + x + ''; }).join('') + ''; }, td = function(a) { return '' + a.map(function(x, i) { return '' + (x === '' || x == null ? '-' : x) + ''; }).join('') + ''; }, tone = { OFFICE: 'blue', COMPANY: 'gold', SCRAP: 'bad' }; r.rows = r.rows || []; if (!r.rows.length) { body.innerHTML = stbEmpty(v === 'FAULTY' ? 'box' : 'gift', 'No ' + (v === 'FAULTY' ? 'faulty box resolutions' : 'referral concessions') + ' in ' + lab); return; } body.innerHTML = '
    ' + (v === 'FAULTY' ? th(['Serial Number', 'Origin Branch', 'Final Action', 'Handled Date', 'Admin Remarks']) + '' + r.rows.map(function(x) { return td(['' + escapeHtml(x.serial) + '', escapeHtml(x.branch), '' + escapeHtml(x.actionLabel) + '', escapeHtml(x.date), escapeHtml(x.remarks)]); }).join('') + '' : th(['Referral Customer ID', 'Joined Referred ID', 'Date Credited', 'Remark']) + '' + r.rows.map(function(x) { return td(['' + escapeHtml(x.referrer) + '', escapeHtml(x.joined), escapeHtml(x.date), escapeHtml(x.remark)]); }).join('') + '') + '
    ' + r.rows.length + ' record' + (r.rows.length === 1 ? '' : 's') + ' · ' + lab + '
    '; }).withFailureHandler(function(e) { body.innerHTML = '
    ' + escapeHtml(String(e)) + '
    '; }).getStbHistory(user.phone, v, ym); } function stbMonthLabel(k) { var m = /^(\d{4})-(\d{2})$/.exec(k); return m ? ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'][+m[2] - 1] + ' ' + m[1] : k; } // ---- stage actions function stbActDo(fn, serial) { google.script.run.withSuccessHandler(function(r) { showModal(r && r.success ? 'Done' : 'Error', (r && r.message) || 'Failed', r && r.success ? 'success' : 'error'); if (r && r.success) { stbActOpen(); stbArchRefresh(); stbBadgeRefresh(); } }).withFailureHandler(function(e) { showModal('Error', String(e), 'error'); })[fn](user.phone, serial); } function stbActDelete(serial, from) { clayConfirm(from === 'done' ? 'Revert this completed STB ' + serial + '? It returns to IN_STOCK at its branch, the bundled accessories go back to that branch stock and the setup-fee entry is removed from the ledger / passbook.' : 'Cancel the assignment of ' + serial + '? It returns to IN_STOCK, the accessories go back to the branch stock and the setup-fee entry is reversed.', function() { stbActDo('stbAdminDelete', serial); }, { title: from === 'done' ? 'Revert / Delete Completed' : 'Delete / Cancel Assignment', yes: 'Delete', icon: '🗑' }); } function stbActConfirm(serial) { clayConfirm('Activation verified (portal / MSO, box and accessories)? ' + serial + ' will move to the Installation Queue.', function() { stbActDo('stbConfirmActivation', serial); }, { title: 'Activate', yes: 'Activate', icon: '✔' }); } function stbActReject(serial) { clayConfirm('Reject this activation? ' + serial + ' goes back to IN_STOCK, the bundled accessories return to the branch stock and the setup-fee entry is reversed.', function() { stbActDo('stbRejectActivation', serial); }, { title: 'Reject Activation', yes: 'Reject', icon: '✖' }); } function stbActEditOpen(serial, from) { var src = from === 'arch' ? stbArch : { rows: stbAct.actQueue, r: stbAct.r }, x = src.rows.filter(function(r) { return r.serial === serial; })[0]; if (!x) return; stbAct.edit = x; document.getElementById('stbEdaSerial').value = serial; document.getElementById('stbEdaName').value = x.customer || ''; document.getElementById('stbEdaMobile').value = x.mobile || ''; document.getElementById('stbEdaAddr').value = x.address || ''; var pk = ((src.r && src.r.packages) || []).filter(function(p) { return !(p.branches || []).length || p.branches.indexOf(x.branch) >= 0 || p.name === x.pkg; }); document.getElementById('stbEdaPkg').innerHTML = '' + pk.map(function(p) { return ''; }).join(''); document.getElementById('stbEdaPkg').value = x.pkg || ''; var offer = ((src.r && src.r.bundles) || {})[x.branch] || [], have = {}; (x.accIds || []).forEach(function(i) { have[i] = 1; }); var list = offer.map(function(b) { return { id: b.itemId, name: b.name, qty: b.qty }; }); (x.accItems || []).forEach(function(a) { if (a.id && !list.some(function(l) { return l.id === a.id; })) list.push({ id: a.id, name: a.name, qty: a.qty }); }); document.getElementById('stbEdAccBox').innerHTML = list.length ? list.map(function(b) { var on = !!have[b.id]; return ''; }).join('') : '
    No accessories configured for this branch.
    '; document.getElementById('stbEdaSave').disabled = false; document.getElementById('stbActEditModal').classList.remove('hidden'); } function stbActEditClose() { document.getElementById('stbActEditModal').classList.add('hidden'); } function stbActEditSave() { var g = function(i) { return document.getElementById(i).value; }, b = document.getElementById('stbEdaSave'), acc = Array.prototype.filter.call(document.getElementsByName('stbEdAcc'), function(c) { return c.checked; }).map(function(c) { return c.value; }); b.disabled = true; google.script.run.withSuccessHandler(function(r) { b.disabled = false; if (r && r.success) { stbActEditClose(); stbActOpen(); stbArchRefresh(); } showModal(r && r.success ? 'Saved' : 'Error', (r && r.message) || 'Failed', r && r.success ? 'success' : 'error'); }) .withFailureHandler(function(e) { b.disabled = false; showModal('Error', String(e), 'error'); }).stbActEdit(user.phone, { serial: g('stbEdaSerial'), customerName: g('stbEdaName'), mobile: g('stbEdaMobile'), address: g('stbEdaAddr'), pkg: g('stbEdaPkg'), accessories: acc }); } // ---- referral dock: cards stay until Applied / Rejected; a card click picks the target month (current or next billing cycle) function stbToast(msg, tone) { var e = document.getElementById('stbToastBox'); e.className = 'stb-toast ' + (tone || 'ok'); e.innerText = msg; clearTimeout(stbToast.t); stbToast.t = setTimeout(function() { e.classList.add('hidden'); }, 2600); } function stbMonName(d) { return stbMonthLabel(d.getFullYear() + '-' + ('0' + (d.getMonth() + 1)).slice(-2)); } function stbRefCycles() { var n = new Date(); return { cur: stbMonName(n), next: stbMonName(new Date(n.getFullYear(), n.getMonth() + 1, 1)) }; } function stbRefTarget(x) { var m = /^HOLD (\d{2})\/(\d{4})$/.exec(x.hold || ''); return m ? stbMonthLabel(m[2] + '-' + m[1]) : stbRefCycles().cur; } function stbRefRender() { var l = stbAct.referrals.map(function(x, i) { return [x, i + 1]; }).filter(function(p) { return !stbAct.fBr || p[0].branch === stbAct.fBr; }); // server sends them oldest-first: #1 = referral activated first document.getElementById('stbRefList').innerHTML = l.length ? l.map(function(p) { var x = p[0], e = escapeHtml(x.serial), note = '1 Month Free for Referral (Joined ID: ' + x.customerId + ')'; return '
    #' + p[1] + '
    Card: ' + escapeHtml(x.referral) + 'Target Cycle: ' + escapeHtml(stbRefTarget(x)) + '
    ' + '
    ' + stbSvg('gift') + ' ' + escapeHtml(note) + '
    ' + '
    ' + escapeHtml(x.customer) + ' · ' + escapeHtml(x.branch) + '
    ' + '
    '; }).join('') : stbEmpty('gift', stbAct.fBr ? 'No Referral Discounts for this branch' : 'No Referral Discounts'); } // one-click: the exact billing remark goes to the clipboard, with a visible "Copied for Portal Entry!" tip function stbCopyRef(btn) { stbCopy(btn); var p = btn.parentNode, old = p.querySelector('.rc-tip'); if (old) old.remove(); var t = document.createElement('span'); t.className = 'rc-tip'; t.setAttribute('role', 'status'); t.textContent = '✓ Copied!'; p.appendChild(t); setTimeout(function() { t.remove(); }, 1500); } function stbRefOpen(serial) { var x = stbAct.referrals.filter(function(r) { return r.serial === serial; })[0]; if (!x) return; stbAct.ref = x; var c = stbRefCycles(), isNext = !!x.hold; document.getElementById('stbRefInfo').innerHTML = 'Card: ' + escapeHtml(x.referral) + ' · 1 month free for referring ' + escapeHtml(x.customer) + ' (Joined ID: ' + escapeHtml(x.customerId) + ')'; document.getElementById('stbRefOpts').innerHTML = [['NOW', 'Current Month', c.cur, !isNext], ['NEXT', 'Next Month / Next Billing Cycle', c.next, isNext]].map(function(o) { return ''; }).join(''); document.getElementById('stbRefModal').classList.remove('hidden'); } function stbRefClose() { document.getElementById('stbRefModal').classList.add('hidden'); } function stbRefGo(cycle) { var x = stbAct.ref; if (!x) return; stbRefClose(); google.script.run.withSuccessHandler(function(r) { if (r && r.success) { x.hold = cycle === 'NEXT' ? 'HOLD ' + r.held : ''; stbRefRender(); stbToast('Target Cycle: ' + stbRefTarget(x), 'ok'); } else showModal('Error', (r && r.message) || 'Failed', 'error'); }) .withFailureHandler(function(e) { showModal('Error', String(e), 'error'); }).stbReferralCycle(user.phone, x.serial, cycle); } function stbRefDrop(serial) { stbAct.referrals = stbAct.referrals.filter(function(r) { return r.serial !== serial; }); stbRefRender(); } function stbRefApply(serial) { google.script.run.withSuccessHandler(function(r) { if (r && r.success) { stbRefDrop(serial); stbToast('Discount Applied Successfully', 'ok'); stbActOpen(); } else showModal('Error', (r && r.message) || 'Failed', 'error'); }) .withFailureHandler(function(e) { showModal('Error', String(e), 'error'); }).stbDiscountDone(user.phone, serial, 'NOW'); } function stbRefRejectNow(serial) { google.script.run.withSuccessHandler(function(r) { if (r && r.success) { stbRefDrop(serial); stbToast('Referral Rejected', 'bad'); stbActOpen(); } else showModal('Error', (r && r.message) || 'Failed', 'error'); }) .withFailureHandler(function(e) { showModal('Error', String(e), 'error'); }).stbReferralReject(user.phone, serial); } // ---- Quick Notes: auto-saves while typing var stbNoteTimer = null, stbNoteDirty = false; function stbNoteState(t) { var e = document.getElementById('stbNotesState'); if (e) e.innerText = t; } function stbNotesLoad() { google.script.run.withSuccessHandler(function(r) { if (r && r.success && !stbNoteDirty) { document.getElementById('stbNotes').value = r.text || ''; stbNoteState('Saved ✓'); } }).withFailureHandler(function() { stbNoteState('Offline'); }).getStbNotes(user.phone); } function stbNotesInput() { stbNoteDirty = true; stbNoteState('Typing…'); clearTimeout(stbNoteTimer); stbNoteTimer = setTimeout(stbNotesSave, 700); } function stbNotesFlush() { if (stbNoteDirty) { clearTimeout(stbNoteTimer); stbNotesSave(); } } function stbNotesSave() { var ta = document.getElementById('stbNotes'), v = ta.value; stbNoteState('Saving…'); google.script.run.withSuccessHandler(function(r) { if (r && r.success) { if (ta.value === v) stbNoteDirty = false; stbNoteState('Saved ✓'); } else stbNoteState('Not saved — retry'); }).withFailureHandler(function() { stbNoteState('Not saved — retry'); }).saveStbNotes(user.phone, v); } window.addEventListener('beforeunload', function() { if (stbNoteDirty) stbNotesSave(); }); // ===== Reports -> STB Reports (Admin): In-Stock inventory + Assigned STB monthly report; PDFs open the print dialog like the other reports ===== var stbRep = { stock: null, asg: null }; function stbRepOpen() { var m = document.getElementById('stbRepMonth'); if (!m.value) m.value = todayISO().slice(0, 7); google.script.run.withSuccessHandler(function(r) { if (!r || !r.success) { document.getElementById('stbRepStockBody').innerHTML = '' + escapeHtml((r && r.message) || 'Failed') + ''; return; } stbRep.stock = r; document.getElementById('stbRepKpis').innerHTML = '
    📦
    Total In-Stock Units
    ' + r.total + '
    ' + Object.keys(r.byBranch).map(function(b) { return '
    🏬
    ' + (b === 'Admin' ? 'Admin Central' : escapeHtml(b)) + '
    ' + r.byBranch[b] + '
    '; }).join(''); document.getElementById('stbRepStockBody').innerHTML = r.rows.length ? r.rows.map(function(x, i) { return '' + (i + 1) + '' + escapeHtml(x.serial) + '' + (x.branch === 'Admin' ? 'Admin Central' : escapeHtml(x.branch)) + '' + escapeHtml(x.model || '-') + '' + escapeHtml(x.received || '-') + ''; }).join('') : 'No STB in stock.'; }).withFailureHandler(function() {}).getStbInStockReport(user.phone); stbRepAssignedLoad(); } function stbRepAssignedLoad() { var el = document.getElementById('stbRepMonth'); if (!el.value) el.value = todayISO().slice(0, 7); google.script.run.withSuccessHandler(function(r) { if (!r || !r.success) { document.getElementById('stbRepAsgBody').innerHTML = '' + escapeHtml((r && r.message) || 'Failed') + ''; return; } if (r.month !== el.value) return; stbRep.asg = r; document.getElementById('stbRepAsgSum').innerHTML = '' + escapeHtml(stbMonthLabel(r.month)) + ': ' + r.rows.length + ' STB assigned'; document.getElementById('stbRepAsgBody').innerHTML = r.rows.length ? r.rows.map(function(x) { return '' + escapeHtml(x.at || '-') + '' + escapeHtml(x.serial) + '' + escapeHtml(x.customer) + '
    CID: ' + escapeHtml(x.customerId) + '' + escapeHtml(x.mobile) + '
    ' + escapeHtml(String(x.address || '-').replace(/\s*\n\s*/g, ', ')) + '' + escapeHtml(x.branch) + '' + escapeHtml(x.instBy || '-') + '' + escapeHtml(x.pkg || '-') + ''; }).join('') : 'No STB assigned in this month.'; }).withFailureHandler(function() {}).getStbAssigned(user.phone, el.value); } function stbRepPrint(title, sub, head, rows, foot) { var w = window.open('', '_blank'); if (!w) return showModal('Pop-up blocked', 'Allow pop-ups for this site, then press Download again.', 'error'); w.document.write('' + title + '

    Udhayam Cable TV — ' + title + '

    ' + sub + '

    ' + head.map(function(h) { return ''; }).join('') + '' + rows + '
    ' + h + '
    ' + foot + '
    '); w.document.close(); w.focus(); setTimeout(function() { w.print(); }, 500); } function stbRepStockPdf() { var r = stbRep.stock; if (!r || !r.rows.length) return showModal('No data', 'No STB in stock to export.', 'info'); stbRepPrint('In-Stock STB Inventory Report', 'As on ' + todayISO().split('-').reverse().join('/') + ' · ' + r.total + ' units', ['#', 'STB Serial', 'Location', 'Model', 'Received'], r.rows.map(function(x, i) { return '' + (i + 1) + '' + escapeHtml(x.serial) + '' + (x.branch === 'Admin' ? 'Admin Central' : escapeHtml(x.branch)) + '' + escapeHtml(x.model || '-') + '' + escapeHtml(x.received || '-') + ''; }).join(''), 'Total in stock: ' + r.total + ' — ' + Object.keys(r.byBranch).map(function(b) { return (b === 'Admin' ? 'Admin Central' : escapeHtml(b)) + ': ' + r.byBranch[b]; }).join(' · ')); } function stbRepStockCsv() { var r = stbRep.stock; if (!r || !r.rows.length) return showModal('No data', 'No STB in stock to export.', 'info'); var q = function(v) { return '"' + String(v == null ? '' : v).replace(/"/g, '""') + '"'; }, csv = ['Serial,Location,Model,Received'].concat(r.rows.map(function(x) { return [x.serial, x.branch === 'Admin' ? 'Admin Central' : x.branch, x.model, x.received].map(q).join(','); })).join('\r\n'); var a = document.createElement('a'); a.href = URL.createObjectURL(new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8' })); a.download = 'STB_In_Stock_' + todayISO() + '.csv'; document.body.appendChild(a); a.click(); setTimeout(function() { URL.revokeObjectURL(a.href); a.remove(); }, 500); } function stbRepAssignedPdf() { var r = stbRep.asg; if (!r || !r.rows.length) return showModal('No data', 'No STB assigned in this month.', 'info'); stbRepPrint('Assigned STB Report', escapeHtml(stbMonthLabel(r.month)) + ' · ' + r.rows.length + ' STB assigned', ['Assigned', 'STB', 'Customer ID', 'Customer', 'Mobile', 'Address', 'Branch', 'Technician', 'Package'], r.rows.map(function(x) { return '' + escapeHtml(x.at || '-') + '' + escapeHtml(x.serial) + '' + escapeHtml(x.customerId) + '' + escapeHtml(x.customer) + '' + escapeHtml(x.mobile) + '' + escapeHtml(String(x.address || '-').replace(/\s*\n\s*/g, ', ')) + '' + escapeHtml(x.branch) + '' + escapeHtml(x.instBy || '-') + '' + escapeHtml(x.pkg || '-') + ''; }).join(''), 'Total STB assigned: ' + r.rows.length); } ; // ===== STB RETURN (R42) ===== var stbRet = { editId: null, rows: [], sel: {}, isAdmin: false, cfg: null, boxes: [], pool: null, poolKey: '' }; function stbRetLabel(s) { return { RETURNED: 'IN POOL', PENDING: 'IN POOL', MOVED_TO_STOCK: 'MOVED TO STOCK', COMPANY_RETURN: 'COMPANY RETURN', APPROVED: 'IN STOCK', DEFECTIVE: 'DEFECTIVE → FAULTY POOL', CANCELLED: 'CANCELLED' }[s] || s; } function stbRetTone(s) { return { MOVED_TO_STOCK: 'ok', APPROVED: 'ok', CANCELLED: 'bad', DEFECTIVE: 'bad', COMPANY_RETURN: 'purple' }[s] || 'warn'; } // ---- Admin: return valuation (base refund + per-item deductions). Low-stock limits are untouched here (STB Stock KPI cards own them). // ---- Admin: one consolidated card per location (Admin Central + every branch; new branches appear on their own) var STB_CFG_ELIG = [['Remote', 'Remote Control'], ['Adapter', 'Power Adapter'], ['HDMI', 'HDMI Cable'], ['AV Cable', 'AV Cable']]; var STB_ACC = [['Adapter', 'Power Adapter'], ['Remote', 'Remote Control'], ['HDMI', 'HDMI Cable'], ['AV Cable', 'AV Cable']]; function stbCfgName(b) { return b === 'Admin' ? 'Admin Central' : b; } function stbCfgRule(label, cls, v, extra) { return '
    ' + label + '' + (extra || '') + '
    '; } function stbCfgLbl(k, v) { return ''; } function stbCfgCustomRow(name, amt) { var dl = (stbRet.cfgAll && stbRet.cfgAll.invItems || []).map(function(n) { return ''; }).join(''); } else s.innerHTML = ''; s.disabled = !isA; stbRet.paidEdited = false; document.getElementById('stbRfPaid').value = ''; stbRet.usageWaived = false; document.getElementById('stbRfBtn').disabled = false; stbRet.sel = {}; if (edit) { var dp = String(edit.date).split('/'), v = function(i, x) { document.getElementById(i).value = x || ''; }; v('stbRfDate', dp.length === 3 ? dp[2] + '-' + dp[1] + '-' + dp[0] : todayISO()); v('stbRfCid', edit.customerId); v('stbRfName', edit.customer); v('stbRfMobile', edit.mobile); v('stbRfSerial', edit.serial); v('stbRfReason', edit.reason); if (!s.querySelector('option[value="' + edit.branch + '"]')) s.insertAdjacentHTML('beforeend', ''); s.value = edit.branch; s.disabled = true; String(edit.returned).split(',').forEach(function(n) { n = n.trim(); if (!n || n === 'STB') return; stbRet.sel[n === 'HDMI' || n === 'AV Cable' ? 'Cable' : n] = true; }); stbRet.paidEdited = true; document.getElementById('stbRfPaid').value = (+edit.refund || 0).toFixed(2); } stbRfLoad(); stbRfCalc(); document.getElementById('stbRfModal').classList.remove('hidden'); csTickSoon(); } function stbRfClose() { document.getElementById('stbRfModal').classList.add('hidden'); if (stbRet.editId) { ['stbRfCid', 'stbRfName', 'stbRfSerial', 'stbRfMobile', 'stbRfReason'].forEach(function(i) { document.getElementById(i).value = ''; }); stbRet.sel = {}; stbRet.paidEdited = false; stbRet.usageWaived = false; } stbRet.editId = null; stbRet.editRow = null; document.getElementById('stbRfBranch').disabled = user.role !== 'Admin'; } function stbRfLoad() { var br = user.role === 'Admin' ? document.getElementById('stbRfBranch').value : ''; google.script.run.withSuccessHandler(function(r) { if (!r || !r.success) return; stbRet.cfg = r.cfg; stbRet.boxes = r.boxes; if (stbRet.editRow && !stbRet.editRow._u) { stbRet.editRow._u = 1; var mm = String(stbRet.editRow.missing || '').split(',').map(function(x) { return x.trim(); }); stbRet.usageWaived = !(mm.indexOf(stbRfLbl('Usage')) !== -1 || mm.indexOf('Usage') !== -1); } stbRfCalc(); }).withFailureHandler(function() {}).getStbReturnForm(user.phone, br); } function stbRfPick() { var s = document.getElementById('stbRfSerial').value.trim().toUpperCase(), b = (stbRet.boxes || []).filter(function(x) { return x.serial === s; })[0]; if (b) { var set = function(i, v) { var e = document.getElementById(i); if (!e.value) e.value = v || ''; }; set('stbRfCid', b.customerId); set('stbRfName', b.customer); set('stbRfMobile', b.mobile); } } function stbRfLbl(k) { var l = (stbRet.cfg && stbRet.cfg.labels) || {}; return l[k] || { Adapter: 'Power Adapter', Remote: 'Remote Control', Cable: 'HDMI / AV Cable', Usage: 'Usage (Day 1 - 12)' }[k]; } function stbRfAccList() { var el = (stbRet.cfg && stbRet.cfg.elig) || {}, l = []; if (el.Adapter) l.push(['Adapter', stbRfLbl('Adapter')]); if (el.Remote) l.push(['Remote', stbRfLbl('Remote')]); if (el.HDMI || el['AV Cable']) l.push(['Cable', stbRfLbl('Cable')]); return l.concat(((stbRet.cfg && stbRet.cfg.custom) || []).map(function(x) { return [x.name, x.name]; })); } function stbRfAccDraw() { stbRet.sel = stbRet.sel || {}; var cfg = stbRet.cfg || {}, w = !!stbRet.usageWaived; document.getElementById('stbRfAcc').innerHTML = '' + stbRfAccList().map(function(a) { var on = !!stbRet.sel[a[0]]; return ''; }).join('') + ''; } function stbRfUsageTog() { stbRet.usageWaived = !stbRet.usageWaived; stbRet.paidEdited = false; stbRfCalc(); } function stbRfTog(b) { var k = b.getAttribute('data-k'); stbRet.sel[k] = !stbRet.sel[k]; if (stbRet.editId) stbRet.paidEdited = false; stbRfCalc(); } function stbRfMissing() { // mirrors the server rule: only eligible accessories count; cable = HDMI or AV; custom rules by item name var cfg = stbRet.cfg || {}, el = cfg.elig || {}, s = stbRet.sel || {}, m = []; if (el.Adapter && !s.Adapter) m.push('Adapter'); if (el.Remote && !s.Remote) m.push('Remote'); if ((el.HDMI || el['AV Cable']) && !s.Cable) m.push('Cable'); (cfg.custom || []).forEach(function(x) { if (!s[x.name]) m.push(x.name); }); return m; } function stbRfCalc() { stbRfAccDraw(); var cfg = stbRet.cfg; if (!cfg) return; var amt = function(n) { var c = (cfg.custom || []).filter(function(x) { return x.name === n; })[0]; return c ? c.amt : (cfg.ded[n] || 0); }; var miss = stbRfMissing(), ded = miss.reduce(function(a, n) { return a + amt(n); }, 0) + (stbRet.usageWaived ? 0 : (cfg.usage || 0)), net = Math.max(cfg.base - ded, 0); document.getElementById('stbRfNet').innerHTML = ieMoney(net) + ' = base ' + ieMoney(cfg.base) + (miss.length ? ' − ' + miss.map(function(n) { return n + ' ' + ieMoney(amt(n)); }).join(' − ') : '') + (stbRet.usageWaived ? '' : ' − ' + stbRfLbl('Usage') + ' ' + ieMoney(cfg.usage || 0)) + ''; if (!stbRet.paidEdited) document.getElementById('stbRfPaid').value = net.toFixed(2); } function stbRfSubmit() { var g = function(i) { return document.getElementById(i).value.trim(); }, b = document.getElementById('stbRfBtn'); if (!g('stbRfCid') || !g('stbRfName') || g('stbRfSerial').length < 4) return showModal('Error', 'Customer ID, Customer Name and STB Serial Number are required!', 'error'); var el = stbRet.cfg.elig, got = stbRfAccList().map(function(a) { return a[0]; }).filter(function(k) { return stbRet.sel[k]; }).map(function(k) { return k === 'Cable' ? (el.HDMI ? 'HDMI' : 'AV Cable') : k; }); b.disabled = true; if (stbRet.editId) { google.script.run.withSuccessHandler(function(r) { b.disabled = false; if (r && r.success) { stbRfClose(); stbRetOpen(); stbPoolLoad(); stbBadgeRefresh(); if (typeof loadData === 'function') try { loadData(); } catch (e) {} } showModal(r && r.success ? 'Return updated' : 'Error', (r && r.message) || 'Failed', r && r.success ? 'success' : 'error'); }).withFailureHandler(function(e) { b.disabled = false; showModal('Error', String(e), 'error'); }) .stbReturnEdit(user.phone, { id: stbRet.editId, date: g('stbRfDate'), serial: g('stbRfSerial'), customerId: g('stbRfCid'), customerName: g('stbRfName'), mobile: g('stbRfMobile'), reason: g('stbRfReason'), paid: g('stbRfPaid'), usage: !stbRet.usageWaived, items: ['STB'].concat(got) }); return; } google.script.run.withSuccessHandler(function(r) { b.disabled = false; if (r && r.success) { stbRfClose(); ['stbRfCid', 'stbRfName', 'stbRfSerial', 'stbRfMobile', 'stbRfReason'].forEach(function(i) { document.getElementById(i).value = ''; }); stbRet.sel = {}; stbRet.paidEdited = false; stbRet.usageWaived = false; stbRfCalc(); stbRetOpen(); stbBadgeRefresh(); if (typeof loadData === 'function') try { loadData(); } catch (e) {} } showModal(r && r.success ? 'Return submitted' : 'Error', (r && r.message) || 'Failed', r && r.success ? 'success' : 'error'); }).withFailureHandler(function(e) { b.disabled = false; showModal('Error', String(e), 'error'); }) .submitStbReturn(user.phone, { date: g('stbRfDate'), branch: user.role === 'Admin' ? g('stbRfBranch') : '', serial: g('stbRfSerial'), reason: g('stbRfReason'), customerId: g('stbRfCid'), customerName: g('stbRfName'), mobile: g('stbRfMobile'), paid: stbRet.paidEdited ? g('stbRfPaid') : '', usage: !stbRet.usageWaived, items: ['STB'].concat(got) }); } // ---- Returned Stock Pool (Admin) function stbPoolLoad() { google.script.run.withSuccessHandler(function(r) { if (!r || !r.success) return; stbRet.pool = r; var q = function(k) { var it = r.items.filter(function(i) { return i.key === k; })[0]; return it ? it.qty : 0; }, cab = q('HDMI') + q('AV Cable'); var card = function(ico, num, label, hint, fn, svg2) { return ''; }; document.getElementById('stbPoolCards').innerHTML = card(ssSvg('stb', 64), r.boxes.length, 'STB Units', r.boxes.length ? 'Review & route' : 'Nothing waiting', 'stbPoolBoxOpen()') + card(ssSvg('remote', 64), q('Remote'), 'Remote Controls', q('Remote') ? 'Review & route' : 'Nothing waiting', "stbPoolItemOpen('Remote')") + card(ssSvg('adapter', 64), q('Adapter'), 'Power Adapters', q('Adapter') ? 'Review & route' : 'Nothing waiting', "stbPoolItemOpen('Adapter')") + card(ssSvg('hdmi', 56), cab, 'HDMI / AV Cables', cab ? 'HDMI ' + q('HDMI') + ' · AV ' + q('AV Cable') : 'Nothing waiting', "stbPoolItemOpen('" + (q('HDMI') || !q('AV Cable') ? 'HDMI' : 'AV Cable') + "')", ssSvg('av', 56)); document.getElementById('stbPoolCompany').innerHTML = r.company.length ? 'Returned to vendor (archived): ' + r.company.map(function(c) { return '' + escapeHtml(c.serial) + ''; }).join(', ') : ''; }).withFailureHandler(function() {}).getStbPool(user.phone); } function stbPoolBranches() { var l = [(stbRet.cfgAll && stbRet.cfgAll.branches) || [], (cachedData && cachedData.branches) || []].filter(function(x) { return x.length; })[0] || []; return ['Admin'].concat(l.filter(function(b) { return b !== 'Admin'; })); } function stbPoolTgtFill(id) { document.getElementById(id).innerHTML = '' + stbPoolBranches().map(function(b) { return ''; }).join(''); } function stbPoolClose() { document.getElementById('stbPoolItemModal').classList.add('hidden'); document.getElementById('stbPoolBoxModal').classList.add('hidden'); } function stbPoolItemOpen(key, act) { var it = stbRet.pool.items.filter(function(i) { return i.key === key; })[0]; if (!it) return; stbRet.poolKey = key; stbRet.poolAct = act; var cable = key === 'HDMI' || key === 'AV Cable', kinds = document.getElementById('stbPoolKinds'); kinds.classList.toggle('hidden', !cable); if (cable) kinds.innerHTML = ['HDMI', 'AV Cable'].map(function(k) { var x = stbRet.pool.items.filter(function(i) { return i.key === k; })[0] || { qty: 0 }; return ''; }).join(''); document.getElementById('stbPoolItemTitle').innerText = it.label; document.getElementById('stbPoolAvail').innerText = it.qty; document.getElementById('stbPoolLines').innerHTML = it.lines.length ? it.lines.map(function(l) { return '
    ' + escapeHtml(l.branch) + '' + l.qty + '
    '; }).join('') : '
    Nothing quarantined0
    '; var qn = document.getElementById('stbPoolQty'); qn.max = it.qty; qn.value = it.qty ? it.qty : ''; qn.disabled = !it.qty; stbPoolTgtFill('stbPoolTgt'); ['stbPoolGo', 'stbPoolRej'].forEach(function(i) { document.getElementById(i).disabled = !it.qty; }); var guess = { 'HDMI': 'hdmi', 'Adapter': 'adapter', 'Remote': 'remote', 'AV Cable': 'av' }[key], inv = stbRet.pool.invItems, sel = inv.filter(function(x) { return x.name.toLowerCase().indexOf(guess) !== -1; })[0]; document.getElementById('stbPoolInv').innerHTML = inv.map(function(x) { return ''; }).join(''); document.getElementById('stbPoolItemModal').classList.remove('hidden'); } function stbPoolAct(a) { var it = stbRet.pool.items.filter(function(i) { return i.key === stbRet.poolKey; })[0], act = a || stbRet.poolAct || 'MOVE', n = Math.round(+document.getElementById('stbPoolQty').value || 0); if (!(n > 0) || n > it.qty) return showModal('Error', 'Enter a quantity between 1 and ' + it.qty + '.', 'error'); var tgt = document.getElementById('stbPoolTgt').value; if (act === 'MOVE' && !tgt) return showModal('Error', 'Select where to route the stock (Admin Central or a branch).', 'error'); var left = n, lines = []; it.lines.forEach(function(l) { var t = Math.min(l.qty, left); if (t > 0) { lines.push({ branch: l.branch, qty: t }); left -= t; } }); // returned stock goes back to the branch it came from var run = function() { google.script.run.withSuccessHandler(function(r) { showModal(r && r.success ? 'Done' : 'Error', (r && r.message) || 'Failed', r && r.success ? 'success' : 'error'); if (r && r.success) { stbPoolClose(); stbPoolLoad(); } }).withFailureHandler(function(e) { showModal('Error', String(e), 'error'); }).stbPoolAccessory(user.phone, stbRet.poolKey, act, lines, document.getElementById('stbPoolInv').value, tgt); }; if (act === 'SCRAP') clayConfirm('Permanently scrap ' + n + ' returned item(s)? This cannot be undone.', run, { title: 'Reject / Scrap Stock', yes: 'Scrap', icon: '🗑' }); else run(); } function stbPoolBoxOpen() { stbRet.boxSel = {}; stbPoolTgtFill('stbPoolBoxTgt'); document.getElementById('stbPoolBoxQ').value = ''; stbPoolBoxDraw(); document.getElementById('stbPoolBoxModal').classList.remove('hidden'); } function stbPoolBoxPick(cb) { stbRet.boxSel[cb.value] = cb.checked; stbPoolBoxCount(); } function stbPoolBoxCount() { var n = Object.keys(stbRet.boxSel).filter(function(k) { return stbRet.boxSel[k]; }).length; ['stbPoolBoxOk', 'stbPoolBoxNo'].forEach(function(i) { document.getElementById(i).disabled = !n; }); } function stbPoolBoxDraw() { var q = (document.getElementById('stbPoolBoxQ').value || '').trim().toLowerCase(); var l = stbRet.pool.boxes.filter(function(b) { return !q || [b.serial, b.customer, b.customerId].join(' ').toLowerCase().indexOf(q) !== -1; }); document.getElementById('stbPoolBoxes').innerHTML = l.length ? l.map(function(b) { var e = escapeHtml(b.serial); return ''; }).join('') : stbEmpty('box', 'No Returned STB Units'); stbPoolBoxCount(); } function stbPoolBoxGo(action) { var l = Object.keys(stbRet.boxSel).filter(function(k) { return stbRet.boxSel[k]; }); if (!l.length) return; var tgt = document.getElementById('stbPoolBoxTgt').value; if (action === 'STOCK' && !tgt) return showModal('Error', 'Select the Target Branch.', 'error'); var run = function() { google.script.run.withSuccessHandler(function(r) { showModal(r && r.success ? 'Done' : 'Error', (r && r.message) || 'Failed', r && r.success ? 'success' : 'error'); if (r && r.success) { stbPoolClose(); stbPoolLoad(); stbRetOpen(); if (typeof stbHubOpen === 'function') try { stbHubOpen(); } catch (e) {} } }).withFailureHandler(function(e) { showModal('Error', String(e), 'error'); }).stbPoolBoxes(user.phone, l, action, tgt); }; if (action === 'COMPANY') clayConfirm('Flag ' + l.length + ' selected box(es) as permanently defective and return to the vendor? They leave the office inventory.', run, { title: 'Return to Vendor', yes: 'Reject', icon: '🏭' }); else run(); } ; function loadTechnicianDashboard() { var monthInput = document.getElementById('techMonthInput'); if (!monthInput.value) { var today = new Date(); monthInput.value = todayISO().substring(0, 7); } monthInput.max = todayISO().substring(0, 7); var monthKey = monthKeyToInputValueReverse(monthInput.value); google.script.run.withSuccessHandler(function(res) { if (!res || !res.success) { showModal('Error', (res && res.message) || 'Could not fetch attendance', 'error'); return; } document.getElementById('techPresent').innerText = res.present; document.getElementById('techAbsent').innerText = res.absent; document.getElementById('techPaidLeave').innerText = res.paidLeave || 0; document.getElementById('techLeave').innerText = res.leave; document.getElementById('techWorkOut').innerText = res.workOut; renderTechnicianCalendar(res); }).withFailureHandler(function(err) { showModal('Error', err.toString(), 'error'); }).getMyAttendanceMonth(user.phone, monthKey); google.script.run.withSuccessHandler(function(res) { if (!res || !res.success) return; document.getElementById('techSalaryMonthLabel').innerText = monthLabelFromKey(res.month) + ' Salary'; document.getElementById('techMonthlySalary').innerText = Number(res.monthlySalary).toLocaleString('en-IN'); document.getElementById('techPayableDays').innerText = res.payableDays; document.getElementById('techPaidLeaveUsed').innerText = res.payableLeaveDays; if (res.isPaid) { document.getElementById('techSalaryAmount').innerText = Number(res.actualPaid.amount).toLocaleString('en-IN'); document.getElementById('techSalaryStatus').innerHTML = '✅ Paid on ' + res.actualPaid.paidAt + ''; } else { document.getElementById('techSalaryAmount').innerText = Number(res.referenceAmount).toLocaleString('en-IN'); document.getElementById('techSalaryStatus').innerHTML = 'Pending (Reference Only)'; } }).withFailureHandler(function(err) { showModal('Error', err.toString(), 'error'); }).getMySalaryForMonth(user.phone, monthKey); google.script.run.withSuccessHandler(function(res) { var tbody = document.getElementById('techSalaryHistoryBody'); if (!tbody) return; tbody.innerHTML = ''; if (!res || !res.success || !res.data.length) { tbody.innerHTML = 'No salary history yet'; return; } res.data.forEach(function(p) { tbody.insertAdjacentHTML('beforeend', rcRow({ tone: 'ok', dp: rcMonthParts(p.month), title: escapeHtml(user.name || 'My salary'), pills: [['SALARY CREDITED', 'ok']], sub: p.paidAt ? escapeHtml(p.paidAt) : '', meta: [['Present', '' + p.present + ''], ['Absent', '' + p.absent + ''], ['Net Salary', '₹' + Number(p.paidAmount).toLocaleString('en-IN') + '']] })); }); }).withFailureHandler(function(err) { showModal('Error', err.toString(), 'error'); }).getMySalaryHistory(user.phone); } function renderTechnicianCalendar(res) { var grid = document.getElementById('techCalendarGrid'); grid.innerHTML = ''; for (var e = 0; e < res.firstWeekday; e++) grid.innerHTML += '
    '; for (var d = 1; d <= res.daysInMonth; d++) { var status = res.days[d] || ''; grid.innerHTML += '
    ' + d + '' + attendanceStatusShort(status) + '
    '; } } // ================= USER CONTROLS (ADMIN ONLY) ================= function loadUserList() { google.script.run.withSuccessHandler(function(res) { var ul = document.getElementById('userListUI'); if (!ul) return; ul.innerHTML = ''; if (!res || !res.success) { showModal('Error', (res && res.message) || 'Could not fetch users', 'error'); return; } if (!res.data.length) { ul.innerHTML = '
  • No users found
  • '; return; } res.data.forEach(function(u) { var isActive = (u.status !== 'Inactive'); var statusBadge = isActive ? '● Active' : '● Inactive'; var roleBadge = (u.role === 'Admin') ? 'Admin' : 'Staff'; var li = document.createElement('li'); li.className = 'manage-item'; li.style.display = 'flex'; li.style.justifyContent = 'space-between'; li.style.alignItems = 'center'; li.style.flexWrap = 'wrap'; li.style.gap = '8px'; li.innerHTML = '' + u.name + ' — ' + u.phone + ' — ' + roleBadge + (u.branch ? ' (' + u.branch + ')' : '') + ' — ' + statusBadge + (u.role === 'Admin' ? (u.hasLogin ? ' — 🔐 ' + escapeHtml(u.loginUser) + '' : ' — ⚠ mobile-only') : '') + '' + '
    ' + (u.role === 'Admin' ? '🔐' : '') + '✏️' + '' + (isActive ? '🚫' : '✅') + '' + '🗑️' + '
    '; ul.appendChild(li); }); }).withFailureHandler(function(err) { showModal('Error', err.toString(), 'error'); }).getUserList(user.phone); } function toggleNewUserBranchField() { var role = document.getElementById('newUserRole').value; document.getElementById('newUserBranchRow').style.display = (role === 'Admin') ? 'none' : 'block'; } function createNewUser() { var name = document.getElementById('newUserName').value.trim(); var phone = document.getElementById('newUserPhone').value.replace(/\D/g, ''); var role = document.getElementById('newUserRole').value; var branch = document.getElementById('newUserBranch').value; if (!name) return showModal('Warning', 'Enter name', 'error'); if (!phone || phone.length < 10) return showModal('Warning', 'Enter a valid 10-digit mobile number', 'error'); var payload = { phone: phone, name: name, role: role, branch: (role === 'Admin' ? 'Admin' : branch), status: 'Active' }; google.script.run.withSuccessHandler(function(res) { showModal(res.success ? 'Success' : 'Failed', res.message, res.success ? 'success' : 'error'); if (res.success) { document.getElementById('newUserName').value = ''; document.getElementById('newUserPhone').value = ''; closeAddUserModal(); loadUserList(); } }).withFailureHandler(function(err) { showModal('Error', err.toString(), 'error'); }).addUser(user.phone, payload); } function openAddUserModal() { document.getElementById('newUserName').value = ''; document.getElementById('newUserPhone').value = ''; document.getElementById('newUserRole').value = 'Staff'; toggleNewUserBranchField(); document.getElementById('addUserModal').classList.remove('hidden'); setTimeout(function(){ document.getElementById('newUserName').focus(); }, 50); } function closeAddUserModal() { document.getElementById('addUserModal').classList.add('hidden'); } function openEditUserModal(phone) { google.script.run.withSuccessHandler(function(res) { if (!res || !res.success) { showModal('Error', (res && res.message) || 'User not found', 'error'); return; } var u = res.userData; document.getElementById('editUserOldPhone').value = u.phone; document.getElementById('editUserName').value = u.name; document.getElementById('editUserPhone').value = u.phone; document.getElementById('editUserRole').value = u.role; toggleEditUserBranchField(); var branchSel = document.getElementById('editUserBranch'); if (branchSel && u.branch) branchSel.value = u.branch; document.getElementById('editUserStatus').value = u.status || 'Active'; document.getElementById('editUserModal').classList.remove('hidden'); }).withFailureHandler(function(err) { showModal('Error', err.toString(), 'error'); }).getSingleUser(user.phone, phone); } function closeEditUserModal() { document.getElementById('editUserModal').classList.add('hidden'); } function toggleEditUserBranchField() { var role = document.getElementById('editUserRole').value; document.getElementById('editUserBranchRow').style.display = (role === 'Admin') ? 'none' : 'block'; } function saveEditedUser() { var payload = { oldPhone: document.getElementById('editUserOldPhone').value, phone: document.getElementById('editUserPhone').value.replace(/\D/g, ''), name: document.getElementById('editUserName').value.trim(), role: document.getElementById('editUserRole').value, branch: document.getElementById('editUserBranch').value, status: document.getElementById('editUserStatus').value }; if (payload.role === 'Admin') payload.branch = 'Admin'; if (!payload.name) return showModal('Warning', 'Enter name', 'error'); if (!payload.phone || payload.phone.length < 10) return showModal('Warning', 'Enter a valid mobile number', 'error'); google.script.run.withSuccessHandler(function(res) { showModal(res.success ? 'Success' : 'Failed', res.message, res.success ? 'success' : 'error'); if (res.success) { closeEditUserModal(); loadUserList(); } }).withFailureHandler(function(err) { showModal('Error', err.toString(), 'error'); }).updateUser(user.phone, payload); } function toggleUserStatusAction(phone, newStatus) { var confirmMsg = (newStatus === 'Inactive') ? 'Deactivate this User? They will no longer be able to Login.' : 'Activate this User again?'; if (!confirm(confirmMsg)) return; google.script.run.withSuccessHandler(function(res) { showModal(res.success ? 'Success' : 'Failed', res.message, res.success ? 'success' : 'error'); if (res.success) loadUserList(); }).withFailureHandler(function(err) { showModal('Error', err.toString(), 'error'); }).toggleUserStatus(user.phone, phone, newStatus); } function deleteUserAction(phone) { if (!confirm('Delete this User permanently? This cannot be undone!')) return; google.script.run.withSuccessHandler(function(res) { showModal(res.success ? 'Success' : 'Failed', res.message, res.success ? 'success' : 'error'); if (res.success) loadUserList(); }).withFailureHandler(function(err) { showModal('Error', err.toString(), 'error'); }).deleteUser(user.phone, phone); } // ================= STAFF MANAGEMENT (Settings -> Staff, ADMIN ONLY) ================= var staffListCache = []; function loadStaffList() { google.script.run.withSuccessHandler(function(res) { var ul = document.getElementById('staffListUI'); if (!ul) return; ul.innerHTML = ''; if (!res || !res.success) { showModal('Error', (res && res.message) || 'Could not fetch staff list', 'error'); return; } staffListCache = res.data || []; if (!staffListCache.length) { ul.innerHTML = '
  • No staff found
  • '; return; } staffListCache.forEach(function(s) { var isActive = (s.status !== 'Inactive'); var statusBadge = isActive ? '● Active' : '● Inactive'; var li = document.createElement('li'); li.className = 'manage-item'; li.style.display = 'flex'; li.style.justifyContent = 'space-between'; li.style.alignItems = 'center'; li.style.flexWrap = 'wrap'; li.style.gap = '8px'; li.innerHTML = '' + s.name + '' + (s.category ? ' — ' + s.category + '' : '') + (s.mobileNumber ? ' — 📱 ' + s.mobileNumber + '' : '') + ' — ₹' + Number(s.monthlySalary || 0).toLocaleString('en-IN') + '/month — ' + statusBadge + '' + '
    ' + '✏️' + '' + (isActive ? '🚫' : '✅') + '' + '🗑️' + '
    '; ul.appendChild(li); }); }).withFailureHandler(function(err) { showModal('Error', err.toString(), 'error'); }).getStaffList(user.phone); } function createNewStaff() { var name = document.getElementById('newStaffName').value.trim(); var category = document.getElementById('newStaffCategory').value.trim(); var mobileNumber = document.getElementById('newStaffMobile').value.replace(/\D/g, ''); var salary = document.getElementById('newStaffSalary').value; if (!name) return showModal('Warning', 'Enter name', 'error'); if (mobileNumber && mobileNumber.length !== 10) return showModal('Warning', 'Enter a valid 10-digit Mobile Number', 'error'); google.script.run.withSuccessHandler(function(res) { showModal(res.success ? 'Success' : 'Failed', res.message, res.success ? 'success' : 'error'); if (res.success) { document.getElementById('newStaffName').value = ''; document.getElementById('newStaffCategory').value = ''; document.getElementById('newStaffMobile').value = ''; document.getElementById('newStaffSalary').value = ''; closeAddStaffModal(); loadStaffList(); } }).withFailureHandler(function(err) { showModal('Error', err.toString(), 'error'); }).addStaffMember(user.phone, { name: name, category: category, mobileNumber: mobileNumber, monthlySalary: salary, status: 'Active' }); } function openAddStaffModal() { ['newStaffName','newStaffCategory','newStaffMobile','newStaffSalary'].forEach(function(id){ document.getElementById(id).value = ''; }); document.getElementById('addStaffModal').classList.remove('hidden'); setTimeout(function(){ document.getElementById('newStaffName').focus(); }, 50); } function closeAddStaffModal() { document.getElementById('addStaffModal').classList.add('hidden'); } function openEditStaffModal(staffId) { var found = staffListCache.find(function(s) { return String(s.staffId) === String(staffId); }); if (!found) return; document.getElementById('editStaffId').value = found.staffId; document.getElementById('editStaffName').value = found.name; document.getElementById('editStaffCategory').value = found.category || ''; document.getElementById('editStaffMobile').value = found.mobileNumber || ''; document.getElementById('editStaffSalary').value = found.monthlySalary || 0; document.getElementById('editStaffStatus').value = found.status || 'Active'; document.getElementById('editStaffModal').classList.remove('hidden'); } function closeEditStaffModal() { document.getElementById('editStaffModal').classList.add('hidden'); } function saveEditedStaff() { var payload = { staffId: document.getElementById('editStaffId').value, name: document.getElementById('editStaffName').value.trim(), category: document.getElementById('editStaffCategory').value.trim(), mobileNumber: document.getElementById('editStaffMobile').value.replace(/\D/g, ''), monthlySalary: document.getElementById('editStaffSalary').value, status: document.getElementById('editStaffStatus').value }; if (!payload.name) return showModal('Warning', 'Enter name', 'error'); if (payload.mobileNumber && payload.mobileNumber.length !== 10) return showModal('Warning', 'Enter a valid 10-digit Mobile Number', 'error'); google.script.run.withSuccessHandler(function(res) { showModal(res.success ? 'Success' : 'Failed', res.message, res.success ? 'success' : 'error'); if (res.success) { closeEditStaffModal(); loadStaffList(); } }).withFailureHandler(function(err) { showModal('Error', err.toString(), 'error'); }).updateStaffMember(user.phone, payload); } function toggleStaffStatusAction(staffId, newStatus) { var confirmMsg = (newStatus === 'Inactive') ? 'Deactivate this Staff? Their name will no longer show in the Salary Expense dropdown.' : 'Activate this Staff again?'; if (!confirm(confirmMsg)) return; google.script.run.withSuccessHandler(function(res) { showModal(res.success ? 'Success' : 'Failed', res.message, res.success ? 'success' : 'error'); if (res.success) loadStaffList(); }).withFailureHandler(function(err) { showModal('Error', err.toString(), 'error'); }).toggleStaffStatus(user.phone, staffId, newStatus); } function deleteStaffAction(staffId) { if (!confirm('Delete this Staff permanently? This cannot be undone!')) return; google.script.run.withSuccessHandler(function(res) { showModal(res.success ? 'Success' : 'Failed', res.message, res.success ? 'success' : 'error'); if (res.success) loadStaffList(); }).withFailureHandler(function(err) { showModal('Error', err.toString(), 'error'); }).deleteStaffMember(user.phone, staffId); } // Staff Name-ஐ Click பண்ணா, அந்த Staff-க்கான Complete Attendance + Salary Summary (Previous Completed Month) தெரியும் function showStaffSummary(staffId) { var found = staffListCache.find(function(s) { return String(s.staffId) === String(staffId); }); if (!found) return; var monthKey = getPreviousCompletedMonthKeyClient(); google.script.run.withSuccessHandler(function(res) { if (!res || !res.success) { showModal('Error', (res && res.message) || 'Could not fetch summary', 'error'); return; } document.getElementById('staffSummaryTitle').innerText = res.staffName + ' — Summary'; document.getElementById('staffSummaryMonthLabel').innerText = monthLabelFromKey(res.month) + ' Payable Salary'; document.getElementById('staffSummaryPayable').innerText = Number(res.payableAmount).toLocaleString('en-IN'); document.getElementById('staffSummaryPresent').innerText = res.present; document.getElementById('staffSummaryPaidLeave').innerText = res.paidLeave; document.getElementById('staffSummaryAbsent').innerText = res.deductionDays; document.getElementById('staffSummaryWorkingDays').innerText = res.workingDays; document.getElementById('staffSummaryPayableDays').innerText = res.payableDays; document.getElementById('staffSummaryMonthly').innerText = Number(res.monthlySalary).toLocaleString('en-IN'); document.getElementById('staffSummaryPerDay').innerText = res.perDaySalary; var paidNote = document.getElementById('staffSummaryPaidNote'); if (res.isPaid) { paidNote.innerText = '✅ Already paid ₹' + Number(res.paymentInfo.paidAmount).toLocaleString('en-IN') + ' on ' + res.paymentInfo.paidAt + ' (' + res.paymentInfo.branch + ')'; paidNote.classList.remove('hidden'); } else { paidNote.classList.add('hidden'); } document.getElementById('staffSummaryModal').classList.remove('hidden'); }).withFailureHandler(function(err) { showModal('Error', err.toString(), 'error'); }).getSalaryReference(user.phone, staffId, monthKey); } function closeStaffSummary() { document.getElementById('staffSummaryModal').classList.add('hidden'); } // ================= OFFICE RENT MANAGEMENT (Settings -> Staff panel, ADMIN ONLY) ================= var officeListCache = []; function loadOfficeList() { google.script.run.withSuccessHandler(function(res) { var ul = document.getElementById('officeListUI'); if (!ul) return; ul.innerHTML = ''; if (!res || !res.success) { showModal('Error', (res && res.message) || 'Could not fetch office list', 'error'); return; } officeListCache = res.data || []; if (!officeListCache.length) { ul.innerHTML = '
  • No offices found
  • '; return; } officeListCache.forEach(function(o) { var isActive = (o.status !== 'Inactive'); var statusBadge = isActive ? '● Active' : '● Inactive'; var li = document.createElement('li'); li.className = 'manage-item'; li.style.display = 'flex'; li.style.justifyContent = 'space-between'; li.style.alignItems = 'center'; li.style.flexWrap = 'wrap'; li.style.gap = '8px'; li.innerHTML = '' + o.officeName + ' — ₹' + Number(o.monthlyRent || 0).toLocaleString('en-IN') + '/month' + (o.commitments || []).map(function(c) { return ' + ' + escapeHtml(c.name) + ' ₹' + Number(c.amount).toLocaleString('en-IN') + ''; }).join('') + ' — ' + statusBadge + '' + '
    ' + '✏️' + '' + (isActive ? '🚫' : '✅') + '' + '🗑️' + '
    '; ul.appendChild(li); }); }).withFailureHandler(function(err) { showModal('Error', err.toString(), 'error'); }).getOfficeList(user.phone); } function createNewOffice() { var officeName = document.getElementById('newOfficeName').value.trim(); var rent = document.getElementById('newOfficeRent').value, commit = document.getElementById('newOfficeCommit').value; if (!officeName) return showModal('Warning', 'Enter office name', 'error'); google.script.run.withSuccessHandler(function(res) { showModal(res.success ? 'Success' : 'Failed', res.message, res.success ? 'success' : 'error'); if (res.success) { document.getElementById('newOfficeName').value = ''; document.getElementById('newOfficeRent').value = ''; closeAddOfficeModal(); loadOfficeList(); } }).withFailureHandler(function(err) { showModal('Error', err.toString(), 'error'); }).addOffice(user.phone, { officeName: officeName, monthlyRent: rent, commitments: commit, status: 'Active' }); } function openAddOfficeModal() { document.getElementById('newOfficeName').value = ''; document.getElementById('newOfficeRent').value = ''; document.getElementById('newOfficeCommit').value = ''; document.getElementById('addOfficeModal').classList.remove('hidden'); setTimeout(function(){ document.getElementById('newOfficeName').focus(); }, 50); } function closeAddOfficeModal() { document.getElementById('addOfficeModal').classList.add('hidden'); } function openEditOfficeModal(officeId) { var found = officeListCache.find(function(o) { return String(o.officeId) === String(officeId); }); if (!found) return; document.getElementById('editOfficeId').value = found.officeId; document.getElementById('editOfficeName').value = found.officeName; document.getElementById('editOfficeRent').value = found.monthlyRent || 0; document.getElementById('editOfficeStatus').value = found.status || 'Active'; document.getElementById('editOfficeCommit').value = (found.commitments || []).map(function(c) { return c.name + ': ' + c.amount; }).join('\n'); document.getElementById('editOfficeModal').classList.remove('hidden'); } function closeEditOfficeModal() { document.getElementById('editOfficeModal').classList.add('hidden'); } function saveEditedOffice() { var payload = { officeId: document.getElementById('editOfficeId').value, officeName: document.getElementById('editOfficeName').value.trim(), monthlyRent: document.getElementById('editOfficeRent').value, commitments: document.getElementById('editOfficeCommit').value, status: document.getElementById('editOfficeStatus').value }; if (!payload.officeName) return showModal('Warning', 'Enter office name', 'error'); google.script.run.withSuccessHandler(function(res) { showModal(res.success ? 'Success' : 'Failed', res.message, res.success ? 'success' : 'error'); if (res.success) { closeEditOfficeModal(); loadOfficeList(); } }).withFailureHandler(function(err) { showModal('Error', err.toString(), 'error'); }).updateOffice(user.phone, payload); } function toggleOfficeStatusAction(officeId, newStatus) { var confirmMsg = (newStatus === 'Inactive') ? 'Deactivate this Office? It will no longer show in the Rent Expense dropdown.' : 'Activate this Office again?'; if (!confirm(confirmMsg)) return; google.script.run.withSuccessHandler(function(res) { showModal(res.success ? 'Success' : 'Failed', res.message, res.success ? 'success' : 'error'); if (res.success) loadOfficeList(); }).withFailureHandler(function(err) { showModal('Error', err.toString(), 'error'); }).toggleOfficeStatus(user.phone, officeId, newStatus); } function deleteOfficeAction(officeId) { if (!confirm('Delete this Office permanently? This cannot be undone!')) return; google.script.run.withSuccessHandler(function(res) { showModal(res.success ? 'Success' : 'Failed', res.message, res.success ? 'success' : 'error'); if (res.success) loadOfficeList(); }).withFailureHandler(function(err) { showModal('Error', err.toString(), 'error'); }).deleteOffice(user.phone, officeId); } ; var editInv = null; function editInvHint() { if (!editInv) return; var q = Math.floor(+document.getElementById('editInvQty').value || 0), d = q - editInv.qty; document.getElementById('editInvHint').innerText = !q ? 'Enter at least 1 (delete the entry to give every unit back).' : d === 0 ? 'Branch stock now ' + editInv.branchStock + ' — unchanged.' : d < 0 ? (-d) + ' unit(s) go back to ' + editInv.branch + ' stock (' + editInv.branchStock + ' → ' + (editInv.branchStock - d) + ').' : d + ' more unit(s) are taken from ' + editInv.branch + ' stock (' + editInv.branchStock + ' → ' + (editInv.branchStock - d) + ').'; } function openEditModal(sheetName, rowId) { if (user.role !== 'Admin') return; hideEditModalAlert(); var item = null; var lookupSource = monthlyPassbookOverride || cachedData; for (var bKey in lookupSource.branchPassbooks) { var found = lookupSource.branchPassbooks[bKey].find(function(t) { return t.sheetName === sheetName && String(t.rowId) === String(rowId); }); if (found) { item = found; break; } } if (!item) return; document.getElementById('editSheetName').value = item.sheetName; document.getElementById('editSheetRow').value = item.sheetRow; document.getElementById('editRowId').value = item.rowId; document.getElementById('editTxBranch').value = item.branch || ''; var editTxDateInput = document.getElementById('editTxDate'); editTxDateInput.value = toYYYYMMDD(item.date); editTxDateInput.max = todayISO(); // A Transfer row has no 'Transfer' option in editTxType (Income/Expense only) — its base type is // whichever list (ExpenseCat/IncomeCat) the real category belongs to, and the Bank Transfer checkbox // (set below, AFTER the category listener has run) is what actually marks it as a Transfer. var isTransferItem = item.type === 'Transfer'; var baseType = item.type || 'Income'; if (isTransferItem) { var catTypeMeta = cachedData.allCategorySettings.find(function(c){ return c.name.trim() === item.category.trim(); }); baseType = (catTypeMeta && catTypeMeta.type === 'IncomeCat') ? 'Income' : 'Expense'; } document.getElementById('editTxType').value = baseType; updateEditCategoriesDropdown(); // rebuilds the category list for baseType and calls onEditCategoryChange (sets the toggle to its per-category default) document.getElementById('editTxCategory').value = item.category; onEditCategoryChange(); // re-run against the ACTUAL category now that it's set, not the first option the rebuild left selected // The category listener just set the Bank Transfer checkbox to this category's default — override it // with the row's REAL saved state so loading the modal never overwrites what was actually saved. var editXferBoxLoad = document.getElementById('editBankTransferToggle'); if (editXferBoxLoad) editXferBoxLoad.checked = isTransferItem; // Actual Saved Mode-ஐயே காட்டுவோம் (Category Default recompute பண்ணாது) — Admin இங்கிருந்து Freely மாத்திக்கலாம் var modeVal = (item.mode || 'Cash').trim(); document.getElementById('editTxMode').value = (isTransferItem || modeVal === 'Bank') ? 'Bank' : 'Cash'; // Pre-select the Transfer Flow dropdown to the row's actual saved sub-type. var flowRowLoad = document.getElementById('editTransferFlowRow'); if (flowRowLoad) flowRowLoad.classList.toggle('hidden', !isTransferItem); var flowSelLoad = document.getElementById('editTransferFlow'); if (flowSelLoad) { flowSelLoad.value = (modeVal === 'BankToBankOut' || modeVal === 'BankToBankIn') ? 'BankToBank' : (modeVal === 'BankToCash') ? 'BankToCash' : (modeVal === 'UPIToBank') ? 'UPIToBank' : 'CashToBank'; } var bankSelect = document.getElementById('editTxBank'); bankSelect.innerHTML = ''; cachedData.bankList.forEach(function(bk) { bankSelect.innerHTML += ''; }); bankSelect.value = item.targetBank || ''; var toBankSelect = document.getElementById('editToBank'); if (toBankSelect) { toBankSelect.innerHTML = ''; cachedData.bankList.forEach(function(bk) { toBankSelect.innerHTML += ''; }); } onEditTransferFlowChange(); document.getElementById('editTxAmount').value = item.amount; // Inventory sale: show the sold quantity so an edit restores / takes the difference from that branch's stock editInv = null; document.getElementById('editInvRow').classList.add('hidden'); if (item.type === 'Income') google.script.run.withSuccessHandler(function(r) { if (!r || !r.success || !r.sale || String(document.getElementById('editRowId').value) !== String(rowId)) return; editInv = r; document.getElementById('editInvItemName').innerText = r.item; document.getElementById('editInvQty').value = r.qty; editInvHint(); document.getElementById('editInvRow').classList.remove('hidden'); }).withFailureHandler(function() {}).getInvSaleForRow(user.phone, rowId, sheetName); document.getElementById('editTxRemarks').value = item.remarks; // Staff/Rent Mapping இருக்குற Category ஆ இருந்தா, Saved Remark-ல் இருந்து Staff/Office Name-ஐ கண்டுபிடித்து Pre-select பண்றோம் var catObjForEdit = cachedData.allCategorySettings.find(function(c){ return c.name.trim() === item.category.trim(); }); var linkTypeForEdit = catObjForEdit ? (catObjForEdit.linkType || '').trim() : ''; var editStaffRow = document.getElementById('editTxStaffRow'); var editOfficeRow = document.getElementById('editTxOfficeRow'); var extractedName = ''; if (linkTypeForEdit && item.remarks) { var prefix = item.category.trim() + ' - '; if (item.remarks.indexOf(prefix) === 0) extractedName = item.remarks.substring(prefix.length); } if (linkTypeForEdit === 'Staff') { editStaffRow.classList.remove('hidden'); editOfficeRow.classList.add('hidden'); populateStaffSelect('editTxStaffSelect', extractedName); } else if (linkTypeForEdit === 'Rent') { editOfficeRow.classList.remove('hidden'); editStaffRow.classList.add('hidden'); populateOfficeSelect('editTxOfficeSelect', extractedName); } else { editStaffRow.classList.add('hidden'); editOfficeRow.classList.add('hidden'); } document.getElementById('editTxModal').classList.remove('hidden'); } function closeEditModal() { document.getElementById('editTxModal').classList.add('hidden'); } function updateEditCategoriesDropdown() { var t = document.getElementById('editTxType').value; var select = document.getElementById('editTxCategory'); select.innerHTML = ''; var list = (t === 'Income') ? cachedData.incomeCats : cachedData.expenseCats; list.forEach(function(c) { select.innerHTML += ''; }); onEditCategoryChange(); } function saveEditedTransaction() { hideEditModalAlert(); try { var rawDate = document.getElementById('editTxDate').value; var today = todayISO(); if (rawDate > today) { return showEditModalAlert('Future date cannot be selected!', true); } if (!(Number(document.getElementById('editTxAmount').value) > 0)) return showEditModalAlert('Enter an amount greater than 0.', true); var payload = { sheetName: document.getElementById('editSheetName').value, sheetRow: document.getElementById('editSheetRow').value, rowId: document.getElementById('editRowId').value, entryDate: toDDMMYYYY(rawDate), type: document.getElementById('editTxType').value, category: document.getElementById('editTxCategory').value, mode: document.getElementById('editTxMode').value, targetBank: document.getElementById('editTxBank').value, amount: document.getElementById('editTxAmount').value, remarks: document.getElementById('editTxRemarks').value }; if (editInv && !document.getElementById('editInvRow').classList.contains('hidden')) payload.invQty = document.getElementById('editInvQty').value; var editXferBoxSave = document.getElementById('editBankTransferToggle'); if (editXferBoxSave && editXferBoxSave.checked) { payload.type = 'Transfer'; var flowSelSave = document.getElementById('editTransferFlow'); payload.transferFlow = flowSelSave ? flowSelSave.value : 'CashToBank'; if (payload.transferFlow === 'BankToBank') { var toBankSave = document.getElementById('editToBank'); payload.toBank = toBankSave ? toBankSave.value : ''; if (!payload.toBank) { return showEditModalAlert('Select the To Bank for this Bank to Bank Transfer!', true); } } } if (!payload.amount || Number(payload.amount) <= 0) { return showEditModalAlert('Enter a valid amount', true); } if (payload.type === 'Transfer' && !payload.targetBank) { return showEditModalAlert('Select a Bank Account for this Transfer!', true); } var editCatObj = cachedData.allCategorySettings.find(function(c){ return c.name.trim() === payload.category.trim(); }); var editLinkType = editCatObj ? (editCatObj.linkType || '').trim() : ''; if (editLinkType === 'Staff') { var editStaffSelEl = document.getElementById('editTxStaffSelect'); if (!editStaffSelEl || !editStaffSelEl.value) { return showEditModalAlert('Select a Staff Name!', true); } } else if (editLinkType === 'Rent') { var editOfficeSelEl = document.getElementById('editTxOfficeSelect'); if (!editOfficeSelEl || !editOfficeSelEl.value) { return showEditModalAlert('Select an Office!', true); } } var saveBtn = document.getElementById('editSaveChangesBtn'); if (saveBtn) { saveBtn.disabled = true; saveBtn.innerText = 'Saving...'; } google.script.run.withSuccessHandler(function(res) { if (saveBtn) { saveBtn.disabled = false; saveBtn.innerText = 'Save Changes'; } if (!res || !res.success) { return showEditModalAlert((res && res.message) || 'Could not save changes', true); } showModal('Success', res.message, 'success'); closeEditModal(); loadData(); refreshMonthlyPassbookOverrideIfActive(); }).withFailureHandler(function(err) { if (saveBtn) { saveBtn.disabled = false; saveBtn.innerText = 'Save Changes'; } showEditModalAlert('Server error: ' + err.toString(), true); }).editTransactionByAdmin(user.phone, payload); } catch (e) { showEditModalAlert('Error: ' + e.toString(), true); } } function deleteSelectedTransaction() { var sheetName = document.getElementById('editSheetName').value; var rowId = document.getElementById('editRowId').value; var amt = document.getElementById('editTxAmount').value; if (!confirm('Are you sure you want to delete this ₹' + amt + ' entry permanently?\n\nNote: This is irreversible and the balance will be adjusted accordingly!')) { return; } google.script.run.withSuccessHandler(function(res) { showModal(res.success ? 'Success' : 'Failed', res.message, res.success ? 'success' : 'error'); if (res.success) { closeEditModal(); loadData(); refreshMonthlyPassbookOverrideIfActive(); } }).deleteTransactionByAdmin(user.phone, { sheetName: sheetName, rowId: rowId }); } // Admin ஒரு பழைய மாதத்தை பார்த்துகிட்டே Edit/Delete பண்ணா, அந்த மாத Passbook-ஐயும் மறுபடியும் Fetch பண்ணுவோம் function refreshMonthlyPassbookOverrideIfActive() { if (!monthlyPassbookOverride || !selectedPassbookMonthKey) return; google.script.run.withSuccessHandler(function(res) { if (!res || !res.success) return; monthlyPassbookOverride = { balances: res.balances, branchPassbooks: res.branchPassbooks, monthLabel: res.monthLabel }; renderPassbookTable(); }).getMonthlyPassbookData(user.phone, selectedPassbookMonthKey); } // Hidden-for-staff values still travel with every submission: the toggle is auto-checked and the flow // selector auto-filled from the category (see onCategoryChange), and the bank