Dashboard

Your complete overview at a glance

◷ Tasks Completed
0
◎ Habit Rate
0%
⌂ Active Projects
0
₹ Spent
₹0
Activity Overview
This week
Spend Split
Quick Insights
Business Overview
Total Projects
0
Active Projects
0
In progress
Govt Process Avg
0%
Accounts Logged
0
Projects by company
Finance Overview
Receivables
₹0
Pending payment in
Payables
₹0
Pending dues
Amount Needed
₹0
To complete projects
Payable due Receivable due
Total Budget
₹0
Allocated
Total Paid
₹0
Outstanding
₹0
Pending
Net Balance
₹0
Spending Trend
7 day view
Top Categories
Budget vs Paid
Statistics
Smart Recommendations
Tasks
0 tasks
Progress
Habit Tracker
0 habits
Streaks
Consistency Over Time
Work Schedule
Build a Routine
0 routines

Group tasks into a reusable set, then deploy them all to your task list with one click.

Quick Templates

Start from a preset and customize.

+ Add New Lead
Active Projects
0
0 in progress
Total Budget (with GST)
₹0
All projects
Total Budget (ex. GST)
₹0
Without GST component
Money to Complete
₹0
Forecast need
Net Balance (ex.GST − To Complete)
₹0
Budget ex.GST minus cost to complete
Over Budget
0
Projects flagged
Upcoming Projects
0
From leads pipeline
View Leads →
Gross Project Cost (Total)
₹0
Active + Won + Pipeline
📁 Projects
🔍
Total Materials
0
Across all projects
To Order
0
Not yet ordered
Ordered
0
Awaiting delivery
Est. Value
₹0
All listed materials
All Materials
MaterialProjectQtyVendorEst. RateEst. TotalNeeded ByStatus
Government Projects
0
In the pipeline
Avg Completion
0%
Across all stages
In Progress
0
Some stages done
Completed
0
All 15 stages
Government Work — Documentation Status
0 projects

This pipeline applies to Government-category projects only. Tap a stage to mark it complete.

Add Labour
Labour Roster
0 workers
NameRoleCategoryProjectDaily RatePhoneCompany
💵 Labour Advance
Worker
Amount
Date
Note
Petty Cash
₹0
Total spent
Project Expenses
₹0
Total spent
Purchases
₹0
Materials / equipment
Payment In
₹0
Received
New Entry
Ledger
0 entries
Type Project Party Bank Amount Date Due Status
Import Budget Data
CSV / Excel
Click to upload your budget file (CSV or Excel)
Income
₹0
Monthly
Budget
₹0
Planned
Paid
₹0
Remaining
₹0
Available
Total Available Balance
₹0
Completed ledger balance
Receivables
₹0
Pending payment in
Payables
₹0
Pending dues to vendors
Amount Needed to Complete Projects
₹0
Manually entered per project
Expected Payments

Log money you expect to receive or need to pay — these power the calendar and projected balance below.

↓ Expected IN
↑ Expected OUT
Cash Flow Calendar
Amount needed / payable due Receivable due
Bank Accounts
₹0 total

Balances are calculated automatically from Accounts ledger entries tagged to each bank — completed entries only. Tag a bank when adding an entry in Accounts.

Project Costs
0 projects

Pulled automatically from project estimates entered under Projects.

Total Project Value
₹0
All estimates combined
Government Works
₹0
Govt-category projects
Private / Other Works
₹0
All other categories
Avg per Project
₹0
Across estimated projects
Project Company Category Status Estimate Amount Needed
Add Entry
Transactions
Category Budget Paid Payable Date
🧮 Report Builder — All Datasets
Pick a module from the sidebar, filter, and export to Excel or PDF
Select a report
Search
📤 Export Reports
📕 PDF Reports
📗 Excel Workbooks
📄 CSV & Print
Executive KPIs
Top Cost Categories
Recommendations
Report Library
Every report pulls live from your real data
+ Add Fund Requirement
All Fund Requirements
`; const w = window.open('', '_blank'); if (!w) { alert('Allow pop-ups to generate the PDF report.'); return; } w.document.write(html); w.document.close(); setTimeout(()=>{ try{ w.focus(); w.print(); }catch(e){} }, 400); document.getElementById('labReportModal')?.remove(); } } function markAttendance() { const labId = Number(document.getElementById('attLabour').value); const projId = document.getElementById('attProject').value ? Number(document.getElementById('attProject').value) : null; const date = document.getElementById('attDate').value || dayKey(new Date()); const status = document.getElementById('attStatus').value; const lab = data.labours.find(l => l.id === labId); if (!lab) return; const multiplier = status === 'full' ? 1 : status === 'half' ? 0.5 : status === 'overtime' ? 1.5 : status === 'night' ? 2 : 0; const wage = Math.round((lab.dailyRate || 0) * multiplier); const proj = projId ? data.projects.find(p => p.id === projId) : null; const stLabel = { full:'full day', half:'half day', overtime:'overtime (1.5×)', night:'night shift (2×)', absent:'absent' }[status] || status; const att = { id: Date.now(), labourId: lab.id, labourName: lab.name, projectId: projId, date, status, wage, accountEntryId: null }; if (wage > 0) { const acctId = Date.now() + 1; data.accounts.push({ id: acctId, type: 'vendor-payment', projectId: proj ? proj.id : null, company: proj ? proj.company : null, bank: null, party: lab.name, amount: wage, date, status: 'pending', dueDate: '', note: `Attendance ${stLabel} — ${date}${proj ? ' ('+projNick(proj)+')' : ''}` }); att.accountEntryId = acctId; } data.attendance.push(att); const dateEl = document.getElementById('attDate'); if (dateEl) dateEl.value = dayKey(new Date()); saveData(); renderAll(); } function delAttendance(id) { const att = data.attendance.find(x => x.id === id); if (att && att.accountEntryId) data.accounts = data.accounts.filter(e => e.id !== att.accountEntryId); data.attendance = data.attendance.filter(x => x.id !== id); saveData(); renderAll(); } function toggleAttendancePayStatus(id) { const att = data.attendance.find(x => x.id === id); if (!att || !att.accountEntryId) return; const entry = data.accounts.find(e => e.id === att.accountEntryId); if (entry) { entry.status = entry.status === 'pending' ? 'completed' : 'pending'; saveData(); renderAll(); } } function renderAttendance() { populateAttendanceSelects(); populateReportFilters(); const weekPanel = document.getElementById('attModeWeek'); if (weekPanel && weekPanel.style.display !== 'none') renderAttendanceWeek(); const tbody = document.getElementById('attendanceTableBody'); if (!tbody) return; const list = [...(data.attendance || [])].sort((a, b) => (b.id||0) - (a.id||0)); const meta = document.getElementById('attendanceCountMeta'); if (meta) meta.textContent = `${list.length} entries`; const statusLabel = { full: 'Full Day', half: 'Half Day', overtime: 'Overtime', night: 'Night Shift', absent: 'Absent' }; const statusPill = { full:'pill-green', half:'pill-amber', overtime:'pill-violet', night:'pill-violet', absent:'pill-rose' }; tbody.innerHTML = list.length ? list.map(att => { const proj = att.projectId ? data.projects.find(p => p.id === att.projectId) : null; const labRec = data.labours.find(l => l.id === att.labourId || l.name === att.labourName); const assigned = labRec ? labProjectIds(labRec).map(id => { const p = data.projects.find(x => x.id === id); return p ? projNick(p) : null; }).filter(Boolean) : []; const projCell = proj ? escHtml(projNick(proj)) : (assigned.length ? `${escHtml(assigned.join(', '))}` : '—'); const entry = att.accountEntryId ? data.accounts.find(e => e.id === att.accountEntryId) : null; const paid = entry ? entry.status !== 'pending' : null; return ` ${att.date} ${escHtml(att.labourName)} ${projCell} ${statusLabel[att.status]||att.status} ${fmtINR(att.wage)} ${entry ? `${paid?'Paid':'Pending'}` : ''} `; }).join('') : 'No attendance marked yet'; } // ===== LABOUR REPORTS (Excel / PDF) ===== function populateReportFilters() { const projSel = document.getElementById('rptProject'); if (projSel) { const prev = projSel.value; projSel.innerHTML = '' + (data.projects||[]).map(p=>``).join(''); projSel.value = prev; } const catSel = document.getElementById('rptCategory'); if (catSel) { const prev = catSel.value; const cats = [...new Set((data.labours||[]).map(l=>(l.category||'').trim()).filter(Boolean))].sort(); catSel.innerHTML = '' + cats.map(c=>``).join(''); catSel.value = prev; } const labSel = document.getElementById('rptLabour'); if (labSel) { const prev = labSel.value; labSel.innerHTML = '' + (data.labours||[]).map(l=>``).join(''); labSel.value = prev; } } function buildLabourReport() { const f = { type: document.getElementById('rptType')?.value || 'attendance', from: document.getElementById('rptFrom')?.value || '', to: document.getElementById('rptTo')?.value || '', project: document.getElementById('rptProject')?.value || '', category: document.getElementById('rptCategory')?.value || '', labour: document.getElementById('rptLabour')?.value || '' }; const inR = ds => (!f.from || ds >= f.from) && (!f.to || ds <= f.to); const projName = id => { const p = (data.projects||[]).find(x => x.id === id); return p ? projNick(p) : ''; }; const labOf = a => data.labours.find(l => l.id === a.labourId || l.name === a.labourName) || {}; const matchProjAtt = a => { if (!f.project) return true; if (String(a.projectId||'') === f.project) return true; return (a.projectId == null) && labProjectIds(labOf(a)).map(String).includes(f.project); }; if (f.type === 'attendance') { const statusLabel = { full:'Full Day', half:'Half Day', overtime:'Overtime', night:'Night Shift', absent:'Absent' }; const rows = (data.attendance||[]) .filter(a => inR(a.date) && matchProjAtt(a)) .map(a => ({ a, lab: labOf(a) })) .filter(x => (!f.category || (x.lab.category||'') === f.category) && (!f.labour || x.a.labourName === f.labour)) .sort((x,y) => (x.a.date||'').localeCompare(y.a.date||'') || (x.a.labourName||'').localeCompare(y.a.labourName||'')); const header = ['Date','Labour','Role','Category','Project','Status','Wage (₹)','Pay Status']; const aoa = rows.map(({a,lab}) => { const entry = a.accountEntryId ? (data.accounts||[]).find(e=>e.id===a.accountEntryId) : null; const pay = entry ? (entry.status!=='pending'?'Paid':'Pending') : '—'; const pj = a.projectId ? projName(a.projectId) : labProjectIds(lab).map(projName).filter(Boolean).join(', '); return [a.date, a.labourName, lab.role||'', lab.category||'', pj, statusLabel[a.status]||a.status, a.wage||0, pay]; }); const totalWage = rows.reduce((s,x)=>s+Number(x.a.wage||0),0); return { title:'Labour Attendance Report', header, rows: aoa, footer:['TOTAL','','','','','', totalWage, ''], meta:f }; } else { const header = ['Worker','Role','Category','Project(s)','Total Wages (₹)','Bonus (₹)','Paid (₹)','Balance (₹)']; const aoa = []; let tW=0,tB=0,tP=0,tBal=0; (data.labours||[]) .filter(l => (!f.category || (l.category||'')===f.category) && (!f.labour || l.name===f.labour) && (!f.project || labProjectIds(l).map(String).includes(f.project))) .sort((a,b)=>a.name.localeCompare(b.name)) .forEach(l => { const wages = (data.attendance||[]).filter(a=>a.labourName===l.name && a.wage>0 && inR(a.date) && matchProjAtt(a)).reduce((s,a)=>s+Number(a.wage||0),0); const bonus = computeLabourBonus(l.name, inR).total; const total = wages + bonus; const paid = labourPaidTotal(l.name, inR); const bal = total - paid; if (total === 0 && paid === 0) return; aoa.push([l.name, l.role||'', l.category||'', labProjectIds(l).map(projName).filter(Boolean).join(', '), total, bonus, paid, bal]); tW+=total; tB+=bonus; tP+=paid; tBal+=bal; }); return { title:'Labour Payable Report', header, rows: aoa, footer:['TOTAL','','','', tW, tB, tP, tBal], meta:f }; } } function reportMetaText(rep) { const f = rep.meta; const parts = []; parts.push('Period: ' + (f.from||'start') + ' → ' + (f.to||'today')); if (f.project) { const p=(data.projects||[]).find(x=>String(x.id)===f.project); parts.push('Project: ' + (p?projNick(p):f.project)); } if (f.category) parts.push('Category: ' + f.category); if (f.labour) parts.push('Worker: ' + f.labour); return parts.join(' · '); } function generateLabourReport(format) { const rep = buildLabourReport(); if (!rep.rows.length) { alert('No data for the selected filters.'); return; } const fname = rep.title.replace(/\s+/g,'_') + '_' + dayKey(new Date()); if (format === 'excel') { if (typeof XLSX === 'undefined') { alert('Excel library not loaded.'); return; } const sheet = [[rep.title], [reportMetaText(rep)], [], rep.header, ...rep.rows, rep.footer]; const ws = XLSX.utils.aoa_to_sheet(sheet); const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, 'Report'); XLSX.writeFile(wb, fname + '.xlsx'); if (typeof showToast === 'function') showToast('✅ Excel report downloaded'); } else { const win = window.open('', '_blank'); if (!win) { alert('Allow pop-ups to export the PDF.'); return; } const cell = c => typeof c === 'number' ? `₹${c.toLocaleString('en-IN')}` : `${escHtml(String(c))}`; win.document.write(`${escHtml(rep.title)}

${escHtml(rep.title)}

${escHtml(reportMetaText(rep))}
Generated ${escHtml(new Date().toLocaleString('en-IN'))}
${rep.header.map(h=>``).join('')}${rep.rows.map(r=>`${r.map(cell).join('')}`).join('')}${rep.footer.map(cell).join('')}
${escHtml(h)}
setTimeout(function(){window.print();},400); `); win.document.close(); if (typeof showToast === 'function') showToast('🖨 PDF print view opened'); } } // ===== DOCUMENTS: Purchase Order / Invoice / Quotation ===== const DOC_TYPE_META = { po: { label: 'Purchase Order', prefix: 'PO', store: 'purchaseOrders', counterKey: 'po', partyRole: 'Vendor' }, invoice: { label: 'Invoice', prefix: 'INV', store: 'invoices', counterKey: 'inv', partyRole: 'Bill To' }, quotation: { label: 'Quotation', prefix: 'QTN', store: 'quotations', counterKey: 'qtn', partyRole: 'To' }, bill: { label: 'Vendor Bill', prefix: 'BILL', store: 'vendorBills', counterKey: 'bill', partyRole: 'Vendor' } }; let docBuilderType = 'po'; let docBuilderItems = []; let docFilterType = 'all'; let docGSTEnabled = false; let docGSTRate = 18; let docGSTType = 'cgst_sgst'; // 'cgst_sgst' | 'igst' let docEditId = null; // id of doc being edited (null = new) let pendingPOMaterialIds = null; // [{projectId, materialIds}] when launched from "Create PO" on materials let pendingPOTaskIds = null; // {projectId, taskIds} when launched from "Create PO" on selected BOQ tasks function nextDocNumber(type) { const meta = DOC_TYPE_META[type]; data.docCounter = data.docCounter || { po: 0, inv: 0, qtn: 0 }; data.docCounter[meta.counterKey] = (data.docCounter[meta.counterKey] || 0) + 1; return `${meta.prefix}-${String(data.docCounter[meta.counterKey]).padStart(4, '0')}`; } function populateDocProjectSelect() { // Fill the datalist so existing project names appear as suggestions const dl = document.getElementById('docProjectList'); if (!dl) return; const names = (data.projects || []).map(p => projNick(p)).filter(Boolean); dl.innerHTML = names.map(n => ``).join(''); } function populateDocPartySourceSelect() { const sel = document.getElementById('docPartySource'); if (!sel) return; let options = []; if (docBuilderType === 'po') options = (data.vendors || []).map(v => ({ id: 'v'+v.id, name: v.name })); else if (docBuilderType === 'bill') options = [ ...(data.vendors || []).map(v => ({ id: 'v'+v.id, name: v.name })), ...(data.subcontractors || []).map(s => ({ id: 's'+s.id, name: s.name })) ]; else if (docBuilderType === 'quotation') options = [ ...(data.vendors || []).map(v => ({ id: 'v'+v.id, name: v.name })), ...(data.subcontractors || []).map(s => ({ id: 's'+s.id, name: s.name })) ]; else options = (data.projects || []).map(p => ({ id: 'c'+p.id, name: p.client })); sel.innerHTML = '' + options.map(o => ``).join(''); } function onDocPartySourceChange() { const sel = document.getElementById('docPartySource'); const nameEl = document.getElementById('docPartyName'); if (sel && sel.value && nameEl) nameEl.value = sel.value; } function openSalesDocBuilder(type, prefillItems = null, projectId = null) { openDocBuilder(type, prefillItems, projectId); } function openDocBuilder(type, prefillItems = null, projectId = null) { docBuilderType = type; docEditId = null; // new document docBuilderItems = prefillItems && prefillItems.length ? prefillItems.map(i => ({ ...i })) : [{ desc: '', qty: 1, unit: '', rate: 0 }]; const meta = DOC_TYPE_META[type]; const titleEl = document.getElementById('docBuilderTitle'); if (titleEl) titleEl.textContent = `New ${meta.label}`; const errEl = document.getElementById('docBuilderError'); if (errEl) { errEl.style.display = 'none'; errEl.textContent = ''; } // Reset GST state docGSTEnabled = false; docGSTRate = 18; docGSTType = 'cgst_sgst'; populateDocProjectSelect(); populateDocPartySourceSelect(); const projEl = document.getElementById('docProject'); if (projEl) { if (projectId) { const matchP = (data.projects || []).find(p => p.id === Number(projectId)); projEl.value = matchP ? projNick(matchP) : ''; } else { projEl.value = ''; } } const dateEl = document.getElementById('docDate'); if (dateEl) dateEl.value = dayKey(new Date()); const dueDateEl = document.getElementById('docDueDate'); if (dueDateEl) dueDateEl.value = ''; const statusEl = document.getElementById('docStatus'); if (statusEl) { statusEl.value = type === 'invoice' ? 'draft' : type === 'bill' ? 'pending' : 'draft'; } const partyEl = document.getElementById('docPartyName'); if (partyEl) partyEl.value = ''; const partySrcEl = document.getElementById('docPartySource'); if (partySrcEl) partySrcEl.value = ''; const noteEl = document.getElementById('docNote'); if (noteEl) noteEl.value = ''; applyDocGSTState(); renderDocLineItems(); const modal = document.getElementById('docBuilderModal'); if (modal) modal.classList.add('show'); } function closeDocBuilder() { const modal = document.getElementById('docBuilderModal'); if (modal) modal.classList.remove('show'); pendingPOMaterialIds = null; pendingPOTaskIds = null; docEditId = null; } // ===== EDIT A SALES DOCUMENT ===== function editSalesDoc(type, id) { const meta = DOC_TYPE_META[type]; const rec = (data[meta.store] || []).find(d => d.id === id); if (!rec) return; docBuilderType = type; docEditId = id; // mark edit mode docBuilderItems = rec.items.map(it => ({ ...it })); const titleEl = document.getElementById('docBuilderTitle'); if (titleEl) titleEl.textContent = `Edit ${meta.label} — ${rec.number}`; const errEl = document.getElementById('docBuilderError'); if (errEl) { errEl.style.display = 'none'; errEl.textContent = ''; } populateDocProjectSelect(); populateDocPartySourceSelect(); // Restore project field const projEl = document.getElementById('docProject'); if (projEl) { if (rec.projectId) { const mp = (data.projects || []).find(p => p.id === rec.projectId); projEl.value = mp ? projNick(mp) : (rec.projectLabel || ''); } else { projEl.value = rec.projectLabel || ''; } } const dateEl = document.getElementById('docDate'); if (dateEl) dateEl.value = rec.date || dayKey(new Date()); const dueDateEl = document.getElementById('docDueDate'); if (dueDateEl) dueDateEl.value = rec.dueDate || ''; const statusEl = document.getElementById('docStatus'); if (statusEl) statusEl.value = rec.docStatus || 'draft'; const partyEl = document.getElementById('docPartyName'); if (partyEl) partyEl.value = rec.party || ''; const noteEl = document.getElementById('docNote'); if (noteEl) noteEl.value = rec.note || ''; // Restore GST state from saved record docGSTEnabled = !!rec.gst; docGSTRate = rec.gst?.rate || 18; docGSTType = rec.gst?.type || 'cgst_sgst'; applyDocGSTState(); renderDocLineItems(); const modal = document.getElementById('docBuilderModal'); if (modal) modal.classList.add('show'); } // ===== GST TOGGLE ===== function toggleDocGST() { docGSTEnabled = !docGSTEnabled; applyDocGSTState(); updateDocGSTDisplay(); } function setDocGSTType(t) { docGSTType = t; document.querySelectorAll('#docGSTType .filter-btn').forEach(b => b.classList.toggle('active', b.dataset.gtype === t)); updateDocGSTDisplay(); } function applyDocGSTState() { const icon = document.getElementById('docGSTToggleIcon'); const rateRow = document.getElementById('docGSTRateRow'); const breakdown = document.getElementById('docGSTBreakdown'); if (icon) icon.textContent = docGSTEnabled ? '☑' : '☐'; if (rateRow) rateRow.style.display = docGSTEnabled ? 'flex' : 'none'; if (breakdown) breakdown.style.display = docGSTEnabled ? '' : 'none'; const rateEl = document.getElementById('docGSTRate'); if (rateEl) rateEl.value = String(docGSTRate); document.querySelectorAll('#docGSTType .filter-btn').forEach(b => b.classList.toggle('active', b.dataset.gtype === docGSTType)); if (docGSTEnabled) updateDocGSTDisplay(); } function updateDocGSTDisplay() { const rateEl = document.getElementById('docGSTRate'); if (rateEl) docGSTRate = Number(rateEl.value) || 18; const subtotal = computeDocSubtotal(); const gstAmt = Math.round(subtotal * docGSTRate / 100 * 100) / 100; const half = Math.round(gstAmt / 2 * 100) / 100; const tbody = document.getElementById('docGSTRows'); if (tbody) { if (docGSTType === 'cgst_sgst') { tbody.innerHTML = ` Subtotal${fmtINR(subtotal)} CGST @ ${docGSTRate/2}%${fmtINR(half)} SGST @ ${docGSTRate/2}%${fmtINR(half)} Total (with GST ${docGSTRate}%)${fmtINR(subtotal + gstAmt)}`; } else { tbody.innerHTML = ` Subtotal${fmtINR(subtotal)} IGST @ ${docGSTRate}%${fmtINR(gstAmt)} Total (with GST ${docGSTRate}%)${fmtINR(subtotal + gstAmt)}`; } } const totalEl = document.getElementById('docTotalDisplay'); if (totalEl) totalEl.textContent = fmtINR(computeDocTotal()); } function addDocLineItem() { docBuilderItems.push({ desc: '', qty: 1, unit: '', rate: 0 }); renderDocLineItems(); } function removeDocLineItem(idx) { docBuilderItems.splice(idx, 1); if (!docBuilderItems.length) docBuilderItems.push({ desc: '', qty: 1, unit: '', rate: 0 }); renderDocLineItems(); } function updateDocLineItem(idx, field, value) { if (!docBuilderItems[idx]) return; docBuilderItems[idx][field] = (field === 'qty' || field === 'rate') ? Number(value) || 0 : value; // Only patch the row's own total + the grand total in place — do NOT re-render the // whole line-items list here, or every keystroke destroys and recreates the // elements, kicking focus out after a single character (this was the core bug). const it = docBuilderItems[idx]; const rowTotalEl = document.querySelector(`#docLineItems [data-row-total="${idx}"]`); if (rowTotalEl) rowTotalEl.textContent = fmtINR((Number(it.qty)||0) * (Number(it.rate)||0)); if (docGSTEnabled) updateDocGSTDisplay(); else { const totalEl = document.getElementById('docTotalDisplay'); if (totalEl) totalEl.textContent = fmtINR(computeDocTotal()); } } function computeDocSubtotal() { return docBuilderItems.reduce((s, it) => s + (Number(it.qty) || 0) * (Number(it.rate) || 0), 0); } function computeDocTotal() { const sub = computeDocSubtotal(); if (!docGSTEnabled) return sub; return sub + Math.round(sub * docGSTRate / 100 * 100) / 100; } function renderDocLineItems() { const el = document.getElementById('docLineItems'); if (!el) return; el.innerHTML = docBuilderItems.map((it, idx) => `
${fmtINR((Number(it.qty)||0)*(Number(it.rate)||0))}
`).join(''); if (docGSTEnabled) updateDocGSTDisplay(); else { const totalEl = document.getElementById('docTotalDisplay'); if (totalEl) totalEl.textContent = fmtINR(computeDocTotal()); } } function buildDocRecord() { const errEl = document.getElementById('docBuilderError'); const showError = (msg) => { if (errEl) { errEl.textContent = msg; errEl.style.display = ''; } }; if (errEl) { errEl.style.display = 'none'; errEl.textContent = ''; } const projEl = document.getElementById('docProject'); const projText = projEl ? projEl.value.trim() : ''; // Try to match typed text to an existing project by nickname or name const matchedProj = projText ? (data.projects || []).find(p => projNick(p).toLowerCase() === projText.toLowerCase() || (p.name||'').toLowerCase() === projText.toLowerCase()) : null; const projectId = matchedProj ? matchedProj.id : null; const projectLabel = projText || null; // preserve the typed text if no match const party = document.getElementById('docPartyName').value.trim(); const date = document.getElementById('docDate').value || dayKey(new Date()); const note = document.getElementById('docNote').value.trim(); const statusEl = document.getElementById('docStatus'); const docStatus = statusEl ? statusEl.value : 'draft'; const dueDateEl = document.getElementById('docDueDate'); const dueDate = dueDateEl ? dueDateEl.value : ''; const items = docBuilderItems .filter(it => (it.desc||'').trim() || Number(it.qty) > 0 || Number(it.rate) > 0) .map((it, i) => ({ ...it, desc: (it.desc||'').trim() || `Item ${i+1}` })); if (!party) { showError('Please enter a Vendor / Client / Subcontractor name before saving.'); return null; } if (!items.length) { showError('Add at least one line item (a description, quantity, or rate) before saving.'); return null; } const subtotal = items.reduce((s, it) => s + (Number(it.qty)||0) * (Number(it.rate)||0), 0); const gstInfo = docGSTEnabled ? { enabled: true, rate: docGSTRate, type: docGSTType, amount: Math.round(subtotal * docGSTRate / 100 * 100) / 100, cgst: docGSTType === 'cgst_sgst' ? Math.round(subtotal * docGSTRate / 200 * 100) / 100 : 0, sgst: docGSTType === 'cgst_sgst' ? Math.round(subtotal * docGSTRate / 200 * 100) / 100 : 0, igst: docGSTType === 'igst' ? Math.round(subtotal * docGSTRate / 100 * 100) / 100 : 0 } : null; const total = subtotal + (gstInfo ? gstInfo.amount : 0); return { id: docEditId || Date.now(), number: docEditId ? ((() => { const meta = DOC_TYPE_META[docBuilderType]; const ex = (data[meta.store]||[]).find(d=>d.id===docEditId); return ex?.number || nextDocNumber(docBuilderType); })()) : nextDocNumber(docBuilderType), type: docBuilderType, projectId, projectLabel, party, date, dueDate, note, docStatus, items, total, gst: gstInfo }; } function saveDocBuilder() { // Defensive guards: self-heal if these arrays were ever missing/corrupted, rather // than throwing and silently failing the save with no feedback to the user. data.purchaseOrders = Array.isArray(data.purchaseOrders) ? data.purchaseOrders : []; data.invoices = Array.isArray(data.invoices) ? data.invoices : []; data.quotations = Array.isArray(data.quotations) ? data.quotations : []; data.vendorBills = Array.isArray(data.vendorBills) ? data.vendorBills : []; data.accounts = Array.isArray(data.accounts) ? data.accounts : []; if (!data.docCounter || typeof data.docCounter !== 'object') data.docCounter = { po: 0, inv: 0, qtn: 0, bill: 0 }; try { const rec = buildDocRecord(); if (!rec) return null; const meta = DOC_TYPE_META[docBuilderType]; if (docEditId) { // Edit mode — replace in-place const idx = data[meta.store].findIndex(d => d.id === docEditId); if (idx !== -1) data[meta.store][idx] = rec; else data[meta.store].push(rec); docEditId = null; } else { data[meta.store].push(rec); } // Reflect into Accounts: a PO logs a pending purchase; an Invoice logs a pending receivable. if (docBuilderType === 'po') { data.accounts.push({ id: Date.now()+1, type: 'purchase', projectId: rec.projectId, company: null, bank: null, party: rec.party, amount: rec.total, date: rec.date, status: 'pending', dueDate: '', note: `${rec.number} — Purchase Order` }); if (pendingPOMaterialIds && pendingPOMaterialIds.length) { pendingPOMaterialIds.forEach(group => { const proj = data.projects.find(p => p.id === group.projectId); if (proj) (proj.materials || []).forEach(m => { if (group.materialIds.includes(m.id)) { m.status = 'ordered'; m.poNumber = rec.number; } }); }); } if (pendingPOTaskIds && pendingPOTaskIds.taskIds && pendingPOTaskIds.taskIds.length) { const proj = data.projects.find(p => p.id === pendingPOTaskIds.projectId); if (proj) (proj.tasks || []).forEach(t => { if (pendingPOTaskIds.taskIds.includes(t.id)) t.poNumber = rec.number; }); } } else if (docBuilderType === 'invoice') { data.accounts.push({ id: Date.now()+1, type: 'payment-in', projectId: rec.projectId, company: null, bank: null, party: rec.party, amount: rec.total, date: rec.date, status: 'pending', dueDate: '', note: `${rec.number} — Invoice` }); } else if (docBuilderType === 'bill') { data.accounts.push({ id: Date.now()+1, type: 'vendor-payment', projectId: rec.projectId, company: null, bank: null, party: rec.party, amount: rec.total, date: rec.date, status: 'pending', dueDate: '', note: `${rec.number} — Vendor Bill` }); } saveData(); renderAll(); closeDocBuilder(); return rec; } catch (err) { console.error('saveDocBuilder failed:', err); const errEl = document.getElementById('docBuilderError'); if (errEl) { errEl.textContent = 'Something went wrong saving this document: ' + err.message + ' — please try again.'; errEl.style.display = ''; } return null; } } function saveAndPrintDocBuilder() { const rec = saveDocBuilder(); if (rec) printDocument(docBuilderType, rec.id); } function setDocFilter(type) { docFilterType = type; document.querySelectorAll('#docFilterSwitch .filter-btn').forEach(b => b.classList.toggle('active', b.dataset.doctype === type)); renderDocuments(); } function allDocuments() { return [ ...(data.purchaseOrders || []).map(d => ({ ...d, type: 'po' })), ...(data.invoices || []).map(d => ({ ...d, type: 'invoice' })), ...(data.quotations || []).map(d => ({ ...d, type: 'quotation' })), ...(data.vendorBills || []).map(d => ({ ...d, type: 'bill' })) ].sort((a, b) => (b.id||0) - (a.id||0)); } function renderDocuments() { renderAllSalesSections(); } // ===== VYAPAAR-STYLE SALES SECTION RENDERER ===== const SALES_TYPE_CONFIG = { invoice: { store: 'invoices', listId: 'invoiceList', totalsId: 'invoiceTotals', searchId: 'invoiceSearch', statusId: 'invoiceStatusFilter', icon: '🧾', iconBg: 'rgba(10,132,255,0.15)', emptyIcon: '🧾', emptyLabel: 'No invoices yet', emptySub: 'Create your first invoice for a client' }, quotation: { store: 'quotations', listId: 'quotationList', totalsId: 'quotationTotals', searchId: 'quotationSearch', statusId: 'quotationStatusFilter', icon: '📝', iconBg: 'rgba(191,90,242,0.15)', emptyIcon: '📝', emptyLabel: 'No quotations yet', emptySub: 'Create a quotation for a client or project' }, po: { store: 'purchaseOrders', listId: 'poList', totalsId: 'poTotals', searchId: 'poSearch', statusId: 'poStatusFilter', icon: '📦', iconBg: 'rgba(255,159,10,0.15)', emptyIcon: '📦', emptyLabel: 'No purchase orders yet', emptySub: 'Create a PO for a vendor or material' }, bill: { store: 'vendorBills', listId: 'billList', totalsId: 'billTotals', searchId: 'billSearch', statusId: 'billStatusFilter', icon: '🏭', iconBg: 'rgba(255,69,58,0.15)', emptyIcon: '🏭', emptyLabel: 'No vendor bills yet', emptySub: 'Record a bill received from a vendor' } }; const SALES_STATUS_LABELS = { draft:'Draft', sent:'Sent', accepted:'Accepted', paid:'Paid', pending:'Pending', overdue:'Overdue' }; const SALES_STATUS_NEXT = { draft:'sent', sent:'paid', paid:'paid', accepted:'accepted', pending:'paid', overdue:'paid' }; function cycleDocStatus(type, id) { const cfg = SALES_TYPE_CONFIG[type]; const list = data[cfg.store]; const doc = list.find(d => d.id === id); if (!doc) return; const cur = doc.docStatus || 'draft'; doc.docStatus = SALES_STATUS_NEXT[cur] || 'draft'; saveData(); renderAll(); } function renderSalesSection(type) { const cfg = SALES_TYPE_CONFIG[type]; if (!cfg) return; const listEl = document.getElementById(cfg.listId); const totalsEl = document.getElementById(cfg.totalsId); const searchEl = document.getElementById(cfg.searchId); const statusEl = document.getElementById(cfg.statusId); const todayKey = dayKey(new Date()); if (!listEl) return; let docs = [...(data[cfg.store] || [])].sort((a, b) => (b.id||0) - (a.id||0)); const q = searchEl ? searchEl.value.trim().toLowerCase() : ''; if (q) docs = docs.filter(d => (d.party||'').toLowerCase().includes(q) || (d.number||'').toLowerCase().includes(q) || (d.note||'').toLowerCase().includes(q)); const statusFilter = statusEl ? statusEl.value : 'all'; if (statusFilter !== 'all') docs = docs.filter(d => (d.docStatus || 'draft') === statusFilter); // Totals if (totalsEl) { const all = data[cfg.store] || []; const total = all.reduce((s, d) => s + Number(d.total||0), 0); const paid = all.filter(d => d.docStatus === 'paid').reduce((s, d) => s + Number(d.total||0), 0); const pending = all.filter(d => !['paid'].includes(d.docStatus||'draft')).reduce((s, d) => s + Number(d.total||0), 0); const overdueList = all.filter(d => d.dueDate && d.dueDate < todayKey && d.docStatus !== 'paid'); totalsEl.innerHTML = `
Total ${DOC_TYPE_META[type]?.label || ''}
${fmtINR(total)}
${all.length} document${all.length===1?'':'s'}
Paid
${fmtINR(paid)}
${all.filter(d=>d.docStatus==='paid').length} paid
${overdueList.length ? '⚠ Overdue' : 'Outstanding'}
${fmtINR(pending)}
${overdueList.length ? overdueList.length+' overdue' : 'not yet paid'}
`; } if (!docs.length) { listEl.innerHTML = `
${cfg.emptyIcon}
${cfg.emptyLabel}
${cfg.emptySub}
`; return; } listEl.innerHTML = docs.map(d => { const proj = d.projectId ? data.projects.find(p => p.id === d.projectId) : null; const projDisplay = proj ? projNick(proj) : (d.projectLabel || null); const status = d.docStatus || 'draft'; const isOverdue = d.dueDate && d.dueDate < todayKey && status !== 'paid'; const displayStatus = isOverdue ? 'overdue' : status; return `
${cfg.icon}
${d.number}
${escHtml(d.party)}
${d.date}${d.dueDate?' · Due '+d.dueDate:''}${projDisplay?' · '+escHtml(projDisplay):''}${d.gst?' · GST '+d.gst.rate+'%':''}
${d.note ? `
${escHtml(d.note)}
` : ''}
${fmtINR(d.total)}
${SALES_STATUS_LABELS[displayStatus]||displayStatus}
`; }).join(''); } function renderAllSalesSections() { ['invoice','quotation','po','bill'].forEach(renderSalesSection); } function delDocument(type, id) { const meta = DOC_TYPE_META[type]; data[meta.store] = data[meta.store].filter(d => d.id !== id); saveData(); renderAll(); } function printDocument(type, id) { const meta = DOC_TYPE_META[type]; const rec = (data[meta.store] || []).find(d => d.id === id); if (!rec) return; const proj = rec.projectId ? data.projects.find(p => p.id === rec.projectId) : null; const projName = proj ? proj.name : (rec.projectLabel || null); const itemRows = rec.items.map(it => ` ${escHtml(it.desc)} ${it.qty} ${escHtml(it.unit||'')} ${fmtINR(it.rate)} ${fmtINR((Number(it.qty)||0)*(Number(it.rate)||0))} `).join(''); const subtotal = rec.items.reduce((s,it) => s + (Number(it.qty)||0) * (Number(it.rate)||0), 0); const gstRows = rec.gst ? (rec.gst.type === 'cgst_sgst' ? ` Subtotal${fmtINR(subtotal)} CGST @ ${rec.gst.rate/2}%${fmtINR(rec.gst.cgst)} SGST @ ${rec.gst.rate/2}%${fmtINR(rec.gst.sgst)} ` : ` Subtotal${fmtINR(subtotal)} IGST @ ${rec.gst.rate}%${fmtINR(rec.gst.igst)} `) : ''; const html = ` `; const area = document.getElementById('printDocArea'); if (area) area.innerHTML = html; setTimeout(() => window.print(), 50); } // ----- Materials to Order (per project) — bridges into Purchase Orders ----- function restoreLoansFromHistory() { // Pre-populate loans from Newton's known debt data if empty if (!Array.isArray(data.loans)) data.loans = []; if (data.loans.length > 0) return; // already has loan data const knownLoans = [ {id:1001,lender:"Muthoot Finance",category:"jewel",principal:0,amtReceived:0,amount:9152,tenure:18,startDate:"2025-01-01",endDate:"2026-06-01",interestRate:0,status:"active",note:"SBI debit - PERSONAL LOAN"}, {id:1002,lender:"Propelld / AXIS",category:"education",principal:0,amtReceived:0,amount:7293,tenure:24,startDate:"2025-09-01",endDate:"2027-09-01",interestRate:0,status:"active",note:"NOVATR EDUCATION LOAN - AXIS debit"}, {id:1003,lender:"Axis Bakredit",category:"personal",principal:0,amtReceived:0,amount:6479,tenure:0,startDate:"",endDate:"",interestRate:0,status:"active",note:"SMITHA loan - AXIS debit"}, {id:1004,lender:"Aravind / Google",category:"device",principal:0,amtReceived:0,amount:11232,tenure:0,startDate:"2026-07-01",endDate:"",interestRate:0,status:"active",note:"Google Mobile - due 9th"}, {id:1005,lender:"Shriram Finance",category:"vehicle",principal:0,amtReceived:0,amount:23883,tenure:0,startDate:"",endDate:"",interestRate:0,status:"active",note:"CAR loan - due 20th"}, {id:1006,lender:"FIBE",category:"personal",principal:0,amtReceived:0,amount:0,tenure:0,startDate:"",endDate:"",interestRate:0,status:"active",note:"FIBE"}, ]; data.loans = knownLoans; } function addProjectMaterial(projectId) { const p = data.projects.find(x => x.id === projectId); if (!p) return; const name = document.getElementById('matName')?.value.trim(); if (!name) return; const qty = Number(document.getElementById('matQty')?.value) || 0; const unit = document.getElementById('matUnit')?.value.trim() || ''; const vendorName = document.getElementById('matVendor')?.value.trim() || ''; const estRate = Number(document.getElementById('matRate')?.value) || 0; const dateNeeded = document.getElementById('matDate')?.value || ''; p.materials = Array.isArray(p.materials) ? p.materials : []; p.materials.push({ id: Date.now(), name, qty, unit, vendorName, estRate, status: 'to-order', dateNeeded }); ['matName','matQty','matUnit','matVendor','matRate','matDate'].forEach(id => { const e = document.getElementById(id); if (e) e.value = ''; }); saveData(); renderAll(); } function delProjectMaterial(projectId, matId) { const p = data.projects.find(x => x.id === projectId); if (p) { p.materials = (p.materials||[]).filter(m => m.id !== matId); saveData(); renderAll(); } } function cycleMaterialStatus(projectId, matId) { const p = data.projects.find(x => x.id === projectId); const m = p?.materials?.find(x => x.id === matId); if (!m) return; const order = ['to-order', 'ordered', 'received']; m.status = order[(order.indexOf(m.status) + 1) % order.length]; saveData(); renderAll(); } function createPOFromMaterials(projectId) { const p = data.projects.find(x => x.id === projectId); if (!p) return; const toOrder = (p.materials || []).filter(m => m.status === 'to-order'); if (!toOrder.length) { alert('No materials marked "To Order" for this project.'); return; } const items = toOrder.map(m => ({ desc: m.name, qty: m.qty || 1, unit: m.unit, rate: m.estRate })); pendingPOMaterialIds = [{ projectId, materialIds: toOrder.map(m => m.id) }]; openDocBuilder('po', items, projectId); // Pre-fill vendor name if all materials share the same vendor const vendors = new Set(toOrder.map(m => m.vendorName).filter(Boolean)); if (vendors.size === 1) { const partyEl = document.getElementById('docPartyName'); if (partyEl) partyEl.value = [...vendors][0]; } } // ----- Design Management (per project) ----- function addProjectDesign(projectId) { const p = data.projects.find(x => x.id === projectId); if (!p) return; const name = document.getElementById('desName')?.value.trim(); if (!name) return; const rev = document.getElementById('desRev')?.value.trim() || ''; const status = document.getElementById('desStatus')?.value || 'pending'; const date = document.getElementById('desDate')?.value || dayKey(new Date()); const by = document.getElementById('desBy')?.value.trim() || ''; p.designs = Array.isArray(p.designs) ? p.designs : []; p.designs.push({ id: Date.now(), name, rev, status, date, by }); ['desName','desRev','desBy'].forEach(id => { const e = document.getElementById(id); if (e) e.value = ''; }); saveData(); renderAll(); } function delProjectDesign(projectId, designId) { const p = data.projects.find(x => x.id === projectId); if (p) { p.designs = (p.designs||[]).filter(d => d.id !== designId); saveData(); renderAll(); } } function cycleDesignStatus(projectId, designId) { const p = data.projects.find(x => x.id === projectId); const d = p?.designs?.find(x => x.id === designId); if (!d) return; const order = ['pending', 'approved', 'revision', 'rejected']; d.status = order[(order.indexOf(d.status) + 1) % order.length]; saveData(); renderAll(); } // ----- Quality Management (per project) ----- function addQualityCheck(projectId) { const p = data.projects.find(x => x.id === projectId); if (!p) return; const area = document.getElementById('qcArea')?.value.trim(); if (!area) return; const date = document.getElementById('qcDate')?.value || dayKey(new Date()); const inspector = document.getElementById('qcInspector')?.value.trim() || ''; const result = document.getElementById('qcResult')?.value || 'pending'; const remarks = document.getElementById('qcRemarks')?.value.trim() || ''; p.qualityChecks = Array.isArray(p.qualityChecks) ? p.qualityChecks : []; p.qualityChecks.push({ id: Date.now(), area, date, inspector, result, remarks }); ['qcArea','qcInspector','qcRemarks'].forEach(id => { const e = document.getElementById(id); if (e) e.value = ''; }); saveData(); renderAll(); } function delQualityCheck(projectId, checkId) { const p = data.projects.find(x => x.id === projectId); if (p) { p.qualityChecks = (p.qualityChecks||[]).filter(q => q.id !== checkId); saveData(); renderAll(); } } // ----- Production Management (per project) ----- function addProductionEntry(projectId) { const p = data.projects.find(x => x.id === projectId); if (!p) return; const activity = document.getElementById('prodActivity')?.value.trim(); if (!activity) return; const date = document.getElementById('prodDate')?.value || dayKey(new Date()); const qty = Number(document.getElementById('prodQty')?.value) || 0; const unit = document.getElementById('prodUnit')?.value.trim() || ''; p.production = Array.isArray(p.production) ? p.production : []; p.production.push({ id: Date.now(), date, activity, qty, unit }); ['prodActivity','prodQty','prodUnit'].forEach(id => { const e = document.getElementById(id); if (e) e.value = ''; }); saveData(); renderAll(); } function delProductionEntry(projectId, entryId) { const p = data.projects.find(x => x.id === projectId); if (p) { p.production = (p.production||[]).filter(pr => pr.id !== entryId); saveData(); renderAll(); } } // ----- Global Material section (across all projects) ----- let selectedGlobalMaterials = new Set(); function allMaterialsFlat() { const list = []; (data.projects || []).forEach(p => (p.materials || []).forEach(m => list.push({ ...m, projectId: p.id }))); return list; } function toggleMaterialSelection(key, checked) { if (checked) selectedGlobalMaterials.add(key); else selectedGlobalMaterials.delete(key); } function toggleSelectAllMaterials(checked) { const statusFilter = document.getElementById('matFilterStatus')?.value || 'all'; const projFilter = document.getElementById('matFilterProject')?.value || 'all'; let list = allMaterialsFlat(); if (projFilter !== 'all') list = list.filter(m => String(m.projectId) === projFilter); if (statusFilter !== 'all') list = list.filter(m => m.status === statusFilter); list.forEach(m => { const key = `${m.projectId}:${m.id}`; if (checked) selectedGlobalMaterials.add(key); else selectedGlobalMaterials.delete(key); }); renderMaterialSection(); } function toggleSelectGroupMaterials(projectId, checked) { const statusFilter = document.getElementById('matFilterStatus')?.value || 'all'; let list = allMaterialsFlat().filter(m => m.projectId === projectId); if (statusFilter !== 'all') list = list.filter(m => m.status === statusFilter); list.forEach(m => { const key = `${m.projectId}:${m.id}`; if (checked) selectedGlobalMaterials.add(key); else selectedGlobalMaterials.delete(key); }); renderMaterialSection(); } function createPOFromSelectedGlobalMaterials() { const all = allMaterialsFlat(); const selected = all.filter(m => selectedGlobalMaterials.has(`${m.projectId}:${m.id}`)); if (!selected.length) { alert('Tick at least one material to create a PO.'); return; } const items = selected.map(m => { const proj = data.projects.find(p => p.id === m.projectId); return { desc: (proj ? `[${projNick(proj)}] ` : '') + m.name, qty: m.qty || 1, unit: m.unit, rate: m.estRate }; }); const grouped = {}; selected.forEach(m => { grouped[m.projectId] = grouped[m.projectId] || []; grouped[m.projectId].push(m.id); }); pendingPOMaterialIds = Object.keys(grouped).map(pid => ({ projectId: Number(pid), materialIds: grouped[pid] })); const uniqueProjects = Object.keys(grouped); openDocBuilder('po', items, uniqueProjects.length === 1 ? Number(uniqueProjects[0]) : null); const vendors = new Set(selected.map(m => m.vendorName).filter(Boolean)); if (vendors.size === 1) { const partyEl = document.getElementById('docPartyName'); if (partyEl) partyEl.value = [...vendors][0]; } selectedGlobalMaterials.clear(); } // ═══════════════════ MATERIAL PRICE LIST ═══════════════════ // Auto-fetches rates from vendor bills + purchase orders; supports manual entries. function buildMaterialPriceIndex() { // Returns { key -> { name, unit, history:[{rate,date,party,source}], latest, min, max, avg } } const idx = {}; const ingest = (docs, source) => { (docs||[]).forEach(d => { (d.items||[]).forEach(it => { const name = (it.desc||'').trim(); const rate = Number(it.rate||0); if (!name || !rate) return; const key = name.toLowerCase(); if (!idx[key]) idx[key] = { name, unit: it.unit||'', history: [] }; idx[key].history.push({ rate, date: d.date||'', party: d.party||'', unit: it.unit||'', source }); if (it.unit && !idx[key].unit) idx[key].unit = it.unit; }); }); }; ingest(data.vendorBills, 'Vendor Bill'); ingest(data.purchaseOrders, 'Purchase Order'); Object.values(idx).forEach(m => { m.history.sort((a,b)=>(b.date||'').localeCompare(a.date||'')); const rates = m.history.map(h=>h.rate); m.latest = m.history[0]?.rate || 0; m.latestDate = m.history[0]?.date || ''; m.min = Math.min(...rates); m.max = Math.max(...rates); m.avg = Math.round(rates.reduce((s,r)=>s+r,0)/rates.length); m.count = m.history.length; }); return idx; } function priceListAdd() { const name = document.getElementById('plName')?.value.trim(); const cat = document.getElementById('plCat')?.value.trim() || ''; const subCat = document.getElementById('plSubCat')?.value.trim() || ''; const unit = document.getElementById('plUnit')?.value.trim() || ''; const rate = Math.round(Number(document.getElementById('plRate')?.value||0)); const party = document.getElementById('plParty')?.value.trim() || ''; const note = document.getElementById('plNote')?.value.trim() || ''; if (!name || !rate) { alert('Enter material name and rate.'); return; } data.materialPrices = Array.isArray(data.materialPrices) ? data.materialPrices : []; data.materialPrices.push({ id:Date.now(), name, cat, subCat, unit, rate, party, note, date: dayKey(new Date()) }); ['plName','plCat','plSubCat','plUnit','plRate','plParty','plNote'].forEach(id=>{const e=document.getElementById(id);if(e)e.value='';}); saveData(); renderPriceList(); } function priceListDel(id){ data.materialPrices=(data.materialPrices||[]).filter(x=>x.id!==id); saveData(); renderPriceList(); } function renderPriceList() { const el = document.getElementById('priceListContent'); if (!el) return; data.materialPrices = Array.isArray(data.materialPrices) ? data.materialPrices : []; const idx = buildMaterialPriceIndex(); const fetched = Object.values(idx).sort((a,b)=>a.name.localeCompare(b.name)); const manual = data.materialPrices.slice().sort((a,b)=>a.name.localeCompare(b.name)); const search = (window._plSearch||'').toLowerCase(); const fFetched = search ? fetched.filter(m=>m.name.toLowerCase().includes(search)) : fetched; const fManual = search ? manual.filter(m=>m.name.toLowerCase().includes(search)) : manual; el.innerHTML = `
🏷 Material Price List
Rates auto-fetched from your Purchase Orders & Vendor Bills, plus your own manual entries. Latest rate, range, and price history per material.
Materials Tracked
${fetched.length + manual.length}
${fetched.length} from bills · ${manual.length} manual
From Purchase Bills
${fetched.length}
Auto-fetched rates
Manual Entries
${manual.length}
Your own rates
+ Add Material Rate (manual)
${['Cement','Steel & TMT','Aggregates & Sand','Bricks & Blocks','Pipes & Fittings','Valves','DI / CI Fittings','Electrical','Plumbing & Sanitary','Hardware & Fasteners','Paints & Finishes','Waterproofing','Machinery & Hire','Fuel & Lubricants','Labour & Services','Miscellaneous'].map(c=>` ${[...new Set((data.materialPrices||[]).map(m=>m.subCat).filter(Boolean))].map(s=>`
📦 Auto-fetched from Purchase Bills
${fFetched.length} material${fFetched.length===1?'':'s'}
${fFetched.length ? fFetched.map(m=>{ const hist = m.history.slice(0,4).map(h=>`${h.date||'—'}: ₹${h.rate.toLocaleString('en-IN')}${h.party?(' ('+escHtml(h.party)+')'):''}`).join('
'); return ``; }).join('') : ''}
MaterialUnitLatest RateLast VendorMinMaxAvg#BillsHistory
${escHtml(m.name)} ${escHtml(m.unit||'—')} ${fmtINR(m.latest)} ${escHtml(m.history[0]?.party||'—')} ${fmtINR(m.min)} ${fmtINR(m.max)} ${fmtINR(m.avg)} ${m.count} ${hist}${m.history.length>4?'
+'+(m.history.length-4)+' more':''}
No materials found in purchase orders or vendor bills yet. Create bills with line items, or add manually below.
✍️ Manual Price Entries
${fManual.length} entr${fManual.length===1?'y':'ies'}
${fManual.length ? fManual.map(m=>``).join('') : ''}
MaterialCategorySub-categoryUnitRateVendorDateNote
${escHtml(m.name)} ${m.cat?`${escHtml(m.cat)}`:''} ${escHtml(m.subCat||'—')} ${escHtml(m.unit||'—')} ${fmtINR(m.rate)} ${escHtml(m.party||'—')} ${m.date||'—'} ${escHtml(m.note||'')}
No manual price entries yet.
`; } function renderMaterialSection() { const projSel = document.getElementById('matFilterProject'); if (projSel) { const prev = projSel.value; projSel.innerHTML = '' + (data.projects || []).map(p => ``).join(''); if ([...projSel.options].some(o => o.value === prev)) projSel.value = prev; } const statusFilter = document.getElementById('matFilterStatus')?.value || 'all'; const projFilter = document.getElementById('matFilterProject')?.value || 'all'; const allList = allMaterialsFlat(); let list = allList; if (projFilter !== 'all') list = list.filter(m => String(m.projectId) === projFilter); if (statusFilter !== 'all') list = list.filter(m => m.status === statusFilter); const set = (id, v) => { const e = document.getElementById(id); if (e) e.textContent = v; }; set('matKpiTotal', allList.length); set('matKpiToOrder', allList.filter(m => m.status === 'to-order').length); set('matKpiOrdered', allList.filter(m => m.status === 'ordered').length); set('matKpiValue', fmtINR(allList.reduce((s, m) => s + (m.qty||0) * (m.estRate||0), 0))); const tbody = document.getElementById('materialTableBody'); if (!tbody) return; // ── GROUP BY PROJECT: cluster rows under a project header row with subtotal ── if (list.length) { const groups = []; const idxByProj = {}; list.forEach(m => { const pid = m.projectId; if (!(pid in idxByProj)) { idxByProj[pid] = groups.length; groups.push({ pid, items: [] }); } groups[idxByProj[pid]].items.push(m); }); tbody.innerHTML = groups.map(g => { const proj = data.projects.find(p => p.id === g.pid); const projName = proj ? projNick(proj) : 'Unassigned'; const grpTotal = g.items.reduce((s, m) => s + (m.qty||0) * (m.estRate||0), 0); const grpKeys = g.items.map(m => `${m.projectId}:${m.id}`); const allSel = grpKeys.every(k => selectedGlobalMaterials.has(k)); const header = ` ${escHtml(projName)} · ${g.items.length} item${g.items.length>1?'s':''} ${fmtINR(grpTotal)} `; const rows = g.items.map(m => { const key = `${m.projectId}:${m.id}`; const checked = selectedGlobalMaterials.has(key); return ` ${escHtml(m.name)} ${escHtml(projName)} ${m.qty||0}${m.unit?' '+escHtml(m.unit):''} ${escHtml(m.vendorName||'—')} ${fmtINR(m.estRate||0)} ${fmtINR((m.qty||0)*(m.estRate||0))} ${m.dateNeeded||'—'} ${m.status==='received'?'Received':m.status==='ordered'?'Ordered':'To Order'} `; }).join(''); return header + rows; }).join(''); } else { tbody.innerHTML = 'No materials match this filter'; } renderMaterialNeeds(); } // ── MATERIAL NEEDS: BOQ-selected / to-order materials grouped by project ── function renderMaterialNeeds() { const el = document.getElementById('materialNeedsPanel'); if (!el) return; const toOrder = allMaterialsFlat().filter(m => m.status === 'to-order'); // Group by project const byProj = {}; toOrder.forEach(m => { const pid = m.projectId; (byProj[pid] = byProj[pid] || []).push(m); }); const projIds = Object.keys(byProj); const grandTotal = toOrder.reduce((s,m)=>s+(m.qty||1)*(m.estRate||0),0); if (!toOrder.length) { el.innerHTML = `
📋 Material Needs
No materials marked "To Order" yet. Tick BOQ items in a project, or add materials above, to see them grouped here.
`; return; } el.innerHTML = `
📋 Material Needs
BOQ-selected & to-order items grouped by project · recheck, then create PO per project
Total need across ${projIds.length} project${projIds.length===1?'':'s'}
${fmtINR(grandTotal)}
${projIds.map(pid => { const p = data.projects.find(x => String(x.id) === String(pid)); const items = byProj[pid]; const projTotal = items.reduce((s,m)=>s+(m.qty||1)*(m.estRate||0),0); const projName = p ? projNick(p) : 'Unassigned'; return `
${escHtml(projName)}
${p?escHtml(p.client||'—'):'—'}${p&&p.company?(' · '+escHtml(p.company)):''} ${p&&p.estimate?(' · Est ₹'+Number(p.estimate).toLocaleString('en-IN')):''} · ${items.length} item${items.length===1?'':'s'}
Need
${fmtINR(projTotal)}
${items.map(m=>``).join('')}
MaterialQtyUnitVendorEst. RateEst. TotalNeeded By
${escHtml(m.name)}${m.fromTaskId?' BOQ':''} ${m.qty||1} ${escHtml(m.unit||'—')} ${escHtml(m.vendorName||'—')} ${fmtINR(m.estRate||0)} ${fmtINR((m.qty||1)*(m.estRate||0))} ${m.dateNeeded||'—'}
PROJECT TOTAL${fmtINR(projTotal)}
`; }).join('')}
`; } // ===== REPORTS ===== function computeNetCashFromBanks() { const accts = data.accounts || []; let total = 0; BANK_LIST.forEach(bank => { const entries = accts.filter(e => e.bank === bank && e.status !== 'pending'); const inflow = entries.filter(e => ACCOUNT_TYPES[e.type]?.direction === 'in').reduce((s,e)=>s+Number(e.amount||0),0); const outflow = entries.filter(e => ACCOUNT_TYPES[e.type]?.direction === 'out').reduce((s,e)=>s+Number(e.amount||0),0); total += inflow - outflow; }); return total; } function computeHealthScore(budgetUtilPct, overdueCount, overBudgetCount, netCash) { let score = 100; if (budgetUtilPct > 100) score -= 20; else if (budgetUtilPct > 90) score -= 10; score -= Math.min(overdueCount * 5, 30); score -= Math.min(overBudgetCount * 8, 24); if (netCash < 0) score -= 20; return Math.max(0, Math.min(100, Math.round(score))); } // ===== UNIVERSAL REPORT BUILDER (all datasets) ===== function rbProjNick(id) { const p = (data.projects||[]).find(x=>x.id===id); return p ? projNick(p) : ''; } function rbDate(r, keys){ for (const k of keys){ if (r[k]) return String(r[k]).slice(0,10); } return ''; } function rbDatasets() { const inr = n => Math.round(Number(n)||0); return { projects: { label: '⌂ Projects', filters:['project','status','search'], get: ()=> (data.projects||[]), dateOf: r=> rbDate(r,['date','startDate','createdAt']), projOf: r=> r.id, statusOf: r=> r.status||'', partyOf: r=> r.client||'', cols: [ {label:'Project', val:r=>projNick(r)}, {label:'Client', val:r=>r.client||'—'}, {label:'Category', val:r=>[r.category,r.subCategory].filter(Boolean).join(' / ')||'—'}, {label:'Company', val:r=>r.company||'—'}, {label:'Status', val:r=>r.status||'—'}, {label:'Value ₹', num:true, raw:r=>inr(r.estimate||r.value||0)} ] }, leads: { label: '🎯 Leads', filters:['date','status','search'], get: ()=> (data.leads||[]), dateOf: r=> rbDate(r,['date','createdAt']), statusOf: r=> r.status||'', partyOf: r=> r.client||r.name||'', cols: [ {label:'Date', val:r=>rbDate(r,['date','createdAt'])||'—'}, {label:'Lead', val:r=>r.name||'—'}, {label:'Client', val:r=>r.client||'—'}, {label:'Type', val:r=>r.type||'—'}, {label:'Location', val:r=>r.location||'—'}, {label:'Status', val:r=>r.status||'—'}, {label:'Estimate ₹', num:true, raw:r=>inr(r.estimate||0)} ] }, attendance: { label: '👷 Labour Attendance', filters:['date','project','status','search'], get: ()=> (data.attendance||[]), dateOf: r=> rbDate(r,['date']), projOf: r=> r.projectId ?? null, statusOf: r=> r.status||'', partyOf: r=> r.labourName||'', cols: [ {label:'Date', val:r=>r.date||'—'}, {label:'Worker', val:r=>r.labourName||'—'}, {label:'Project', val:r=>{ if (r.projectId) return rbProjNick(r.projectId); const l=(data.labours||[]).find(x=>x.id===r.labourId||x.name===r.labourName); return l?labProjectIds(l).map(rbProjNick).filter(Boolean).join(', '):''; }}, {label:'Status', val:r=>({full:'Full Day',half:'Half Day',overtime:'Overtime',night:'Night Shift',absent:'Absent'}[r.status]||r.status)}, {label:'Wage ₹', num:true, raw:r=>inr(r.wage)} ] }, payable: { label: '💸 Labour Payable (summary)', filters:['date','project','search'], computed:true, get: ()=> rbPayableRows(), partyOf: r=> r.worker||'', cols: [ {label:'Worker', val:r=>r.worker}, {label:'Role', val:r=>r.role||'—'}, {label:'Category', val:r=>r.category||'—'}, {label:'Wages ₹', num:true, raw:r=>inr(r.wages)}, {label:'Bonus ₹', num:true, raw:r=>inr(r.bonus)}, {label:'Total ₹', num:true, raw:r=>inr(r.total)}, {label:'Paid ₹', num:true, raw:r=>inr(r.paid)}, {label:'Balance ₹', num:true, raw:r=>inr(r.balance)} ] }, labours: { label: '👤 Labour Roster', filters:['project','search'], get: ()=> (data.labours||[]), projOf: r=> labProjectIds(r), statusOf: ()=> '', cols: [ {label:'Name', val:r=>r.name||'—'}, {label:'Role', val:r=>r.role||'—'}, {label:'Category', val:r=>r.category||'—'}, {label:'Projects', val:r=>labProjectIds(r).map(rbProjNick).filter(Boolean).join(', ')||'—'}, {label:'Company', val:r=>r.company||'—'}, {label:'Daily Rate ₹', num:true, raw:r=>inr(r.dailyRate)} ] }, vendors: { label: '🏭 Vendors', filters:['search'], get: ()=> (data.vendors||[]), cols: [ {label:'Name', val:r=>r.name||'—'}, {label:'Category', val:r=>r.category||'—'}, {label:'Phone', val:r=>r.phone||'—'}, {label:'GSTIN', val:r=>r.gstin||'—'}, {label:'Company', val:r=>r.company||'—'} ] }, subcontractors: { label: '🔧 Subcontractors', filters:['search'], get: ()=> (data.subcontractors||[]), cols: [ {label:'Name', val:r=>r.name||'—'}, {label:'Trade', val:r=>r.trade||'—'}, {label:'Rate Basis', val:r=>r.rateBasis||'—'}, {label:'Phone', val:r=>r.phone||'—'}, {label:'Company', val:r=>r.company||'—'} ] }, invoices: rbDocCfg('invoices', '🧾 Invoices'), quotations: rbDocCfg('quotations', '📝 Quotations'), purchaseOrders:rbDocCfg('purchaseOrders', '📦 Purchase Orders'), vendorBills: rbDocCfg('vendorBills', '🏭 Vendor Bills'), accounts: { label: '▤ Accounts Ledger', filters:['date','project','status','search'], get: ()=> (data.accounts||[]), dateOf: r=> rbDate(r,['date']), projOf: r=> r.projectId ?? null, statusOf: r=> r.status||'', partyOf: r=> r.party||'', cols: [ {label:'Date', val:r=>r.date||'—'}, {label:'Type', val:r=>(r.type||'—').replace(/-/g,' ')}, {label:'Party', val:r=>r.party||'—'}, {label:'Project', val:r=>r.projectId?rbProjNick(r.projectId):'—'}, {label:'Status', val:r=>r.status||'—'}, {label:'Amount ₹', num:true, raw:r=>inr(r.amount)} ] }, loans: { label: '💵 Loans / EMIs', filters:['status','search'], get: ()=> (data.loans||[]), statusOf: r=> r.status||'', cols: [ {label:'Loan', val:r=>r.label||r.name||'—'}, {label:'Lender', val:r=>r.lender||'—'}, {label:'Category', val:r=>r.category||'—'}, {label:'Due Day', val:r=>r.dueDay!=null?String(r.dueDay):'—'}, {label:'Status', val:r=>r.status||'—'}, {label:'EMI ₹', num:true, raw:r=>inr(r.amount||r.emi)} ] }, assets: { label: '🏗 Assets & Equipment', filters:['status','search'], get: ()=> (data.assets||[]), statusOf: r=> r.status||'', cols: [ {label:'Asset', val:r=>r.name||r.label||'—'}, {label:'Category', val:r=>r.category||'—'}, {label:'Status', val:r=>r.status||'—'}, {label:'Note', val:r=>r.note||'—'}, {label:'Value ₹', num:true, raw:r=>inr(r.value||r.amount||r.cost)} ] }, tasks: { label: '✓ Daily Tasks', filters:['date','status','search'], get: ()=> (data.tasks||[]), dateOf: r=> rbDate(r,['date']), statusOf: r=> r.completed?'Done':'Pending', cols: [ {label:'Date', val:r=>r.date||'—'}, {label:'Task', val:r=>r.text||'—'}, {label:'Category', val:r=>r.category||'—'}, {label:'Priority', val:r=>[r.urgent?'Urgent':'',r.important?'Important':''].filter(Boolean).join(', ')||'Normal'}, {label:'Status', val:r=>r.completed?'Done':'Pending'} ] }, funds: { label: '💵 Money Management', filters:['date','project','status','search'], get: ()=> (data.funds||[]), dateOf: r=> rbDate(r,['date']), projOf: r=> r.projectId ?? null, statusOf: r=> r.status||'', partyOf: r=> r.vendor||'', cols: [ {label:'Date', val:r=>r.date||'—'}, {label:'Description', val:r=>r.desc||'—'}, {label:'Category', val:r=>r.cat||'—'}, {label:'Vendor', val:r=>r.vendor||'—'}, {label:'Project', val:r=>r.projectId?rbProjNick(r.projectId):(r.projectLabel||'—')}, {label:'Priority', val:r=>r.priority||'—'}, {label:'Status', val:r=>r.status||'—'}, {label:'Amount ₹', num:true, raw:r=>inr(r.amount)} ] }, capex: { label: '🏗 Capital Expenditure', filters:['date','search'], get: ()=> (data.capexEntries||[]), dateOf: r=> rbDate(r,['date']), cols: [ {label:'Date', val:r=>r.date||'—'}, {label:'Asset / Item', val:r=>r.name||'—'}, {label:'Category', val:r=>r.cat||'—'}, {label:'Life (yrs)', val:r=>r.life!=null?String(r.life):'—'}, {label:'Note', val:r=>r.note||'—'}, {label:'Amount ₹', num:true, raw:r=>inr(r.amount)} ] }, opex: { label: '⚙️ Operating Expenditure', filters:['date','search'], get: ()=> (data.opexEntries||[]), dateOf: r=> rbDate(r,['date']), cols: [ {label:'Date', val:r=>r.date||'—'}, {label:'Expense', val:r=>r.name||'—'}, {label:'Category', val:r=>r.cat||'—'}, {label:'Type', val:r=>(r.kind||'fixed')==='variable'?'Variable':'Fixed'}, {label:'Frequency', val:r=>r.freq||'—'}, {label:'Note', val:r=>r.note||'—'}, {label:'Budget ₹', num:true, raw:r=>inr(OPEX_MONTHLY(r))}, {label:'Actual ₹', num:true, raw:r=>opexActualFor(r, opexMonth())} ] }, adminstaff: { label: '👔 Admin Staff & Salary', filters:['search'], get: ()=> (data.adminStaff||[]), cols: [ {label:'Name', val:r=>r.name||'—'}, {label:'Role', val:r=>r.role||'—'}, {label:'Working Days', val:r=>String(staffWD(r))}, {label:`Absent (${opexMonth()})`, val:r=>String(staffAbsent(r, opexMonth()))}, {label:'Monthly Salary ₹', num:true, raw:r=>inr(r.salary)}, {label:'Payable ₹', num:true, raw:r=>staffPayable(r, opexMonth())} ] } }; } function rbDocCfg(store, label) { const inr = n => Math.round(Number(n)||0); return { label, filters:['date','project','status','search'], get: ()=> (data[store]||[]), dateOf: r=> rbDate(r,['date']), projOf: r=> r.projectId ?? null, statusOf: r=> r.docStatus||r.status||'', partyOf: r=> r.party||'', cols: [ {label:'Number', val:r=>r.number||'—'}, {label:'Date', val:r=>r.date||'—'}, {label:'Party', val:r=>r.party||'—'}, {label:'Project', val:r=>r.projectId?rbProjNick(r.projectId):(r.projectLabel||'—')}, {label:'Status', val:r=>r.docStatus||r.status||'—'}, {label:'Total ₹', num:true, raw:r=>inr(r.total)} ] }; } // Payable summary across ALL workers, honoring builder date range function rbPayableRows() { const from = document.getElementById('rbFrom')?.value || ''; const to = document.getElementById('rbTo')?.value || ''; const inRng = d => (!from || d >= from) && (!to || d <= to); const wmap = {}; (data.attendance||[]).forEach(a => { if (!inRng(a.date)) return; if (!wmap[a.labourName]) { const l=(data.labours||[]).find(x=>x.name===a.labourName)||{}; wmap[a.labourName]={worker:a.labourName, role:l.role||'', category:l.category||'', wages:0, bonus:0, paid:0}; } wmap[a.labourName].wages += Math.round(a.wage||0); }); Object.values(wmap).forEach(r => { r.bonus = computeLabourBonus(r.worker, inRng).total; r.paid = labourPaidTotal(r.worker, inRng); r.total = r.wages + r.bonus; r.balance = r.total - r.paid; }); return Object.values(wmap).sort((a,b)=>a.worker.localeCompare(b.worker)); } // Grouped layout of datasets by main menu const RB_GROUPS = [ ['Productivity', ['tasks']], ['Operations', ['projects','leads']], ['Labour & HR', ['attendance','payable','labours','adminstaff','vendors','subcontractors']], ['Sales & Purchases', ['invoices','quotations','purchaseOrders','vendorBills']], ['Finance', ['accounts','funds','loans','capex','opex','assets']] ]; let rbActiveDataset = 'attendance'; function rbCurrent() { return rbDatasets()[rbActiveDataset] || null; } function renderReportBuilder() { const side = document.getElementById('rbSidebar'); if (!side) return; const cfgs = rbDatasets(); side.innerHTML = RB_GROUPS.map(([label, keys]) => { const items = keys.filter(k=>cfgs[k]).map(k => `` ).join(''); return `
${label}
${items}`; }).join(''); // Project dropdown const projSel = document.getElementById('rbProject'); if (projSel) { const prev = projSel.value; projSel.innerHTML = '' + (data.projects||[]).map(p=>``).join(''); if ((data.projects||[]).some(p=>String(p.id)===prev)) projSel.value = prev; } rbDatasetChange(true); } function rbSelectDataset(key) { rbActiveDataset = key; document.querySelectorAll('.rb-ds-item').forEach(b => b.classList.toggle('active', b.dataset.rbds === key)); rbDatasetChange(false); } function rbDatasetChange(keepFilters) { const cfg = rbCurrent(); if (!cfg) return; const titleEl = document.getElementById('rbTitle'); if (titleEl) titleEl.textContent = cfg.label; const show = (id, on) => { const e=document.getElementById(id); if (e) e.style.display = on?'':'none'; }; const f = cfg.filters || []; show('rbDateWrap', f.includes('date')); show('rbDateToWrap', f.includes('date')); show('rbProjWrap', f.includes('project')); show('rbStatusWrap', f.includes('status')); show('rbSearchWrap', f.includes('search')); if (f.includes('status') && cfg.statusOf) { const rows = cfg.computed ? [] : cfg.get(); const sts = [...new Set(rows.map(cfg.statusOf).filter(Boolean))].sort(); const sSel = document.getElementById('rbStatus'); if (sSel) { const prev = sSel.value; sSel.innerHTML = '' + sts.map(s=>``).join(''); if (sts.includes(prev)) sSel.value = prev; else sSel.value=''; } } if (!keepFilters) { const s=document.getElementById('rbSearch'); if (s) s.value=''; const st=document.getElementById('rbStatus'); if (st) st.value=''; const pj=document.getElementById('rbProject'); if (pj) pj.value=''; } rbApply(); } function rbGatherRows() { const cfg = rbCurrent(); if (!cfg) return { cfg:null, rows:[] }; let rows = cfg.get() || []; const from = document.getElementById('rbFrom')?.value || ''; const to = document.getElementById('rbTo')?.value || ''; const proj = document.getElementById('rbProject')?.value || ''; const status = document.getElementById('rbStatus')?.value || ''; const q = (document.getElementById('rbSearch')?.value || '').toLowerCase().trim(); const f = cfg.filters || []; if (f.includes('date') && cfg.dateOf && !cfg.computed) rows = rows.filter(r => { const d=cfg.dateOf(r); return (!from || (d&&d>=from)) && (!to || (d&&d<=to)); }); if (f.includes('project') && proj && cfg.projOf) rows = rows.filter(r => { const pv=cfg.projOf(r); return Array.isArray(pv) ? pv.map(String).includes(proj) : String(pv??'')===proj; }); if (f.includes('status') && status && cfg.statusOf) rows = rows.filter(r => cfg.statusOf(r) === status); if (f.includes('search') && q) rows = rows.filter(r => cfg.cols.some(c => String(c.num?c.raw(r):c.val(r)).toLowerCase().includes(q))); return { cfg, rows }; } function rbApply() { const prev = document.getElementById('rbPreview'); if (!prev) return; const { cfg, rows } = rbGatherRows(); if (!cfg) { prev.innerHTML=''; return; } const totals = cfg.cols.map(c => c.num ? rows.reduce((s,r)=>s+(Number(c.raw(r))||0),0) : null); const hasTotals = totals.some(t=>t!=null); const head = `${cfg.cols.map(c=>`${c.label}`).join('')}`; const body = rows.length ? rows.map(r=>`${cfg.cols.map(c=>`${c.num?fmtINR(c.raw(r)):escHtml(String(c.val(r)??''))}`).join('')}`).join('') : `No records match these filters.`; const foot = hasTotals && rows.length ? `${cfg.cols.map((c,i)=>`${i===0?'TOTAL':(totals[i]!=null?fmtINR(totals[i]):'')}`).join('')}` : ''; prev.innerHTML = `${head}${body}${foot?`${foot}`:''}
`; const meta = document.getElementById('rbMeta'); if (meta) meta.textContent = `${rows.length} record${rows.length===1?'':'s'}`; } function rbExport(fmt) { const { cfg, rows } = rbGatherRows(); if (!cfg) return; if (!rows.length) { alert('No records to export for these filters.'); return; } const name = cfg.label.replace(/[^\w]+/g,'_').replace(/^_|_$/g,''); const stamp = dayKey(new Date()); const totals = cfg.cols.map(c => c.num ? rows.reduce((s,r)=>s+(Number(c.raw(r))||0),0) : null); const hasTotals = totals.some(t=>t!=null); if (fmt === 'excel') { if (typeof XLSX === 'undefined') { alert('Excel engine not loaded.'); return; } const aoa = [ cfg.cols.map(c=>c.label), ...rows.map(r=>cfg.cols.map(c=> c.num ? Math.round(Number(c.raw(r))||0) : String(c.val(r)??''))) ]; if (hasTotals) aoa.push(cfg.cols.map((c,i)=> i===0?'TOTAL':(c.num?Math.round(totals[i]||0):''))); const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(aoa), name.slice(0,28)); XLSX.writeFile(wb, `${name}_${stamp}.xlsx`); if (typeof showToast==='function') showToast('⬇ Excel downloaded'); } else { const esc = s => String(s??'').replace(/[&<>]/g,c=>({'&':'&','<':'<','>':'>'}[c])); let html = `${esc(cfg.label)} ${stamp}

${esc(cfg.label)}

${rows.length} records · generated ${stamp}
${cfg.cols.map(c=>``).join('')}` + rows.map(r=>`${cfg.cols.map(c=>``).join('')}`).join('') + `${hasTotals?`${cfg.cols.map((c,i)=>``).join('')}`:''}
${esc(c.label)}
${c.num?'₹'+Math.round(Number(c.raw(r))||0).toLocaleString('en-IN'):esc(c.val(r))}
${i===0?'TOTAL':(c.num?'₹'+Math.round(totals[i]||0).toLocaleString('en-IN'):'')}
`; const w = window.open('', '_blank'); if (!w) { alert('Allow pop-ups to generate the PDF.'); return; } w.document.write(html); w.document.close(); setTimeout(()=>{ try{ w.focus(); w.print(); }catch(e){} }, 400); } } function renderReports() { renderReportBuilder(); const kpiEl = document.getElementById('reportKpiList'); const costEl = document.getElementById('reportTopCosts'); const recEl = document.getElementById('reportRecommendations'); if (!kpiEl || !costEl || !recEl) return; const projects = data.projects || []; const totalBudget = projects.reduce((s,p) => s + (Number(p.estimate)||0), 0); const totalSpent = projects.reduce((s,p) => s + computeProjectSpent(p), 0); const budgetUtilPct = totalBudget ? Math.round(totalSpent/totalBudget*100) : 0; const grossMarginPct = totalBudget ? Math.round((totalBudget-totalSpent)/totalBudget*100) : 0; const receivable = computeReceivables(); const payable = computePayables(); const netCash = computeNetCashFromBanks(); const overdueEntries = (data.accounts||[]).filter(e => isOverdue(e)); const overBudgetProjects = projects.filter(p => computeProjectForecast(p).overBudget); const healthScore = computeHealthScore(budgetUtilPct, overdueEntries.length, overBudgetProjects.length, netCash); const statusFor = (val, watchAt, alertAt, higherIsBetter=true) => { if (higherIsBetter) return val >= alertAt ? 'OK' : val >= watchAt ? 'Watch' : 'Alert'; return val <= alertAt ? 'OK' : val <= watchAt ? 'Watch' : 'Alert'; }; const pillFor = s => s==='OK' ? 'pill-green' : s==='Watch' ? 'pill-amber' : 'pill-rose'; const kpis = [ { label:'Budget Utilization', value: budgetUtilPct+'%', status: budgetUtilPct>100?'Alert':budgetUtilPct>85?'Watch':'OK' }, { label:'Gross Margin', value: grossMarginPct+'%', status: grossMarginPct<0?'Alert':grossMarginPct<15?'Watch':'OK' }, { label:'Receivable', value: fmtINR(receivable), status: receivable>0?'Watch':'OK' }, { label:'Payable', value: fmtINR(payable), status: overdueEntries.length>0?'Alert':payable>0?'Watch':'OK' }, { label:'Net Cash', value: fmtINR(netCash), status: netCash<0?'Alert':'OK' }, { label:'Health Score', value: healthScore+'/100', status: healthScore>=70?'OK':healthScore>=40?'Watch':'Alert' } ]; kpiEl.innerHTML = kpis.map(k => `
${k.label}
${k.value} ${k.status}
`).join(''); // Top cost categories — by account type, completed entries only const catTotals = {}; (data.accounts||[]).filter(e => e.status !== 'pending').forEach(e => { const label = ACCOUNT_TYPES[e.type]?.label || e.type; catTotals[label] = (catTotals[label]||0) + Number(e.amount||0); }); const ranked = Object.entries(catTotals).sort((a,b)=>b[1]-a[1]).slice(0,5); const maxVal = ranked.length ? ranked[0][1] : 1; costEl.innerHTML = ranked.length ? ranked.map(([label, val], i) => `
${i+1}. ${escHtml(label)}${fmtINR(val)}
`).join('') : '
No completed transactions yet
'; // Recommendations — simple rule-based insights const recs = []; if (overdueEntries.length) { const byParty = {}; overdueEntries.forEach(e => { byParty[e.party||'Unknown'] = (byParty[e.party||'Unknown']||0) + Number(e.amount||0); }); const top = Object.entries(byParty).sort((a,b)=>b[1]-a[1])[0]; recs.push({ icon:'⚠', color:'var(--rose)', text:`Overdue payables ${fmtINR(payable)} — ${escHtml(top[0])} at ${fmtINR(top[1])} is the highest risk. Settle immediately.` }); } if (receivable > 0) { recs.push({ icon:'💰', color:'var(--amber)', text:`Improve receivable collection — ${fmtINR(receivable)} pending would strengthen working capital.` }); } if (overBudgetProjects.length) { recs.push({ icon:'📈', color:'var(--rose)', text:`${overBudgetProjects.length} project${overBudgetProjects.length===1?'':'s'} over budget — review ${overBudgetProjects.map(p=>projNick(p)).join(', ')}.` }); } if (netCash < 0) { recs.push({ icon:'🏦', color:'var(--rose)', text:`Net cash position is negative (${fmtINR(netCash)}) — tag more transactions to a bank to track this accurately, and prioritize collections.` }); } if (!recs.length) { recs.push({ icon:'✓', color:'var(--accent)', text:'No urgent issues detected — finances and projects look healthy.' }); } recEl.innerHTML = recs.map(r => `
${r.icon} ${r.text}
`).join(''); renderReportLibrary(); } // ===== REPORT LIBRARY (full catalog, organized by category) ===== const REPORT_CATEGORIES = [ { name: 'Budget', icon: '📦', reports: [ ['boq-bom', 'BOQ BOM Report'], ['budget-vs-actual-material-cost', 'Budget vs Actual (Material Cost)'], ['budget-vs-actual-material-qty', 'Budget vs Actual (Material Qty)'], ['budget-vs-actual-cost-code', 'Budget vs Actual (Cost Code)'], ]}, { name: 'Misc.', icon: '📦', reports: [ ['project-financial-summary', 'Project Financial Summary'], ['project-operational-summary', 'Project Operational Summary'], ['company-transactions', 'Company Transactions Report'], ['monthly-pl', 'Monthly P&L Report'], ['project-activity-leaderboard', 'Project Activity Leaderboard'], ['company-user-activity-leaderboard', 'Company User Activity Leaderboard'], ]}, { name: 'Library', icon: '📦', reports: [ ['party-library', 'Party Library'], ['cost-code-library', 'Cost Code Library'], ['material-library', 'Material Library'], ['rate-card-library', 'Rate Card Library'], ['payroll-library', 'Payroll Library'], ['equipment-library', 'Equipment Library'], ]}, { name: 'BOQ', icon: '📦', reports: [ ['boq-workorder-summary', 'BOQ Workorder Summary Report'], ['boq-item-report', 'BOQ Item Report'], ['quotation-report', 'Quotation Report'], ['quotation-item-report', 'Quotation Item Report'], ['boq-measurement-book', 'BOQ Measurement Book'], ]}, { name: 'Tax', icon: '🔖', reports: [ ['gstr1', 'Sales (GSTR-1)'], ['gstr2', 'Purchase (GSTR-2)'], ]}, { name: 'Warehouse', icon: '🗄', reports: [ ['warehouse-stock-movement', 'Warehouse Stock Movement Report'], ['warehouse-transaction', 'Warehouse Transaction Report'], ['warehouse-current-stock', 'Warehouse Current Stock Report'], ]}, { name: 'Sub Con.', icon: '🗄', reports: [ ['subcon-workorder-summary', 'Subcon Workorder Summary Report'], ['subcon-measurement-book', 'Subcon Measurement Book'], ['subcon-deduction-retention', 'Subcon Deduction / Retention Report'], ['subcon-material-issue', 'Subcon Material Issue Summary'], ]}, { name: 'Attendance & Salary', icon: '🧑', reports: [ ['attendance-salary', 'Attendance & Salary Report'], ['ot-shift', 'OT & Shift Report'], ['company-attendance', 'Company Attendance'], ['staff-monthly-salary-slip', 'Staff Monthly Salary Slip'], ['staff-salary-report', 'Staff Salary Report'], ['staff-punch-report', 'Staff Punch Report'], ['staff-muster-roll', 'Staff Muster Roll'], ]}, { name: 'Equipments', icon: '🔧', reports: [ ['equipment-usage-detail', 'Equipment Usage Detail Report'], ['fuel-efficiency', 'Fuel Efficiency Report'], ['daily-equipment-used', 'Daily based Equipment Used Report'], ['equipment-expense-summary', 'Equipment Expense Summary'], ['equipment-trip-report', 'Equipment Trip Report'], ]}, { name: 'Asset', icon: '📦', reports: [ ['asset-allocation', 'Asset Allocation Report'], ['asset-status', 'Asset Status Report'], ]}, { name: 'Purchase & Expense', icon: '⤬', reports: [ ['company-expense', 'Company Expense Report'], ['cost-code-expense-analysis', 'Cost Code Expense Analysis'], ['project-wise-expense-summary', 'Project Wise Expense Summary'], ['all-expense-deduction-retention', 'All Expense Deduction / Retention Report'], ]}, { name: 'Party Balances', icon: '👥', reports: [ ['party-ledger', 'Party Ledger'], ['all-party-balances', 'All Party Balances'], ['project-level-party-balance', 'Project level Party Balance Report'], ]}, { name: 'Materials & Inventory', icon: '🚚', reports: [ ['material-request-item', 'Material Request Item Report'], ['material-received-used', 'Material Received & Used Report'], ['material-stock', 'Material Stock Report'], ['unbilled-item', 'Unbilled Item Report'], ['po-summary', 'PO Summary Report'], ['material-received-without-po', 'Material Received without PO'], ['purchase-order-item', 'Purchase Order Item Report'], ['production-material', 'Production Material Report'], ['material-purchase-item', 'Material Purchase Item Report'], ['material-stock-movement', 'Material Stock Movement Report'], ]}, { name: 'Sales', icon: '📈', reports: [ ['company-sales', 'Company Sales Report'], ['item-wise-sales', 'Item Wise Sales Report'], ['sales-deduction-retention', 'Sales Deduction / Retention Report'], ['crm-lead-detail', 'CRM Lead Detail Report'], ['lead-status-funnel', 'Lead Status Funnel Report'], ['project-wise-sales-summary', 'Project Wise Sales Summary'], ]}, { name: 'Payments', icon: '💳', reports: [ ['company-payments', 'Company Payments'], ['bank-statement', 'Bank Statement'], ['project-wise-payment-summary', 'Project Wise Payment Summary'], ['project-payment-report', 'Project Payment Report'], ['payment-request-report', 'Payment Request Report'], ]}, { name: 'Progress & Task', icon: '📋', reports: [ ['daily-progress', 'Daily Progress Report'], ['task-report', 'Task Report'], ['task-measurement-book', 'Task Measurement Book'], ['task-material-report', 'Task Material Report'], ['todo-report', 'To Do Report'], ['task-resource-budget-vs-actual', 'Task Resource Budget Vs Actual Report'], ['site-inspection-report', 'Site Inspection Report'], ['task-revenue-expense', 'Task Revenue & Expense Report'], ['task-boq-billed-unbilled', 'Task BOQ Billed & Unbilled Qty Report'], ]}, ]; function renderReportLibrary() { const el = document.getElementById('reportLibraryGrid'); if (!el) return; el.innerHTML = REPORT_CATEGORIES.map(cat => `
${cat.icon} ${cat.name}
${cat.reports.map(([key, label]) => `
${escHtml(label)}
`).join('')}
`).join(''); } function fmtNum(n) { return Math.round(Number(n)||0).toLocaleString('en-IN'); } // Returns { columns: [...], rows: [[...]] } for a given report key, computed from real data. function getReportRows(key) { const projects = data.projects || []; const accounts = data.accounts || []; const allMats = allMaterialsFlat(); const thisMonth = dayKey(new Date()).slice(0,7); switch (key) { case 'boq-bom': case 'boq-item-report': case 'task-report': case 'task-measurement-book': { const rows = []; projects.forEach(p => (p.tasks||[]).forEach(t => rows.push([projNick(p), t.title, t.assignee||'—', t.due||'—', fmtNum(t.budget), fmtNum(t.spent), (t.progress||0)+'%']))); return { columns: ['Project','Task / Item','Assignee','Due','Budget','Spent','Progress'], rows }; } case 'budget-vs-actual-material-cost': return { columns: ['Project','Material','Qty','Rate','Est. Cost','Status'], rows: allMats.map(m => { const proj = projects.find(p=>p.id===m.projectId); return [proj?projNick(proj):'—', m.name, m.qty||0, fmtNum(m.estRate), fmtNum((m.qty||0)*(m.estRate||0)), m.status]; }) }; case 'budget-vs-actual-material-qty': return { columns: ['Project','Material','Unit','Qty Ordered'], rows: allMats.map(m => { const proj = projects.find(p=>p.id===m.projectId); return [proj?projNick(proj):'—', m.name, m.unit||'—', m.qty||0]; }) }; case 'budget-vs-actual-cost-code': { const byCat = {}; projects.forEach(p => { const cat = projectCategoryLabel[p.category||'other'] || 'Other'; if (!byCat[cat]) byCat[cat] = { budget:0, spent:0 }; byCat[cat].budget += Number(p.estimate)||0; byCat[cat].spent += computeProjectSpent(p); }); return { columns: ['Cost Code (Category)','Budget','Actual','Variance'], rows: Object.entries(byCat).map(([cat,v]) => [cat, fmtNum(v.budget), fmtNum(v.spent), fmtNum(v.budget-v.spent)]) }; } case 'project-financial-summary': return { columns: ['Project','Company','Estimate','Spent','Amount Needed','Status'], rows: projects.map(p => [projNick(p), p.company||'—', fmtNum(p.estimate), fmtNum(computeProjectSpent(p)), fmtNum(p.amountNeeded), p.status]) }; case 'project-operational-summary': return { columns: ['Project','Status','Progress','Tasks Done/Total','Doc Stage %'], rows: projects.map(p => { const st = projectStats(p); const dp = projectDocPct(p); return [projNick(p), p.status, computeProjectProgress(p)+'%', `${st.doneTasks}/${st.tasks}`, dp.pct+'%']; }) }; case 'company-transactions': case 'company-payments': return { columns: ['Date','Type','Party','Project','Amount','Status'], rows: accounts .filter(e => key === 'company-transactions' || e.status !== 'pending') .map(e => { const proj = e.projectId?projects.find(p=>p.id===e.projectId):null; return [e.date, ACCOUNT_TYPES[e.type]?.label||e.type, e.party||'—', proj?projNick(proj):'—', fmtNum(e.amount), e.status]; }) }; case 'monthly-pl': { const monthAccts = accounts.filter(e => (e.date||'').startsWith(thisMonth) && e.status !== 'pending'); const income = monthAccts.filter(e => ACCOUNT_TYPES[e.type]?.direction==='in').reduce((s,e)=>s+Number(e.amount||0),0); const expense = monthAccts.filter(e => ACCOUNT_TYPES[e.type]?.direction==='out').reduce((s,e)=>s+Number(e.amount||0),0); return { columns: ['Metric','Amount'], rows: [['Income (this month)', fmtNum(income)], ['Expense (this month)', fmtNum(expense)], ['Net P&L', fmtNum(income-expense)]] }; } case 'project-activity-leaderboard': return { columns: ['Project','Tasks Done','Production Entries','Quality Checks','Activity Score'], rows: projects.map(p => { const done = (p.tasks||[]).filter(t=>t.progress>=100).length; const prod = (p.production||[]).length; const qc = (p.qualityChecks||[]).length; return [projNick(p), done, prod, qc, done+prod+qc]; }).sort((a,b)=>b[4]-a[4]) }; case 'company-user-activity-leaderboard': { const byAssignee = {}; projects.forEach(p => (p.tasks||[]).forEach(t => { if (t.assignee) byAssignee[t.assignee] = (byAssignee[t.assignee]||0) + (t.progress>=100?1:0); })); return { columns: ['User / Assignee','Tasks Completed'], rows: Object.entries(byAssignee).sort((a,b)=>b[1]-a[1]).map(([n,c])=>[n,c]) }; } case 'party-library': return { columns: ['Name','Type','Phone','Company'], rows: [ ...(data.vendors||[]).map(v=>[v.name,'Vendor',v.phone||'—',v.company||'—']), ...(data.subcontractors||[]).map(s=>[s.name,'Subcontractor',s.phone||'—',s.company||'—']), ...(data.labours||[]).map(l=>[l.name,'Labour',l.phone||'—',l.company||'—']), ] }; case 'cost-code-library': { const counts = {}; projects.forEach(p => { const c = projectCategoryLabel[p.category||'other']||'Other'; counts[c]=(counts[c]||0)+1; }); return { columns: ['Cost Code (Category)','Projects'], rows: Object.entries(counts) }; } case 'material-library': case 'material-stock': { const byName = {}; allMats.forEach(m => { if (!byName[m.name]) byName[m.name] = { unit:m.unit, qty:0, rate:m.estRate }; byName[m.name].qty += (key==='material-stock' && m.status!=='received') ? 0 : (m.qty||0); }); return { columns: ['Material','Unit','Total Qty','Last Rate'], rows: Object.entries(byName).map(([n,v])=>[n,v.unit||'—',v.qty,fmtNum(v.rate)]) }; } case 'rate-card-library': return { columns: ['Name','Type','Rate'], rows: [ ...(data.labours||[]).map(l=>[l.name,'Labour (₹/day)',fmtNum(l.dailyRate)]), ...(data.assets||[]).map(a=>[a.name,'Asset (₹/day)',fmtNum(a.rate)]), ] }; case 'payroll-library': return { columns: ['Name','Role','Daily Rate','Company'], rows: (data.labours||[]).map(l=>[l.name,l.role||'—',fmtNum(l.dailyRate),l.company||'—']) }; case 'equipment-library': case 'asset-allocation': case 'asset-status': return { columns: ['Asset','Type','Status','Project','Rate/day','Company'], rows: (data.assets||[]).map(a => { const proj = a.projectId?projects.find(p=>p.id===a.projectId):null; return [a.name, a.type||'—', a.status, proj?projNick(proj):'Unassigned', fmtNum(a.rate), a.company||'—']; }) }; case 'boq-workorder-summary': return { columns: ['Project','Company','Work Order Stage','Overall %'], rows: projects.map(p => [projNick(p), p.company||'—', p.docStatus?.['WORK ORDER']?.done?'Done':'Pending', projectDocPct(p).pct+'%']) }; case 'quotation-report': return { columns: ['No.','Party','Project','Date','Total'], rows: (data.quotations||[]).map(q => { const proj=q.projectId?projects.find(p=>p.id===q.projectId):null; return [q.number,q.party,proj?projNick(proj):'—',q.date,fmtNum(q.total)]; }) }; case 'quotation-item-report': { const rows = []; (data.quotations||[]).forEach(q => q.items.forEach(it => rows.push([q.number, q.party, it.desc, it.qty, it.unit||'—', fmtNum(it.rate), fmtNum((it.qty||0)*(it.rate||0))]))); return { columns: ['Quotation No.','Party','Item','Qty','Unit','Rate','Amount'], rows }; } case 'gstr1': return { columns: ['Invoice No.','Party','Date','Taxable Value','GST (18%)','Total'], rows: (data.invoices||[]).map(d => [d.number, d.party, d.date, fmtNum(d.total), fmtNum(d.total*0.18), fmtNum(d.total*1.18)]) }; case 'gstr2': return { columns: ['Doc No.','Party','Date','Taxable Value','GST (18%)','Total'], rows: [...(data.purchaseOrders||[]), ...(data.vendorBills||[])].map(d => [d.number, d.party, d.date, fmtNum(d.total), fmtNum(d.total*0.18), fmtNum(d.total*1.18)]) }; case 'warehouse-stock-movement': case 'material-stock-movement': return { columns: ['Project','Material','Qty','Status'], rows: allMats.map(m => { const proj=projects.find(p=>p.id===m.projectId); return [proj?projNick(proj):'—', m.name, m.qty||0, m.status]; }) }; case 'warehouse-transaction': case 'material-received-used': return { columns: ['Project','Material','Qty','Vendor','Received'], rows: allMats.filter(m=>m.status==='received').map(m => { const proj=projects.find(p=>p.id===m.projectId); return [proj?projNick(proj):'—', m.name, m.qty||0, m.vendorName||'—', 'Yes']; }) }; case 'warehouse-current-stock': return { columns: ['Material','On Hand Qty','Unit'], rows: (() => { const byName={}; allMats.filter(m=>m.status==='received').forEach(m=>{byName[m.name]=(byName[m.name]||0)+(m.qty||0);}); return Object.entries(byName).map(([n,q])=>[n,q,'—']); })() }; case 'subcon-workorder-summary': case 'subcon-measurement-book': case 'subcon-deduction-retention': return { columns: ['Subcontractor','Trade','Project (via payments)','Amount','Status'], rows: (() => { const rows = []; (data.subcontractors||[]).forEach(s => { const matches = accounts.filter(e => e.party === s.name); if (!matches.length) { rows.push([s.name, s.trade||'—', '—', 0, '—']); return; } matches.forEach(e => { const proj=e.projectId?projects.find(p=>p.id===e.projectId):null; rows.push([s.name, s.trade||'—', proj?projNick(proj):'—', fmtNum(e.amount), e.status]); }); }); return rows; })() }; case 'subcon-material-issue': return { columns: ['Note'], rows: [['No material-issue-to-subcontractor tracking yet — log it via Projects → Materials to Order, tagging the subcontractor as vendor.']] }; case 'attendance-salary': return { columns: ['Date','Labour','Project','Status','Wage','Pay Status'], rows: (data.attendance||[]).map(att => { const proj = att.projectId?projects.find(p=>p.id===att.projectId):null; const entry = att.accountEntryId ? accounts.find(e=>e.id===att.accountEntryId) : null; return [att.date, att.labourName, proj?projNick(proj):'—', att.status, fmtNum(att.wage), entry?(entry.status==='pending'?'Pending':'Paid'):'—']; }) }; case 'ot-shift': return { columns: ['Note'], rows: [['OT/shift timing isn\'t tracked yet — Attendance currently logs Full/Half/Absent day only.']] }; case 'company-attendance': case 'staff-salary-report': case 'staff-muster-roll': { const byLab = {}; (data.attendance||[]).forEach(att => { if (!byLab[att.labourName]) byLab[att.labourName] = { full:0, half:0, absent:0, wage:0 }; byLab[att.labourName][att.status] = (byLab[att.labourName][att.status]||0) + 1; byLab[att.labourName].wage += Number(att.wage||0); }); return { columns: ['Labour','Full Days','Half Days','Absent','Total Wage'], rows: Object.entries(byLab).map(([n,v])=>[n,v.full||0,v.half||0,v.absent||0,fmtNum(v.wage)]) }; } case 'staff-monthly-salary-slip': { const byLab = {}; (data.attendance||[]).filter(att=>(att.date||'').startsWith(thisMonth)).forEach(att => { if (!byLab[att.labourName]) byLab[att.labourName] = { days:0, wage:0 }; if (att.status !== 'absent') byLab[att.labourName].days += att.status==='half'?0.5:1; byLab[att.labourName].wage += Number(att.wage||0); }); return { columns: ['Labour','Days Worked (this month)','Total Wage'], rows: Object.entries(byLab).map(([n,v])=>[n,v.days,fmtNum(v.wage)]) }; } case 'staff-punch-report': return { columns: ['Date','Labour','Status'], rows: (data.attendance||[]).map(att => [att.date, att.labourName, att.status]) }; case 'equipment-usage-detail': case 'daily-equipment-used': case 'equipment-trip-report': case 'fuel-efficiency': return { columns: ['Asset','Status','Project'], rows: (data.assets||[]).map(a => { const proj=a.projectId?projects.find(p=>p.id===a.projectId):null; return [a.name, a.status, proj?projNick(proj):'Unassigned']; }).concat([['—','Fuel/usage-hour logging isn\'t tracked yet','Add it via Human Resource → Assets & Equipment']]) }; case 'equipment-expense-summary': return { columns: ['Asset','Rate/day','Status'], rows: (data.assets||[]).map(a => [a.name, fmtNum(a.rate), a.status]) }; case 'company-expense': case 'all-expense-deduction-retention': return { columns: ['Type','Total Spent'], rows: (() => { const byType = {}; accounts.filter(e=>e.status!=='pending' && ACCOUNT_TYPES[e.type]?.direction==='out').forEach(e => { const l=ACCOUNT_TYPES[e.type]?.label||e.type; byType[l]=(byType[l]||0)+Number(e.amount||0); }); return Object.entries(byType).map(([l,v])=>[l,fmtNum(v)]); })() }; case 'cost-code-expense-analysis': return getReportRows('budget-vs-actual-cost-code'); case 'project-wise-expense-summary': return { columns: ['Project','Total Spent'], rows: projects.map(p => [projNick(p), fmtNum(computeProjectSpent(p))]) }; case 'party-ledger': case 'all-party-balances': { const byParty = {}; accounts.forEach(e => { if (!e.party) return; if (!byParty[e.party]) byParty[e.party] = { in:0, out:0, pendingIn:0, pendingOut:0 }; const dir = ACCOUNT_TYPES[e.type]?.direction; const amt = Number(e.amount||0); if (dir==='in') { if (e.status==='pending') byParty[e.party].pendingIn += amt; else byParty[e.party].in += amt; } else { if (e.status==='pending') byParty[e.party].pendingOut += amt; else byParty[e.party].out += amt; } }); return { columns: ['Party','Received','Paid','Receivable (Pending)','Payable (Pending)'], rows: Object.entries(byParty).map(([n,v])=>[n,fmtNum(v.in),fmtNum(v.out),fmtNum(v.pendingIn),fmtNum(v.pendingOut)]) }; } case 'project-level-party-balance': { const rows = []; projects.forEach(p => { const byParty = {}; accounts.filter(e=>e.projectId===p.id && e.party).forEach(e => { byParty[e.party]=(byParty[e.party]||0)+(ACCOUNT_TYPES[e.type]?.direction==='out'?Number(e.amount||0):0); }); Object.entries(byParty).forEach(([party,amt]) => rows.push([projNick(p), party, fmtNum(amt)])); }); return { columns: ['Project','Party','Amount Paid/Payable'], rows }; } case 'material-request-item': case 'task-material-report': return { columns: ['Project','Material','Qty','Unit','Vendor','Status'], rows: allMats.map(m => { const proj=projects.find(p=>p.id===m.projectId); return [proj?projNick(proj):'—', m.name, m.qty||0, m.unit||'—', m.vendorName||'—', m.status]; }) }; case 'unbilled-item': return { columns: ['Project','Material','Qty','Status'], rows: allMats.filter(m=>m.status!=='received').map(m => { const proj=projects.find(p=>p.id===m.projectId); return [proj?projNick(proj):'—', m.name, m.qty||0, m.status]; }) }; case 'po-summary': case 'material-purchase-item': return { columns: ['PO No.','Vendor','Project','Date','Total'], rows: (data.purchaseOrders||[]).map(d => { const proj=d.projectId?projects.find(p=>p.id===d.projectId):null; return [d.number,d.party,proj?projNick(proj):'—',d.date,fmtNum(d.total)]; }) }; case 'material-received-without-po': return { columns: ['Project','Material','Qty','Vendor'], rows: allMats.filter(m=>m.status==='received' && !m.poNumber).map(m => { const proj=projects.find(p=>p.id===m.projectId); return [proj?projNick(proj):'—', m.name, m.qty||0, m.vendorName||'—']; }) }; case 'purchase-order-item': { const rows = []; (data.purchaseOrders||[]).forEach(d => d.items.forEach(it => rows.push([d.number, d.party, it.desc, it.qty, it.unit||'—', fmtNum(it.rate), fmtNum((it.qty||0)*(it.rate||0))]))); return { columns: ['PO No.','Vendor','Item','Qty','Unit','Rate','Amount'], rows }; } case 'production-material': case 'daily-progress': return { columns: ['Project','Date','Activity','Qty','Unit'], rows: (() => { const rows = []; projects.forEach(p => (p.production||[]).forEach(pr => rows.push([projNick(p), pr.date, pr.activity, pr.qty||0, pr.unit||'—']))); return rows.sort((a,b)=>(b[1]||'').localeCompare(a[1]||'')); })() }; case 'company-sales': case 'project-wise-sales-summary': return { columns: ['Invoice No.','Party','Project','Date','Total'], rows: (data.invoices||[]).map(d => { const proj=d.projectId?projects.find(p=>p.id===d.projectId):null; return [d.number,d.party,proj?projNick(proj):'—',d.date,fmtNum(d.total)]; }) }; case 'item-wise-sales': { const rows = []; (data.invoices||[]).forEach(d => d.items.forEach(it => rows.push([d.number, d.party, it.desc, it.qty, fmtNum(it.rate), fmtNum((it.qty||0)*(it.rate||0))]))); return { columns: ['Invoice No.','Party','Item','Qty','Rate','Amount'], rows }; } case 'sales-deduction-retention': return { columns: ['Invoice No.','Party','Total','Status'], rows: (data.invoices||[]).map(d => [d.number, d.party, fmtNum(d.total), accounts.find(e=>e.note?.includes(d.number))?.status || '—']) }; case 'crm-lead-detail': case 'lead-status-funnel': return { columns: ['Note'], rows: [['No CRM/leads module yet — closest proxy is projects in Planning status, tracked under Projects.'], ...projects.filter(p=>p.status==='planning').map(p=>[`Pipeline: ${projNick(p)} (${p.client||'—'})`])] }; case 'bank-statement': return { columns: ['Bank','Date','Type','Party','Amount'], rows: accounts.filter(e=>e.bank).map(e => [e.bank, e.date, ACCOUNT_TYPES[e.type]?.label||e.type, e.party||'—', fmtNum(e.amount)]) }; case 'project-wise-payment-summary': case 'project-payment-report': return { columns: ['Project','In (Received)','Out (Paid)'], rows: projects.map(p => { const ins = accounts.filter(e=>e.projectId===p.id && ACCOUNT_TYPES[e.type]?.direction==='in' && e.status!=='pending').reduce((s,e)=>s+Number(e.amount||0),0); const outs = accounts.filter(e=>e.projectId===p.id && ACCOUNT_TYPES[e.type]?.direction==='out' && e.status!=='pending').reduce((s,e)=>s+Number(e.amount||0),0); return [projNick(p), fmtNum(ins), fmtNum(outs)]; }) }; case 'payment-request-report': return { columns: ['Date','Type','Party','Project','Amount','Due'], rows: accounts.filter(e=>e.status==='pending').map(e => { const proj=e.projectId?projects.find(p=>p.id===e.projectId):null; return [e.date, ACCOUNT_TYPES[e.type]?.label||e.type, e.party||'—', proj?projNick(proj):'—', fmtNum(e.amount), e.dueDate||'—']; }) }; case 'todo-report': return { columns: ['Task','Date','Completed','Urgent','Important'], rows: (data.tasks||[]).map(t => [t.text, t.date, t.completed?'Yes':'No', t.urgent?'Yes':'No', t.important?'Yes':'No']) }; case 'task-resource-budget-vs-actual': case 'task-revenue-expense': return { columns: ['Project','Task','Assignee','Budget','Spent','Variance'], rows: (() => { const rows = []; projects.forEach(p => (p.tasks||[]).forEach(t => rows.push([projNick(p), t.title, t.assignee||'—', fmtNum(t.budget), fmtNum(t.spent), fmtNum((t.budget||0)-(t.spent||0))]))); return rows; })() }; case 'site-inspection-report': return { columns: ['Project','Area','Date','Inspector','Result','Remarks'], rows: (() => { const rows = []; projects.forEach(p => (p.qualityChecks||[]).forEach(q => rows.push([projNick(p), q.area, q.date, q.inspector||'—', q.result, q.remarks||'—']))); return rows; })() }; case 'task-boq-billed-unbilled': return { columns: ['Project','Task','Budget','Billed (has PO)','Status'], rows: (() => { const rows = []; projects.forEach(p => (p.tasks||[]).forEach(t => { if ((t.budget||0) > 0) rows.push([projNick(p), t.title, fmtNum(t.budget), t.poNumber?'Yes':'No', t.poNumber||'Unbilled']); })); return rows; })() }; default: return { columns: ['Note'], rows: [['No data available for this report yet.']] }; } } let currentReportKey = null; let currentReportLabel = ''; function viewReport(key, label, downloadDirect) { const { columns, rows } = getReportRows(key); currentReportKey = key; currentReportLabel = label; if (downloadDirect) { downloadReportCSV(label, columns, rows); return; } const titleEl = document.getElementById('reportViewerTitle'); const metaEl = document.getElementById('reportViewerMeta'); const tableEl = document.getElementById('reportViewerTable'); if (titleEl) titleEl.textContent = label; if (metaEl) metaEl.textContent = `${rows.length} row${rows.length===1?'':'s'}`; if (tableEl) tableEl.innerHTML = ` ${columns.map(c=>`${escHtml(c)}`).join('')} ${rows.length ? rows.map(r => `${r.map(c=>`${escHtml(String(c))}`).join('')}`).join('') : `No data yet`}`; const modal = document.getElementById('reportViewerModal'); if (modal) modal.classList.add('show'); } function closeReportViewer() { const modal = document.getElementById('reportViewerModal'); if (modal) modal.classList.remove('show'); } function downloadReportCSV(label, columns, rows) { const csv = [columns.join(','), ...rows.map(r => r.map(c => `"${String(c).replace(/"/g,'""')}"`).join(','))].join('\n'); const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = label.replace(/[^a-z0-9]+/gi,'_') + '.csv'; a.click(); URL.revokeObjectURL(url); } function downloadCurrentReport() { if (!currentReportKey) return; const { columns, rows } = getReportRows(currentReportKey); downloadReportCSV(currentReportLabel, columns, rows); } // ----- PDF-style printable reports (reuse the print-doc styling) ----- function printExecutiveSummary() { const projects = data.projects || []; const totalBudget = projects.reduce((s,p) => s + (Number(p.estimate)||0), 0); const totalSpent = projects.reduce((s,p) => s + computeProjectSpent(p), 0); const receivable = computeReceivables(); const payable = computePayables(); const netCash = computeNetCashFromBanks(); const html = ` `; const area = document.getElementById('printDocArea'); if (area) area.innerHTML = html; setTimeout(() => window.print(), 50); } function printMonthlyFinancial() { const monthKey = dayKey(new Date()).slice(0,7); const entries = (data.accounts||[]).filter(e => (e.date||'').startsWith(monthKey)); const rows = entries.map(e => `${e.date}${ACCOUNT_TYPES[e.type]?.label||e.type}${escHtml(e.party||'')}${fmtINR(e.amount)}${e.status}`).join(''); const total = entries.reduce((s,e)=>s+Number(e.amount||0),0); const html = ``; const area = document.getElementById('printDocArea'); if (area) area.innerHTML = html; setTimeout(() => window.print(), 50); } function printProjectCostReport() { const rows = (data.projects||[]).map(p => { const fc = computeProjectForecast(p); return `${escHtml(projNick(p))}${fmtINR(fc.budget)}${fmtINR(fc.spent)}${fmtINR(fc.budget-fc.spent)}${fc.progress}%${fc.overBudget?'Over Budget':'On Track'}`; }).join(''); const html = ``; const area = document.getElementById('printDocArea'); if (area) area.innerHTML = html; setTimeout(() => window.print(), 50); } // ----- Excel workbook exports (reuse the already-loaded XLSX library) ----- function exportFullWorkbook() { const wb = XLSX.utils.book_new(); const projRows = (data.projects||[]).map(p => ({ Name:p.name, Nickname:p.nickname, Client:p.client, Company:p.company, Category:p.category, Status:p.status, Estimate:p.estimate, AmountNeeded:p.amountNeeded })); XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(projRows), 'Projects'); const acctRows = (data.accounts||[]).map(e => ({ Type:ACCOUNT_TYPES[e.type]?.label||e.type, Party:e.party, Amount:e.amount, Date:e.date, Status:e.status, DueDate:e.dueDate, Bank:e.bank, Note:e.note })); XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(acctRows), 'Accounts'); const labRows = (data.labours||[]).map(l => ({ Name:l.name, Role:l.role, DailyRate:l.dailyRate, Phone:l.phone, Company:l.company })); XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(labRows), 'Labour'); const vnRows = (data.vendors||[]).map(v => ({ Name:v.name, Category:v.category, Phone:v.phone, GSTIN:v.gstin, Company:v.company })); XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(vnRows), 'Vendors'); const docRows = allDocuments().map(d => ({ Number:d.number, Type:DOC_TYPE_META[d.type].label, Party:d.party, Date:d.date, Total:d.total })); XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(docRows), 'Sales Documents'); XLSX.writeFile(wb, `nexus-full-workbook-${dayKey(new Date())}.xlsx`); } function exportProjectsTasksWorkbook() { const wb = XLSX.utils.book_new(); const rows = []; (data.projects||[]).forEach(p => (p.tasks||[]).forEach(t => rows.push({ Project: projNick(p), Task: t.title, Assignee: t.assignee, Due: t.due, Budget: t.budget, Spent: t.spent, Progress: t.progress }))); XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(rows), 'Project Tasks'); XLSX.writeFile(wb, `nexus-projects-tasks-${dayKey(new Date())}.xlsx`); } function exportFinancialLedgerWorkbook() { const wb = XLSX.utils.book_new(); const rows = (data.accounts||[]).map(e => ({ Date:e.date, Type:ACCOUNT_TYPES[e.type]?.label||e.type, Party:e.party, Amount:e.amount, Status:e.status, DueDate:e.dueDate, Bank:e.bank, Project: e.projectId ? projNick(data.projects.find(p=>p.id===e.projectId)||{}) : '', Note:e.note })); XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(rows), 'Financial Ledger'); XLSX.writeFile(wb, `nexus-financial-ledger-${dayKey(new Date())}.xlsx`); } function downloadCSVText(filename, rows) { const csv = rows.map(r => r.map(c => `"${String(c??'').replace(/"/g,'""')}"`).join(',')).join('\r\n'); const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } function exportMonthlyCSV() { const monthKey = dayKey(new Date()).slice(0,7); const entries = (data.accounts||[]).filter(e => (e.date||'').startsWith(monthKey)); const rows = [['Date','Type','Party','Amount','Status','Bank','Note']]; entries.forEach(e => rows.push([e.date, ACCOUNT_TYPES[e.type]?.label||e.type, e.party, e.amount, e.status, e.bank||'', e.note||''])); downloadCSVText(`nexus-monthly-${monthKey}.csv`, rows); } function exportLabourCSV() { const rows = [['Name','Role','Daily Rate','Phone','Company']]; (data.labours||[]).forEach(l => rows.push([l.name, l.role, l.dailyRate, l.phone, l.company])); downloadCSVText(`nexus-labour-${dayKey(new Date())}.csv`, rows); } // Frequency display helper const freqPill = { once:'', daily:'pill-green', weekly:'pill-sky', biweekly:'pill-violet', monthly:'pill-amber' }; const freqLabel = { once:'One-time', daily:'Daily', weekly:'Weekly', biweekly:'Biweekly', monthly:'Monthly' }; // ===== TASKS ===== function addTask() { const i = document.getElementById('taskInput'); const fEl = document.getElementById('taskFreq'); const dEl = document.getElementById('taskDate'); const rEl = document.getElementById('taskReminder'); const catEl = document.getElementById('taskCategory'); const freq = fEl ? fEl.value : 'once'; const chosenDate = (dEl && dEl.value) ? dEl.value : dayKey(new Date()); const reminderTime = rEl ? rEl.value : ''; const category = catEl ? catEl.value.trim() : ''; if (i.value.trim()) { // Auto-register new custom categories if (category) { data.taskCategories = Array.isArray(data.taskCategories) ? data.taskCategories : [...DEFAULT_TASK_CATEGORIES]; if (!data.taskCategories.includes(category)) { data.taskCategories.push(category); } } data.tasks.push({ id:Date.now(), text:i.value.trim(), completed:false, date:chosenDate, freq, reminderTime, remindedOn:'', timeSpent:0, urgent:false, important:false, category }); i.value = ''; if (catEl) catEl.value = ''; if (dEl) dEl.value = dayKey(new Date()); if (rEl) rEl.value = ''; if (reminderTime) ensureNotificationPermission(); saveData(); renderAll(); } } function toggleTask(id) { const t = data.tasks.find(x=>x.id===id); if (!t) return; t.completed = !t.completed; // If this daily task was pushed from a project's BOQ/task list, keep that task's progress in sync if (t.sourceType === 'project-task' && t.projectId && t.projectTaskId) { const p = data.projects.find(x => x.id === t.projectId); const pt = p?.tasks?.find(x => x.id === t.projectTaskId); if (pt) { pt.progress = t.completed ? 100 : (pt.progress >= 100 ? 0 : pt.progress); pt.done = pt.progress >= 100; } } saveData(); renderAll(); } function editTask(id) { const t = data.tasks.find(x=>x.id===id); if (!t) return; const allCats = Array.from(new Set([...DEFAULT_TASK_CATEGORIES,...(Array.isArray(data.taskCategories)?data.taskCategories:[]),...(data.tasks||[]).map(t=>t.category).filter(Boolean)])).sort(); openGenModal('Edit Task', `
${allCats.map(c=>``).join('')}
`, () => { const v = document.getElementById('genName').value.trim(); const dv = document.getElementById('genDate').value; const rv = document.getElementById('genReminder').value; const cv = document.getElementById('genTaskCat').value.trim(); if (v) { t.text = v; if (dv) t.date = dv; t.freq = document.getElementById('genFreq').value; if (cv && cv !== t.category) { t.category = cv; data.taskCategories = Array.isArray(data.taskCategories) ? data.taskCategories : [...DEFAULT_TASK_CATEGORIES]; if (!data.taskCategories.includes(cv)) data.taskCategories.push(cv); } else if (!cv) { t.category = ''; } if (rv !== t.reminderTime) { t.reminderTime = rv; t.remindedOn = ''; if (rv) ensureNotificationPermission(); } saveData(); renderAll(); closeGenModal(); } }); } function delTask(id) { data.tasks = data.tasks.filter(x=>x.id!==id); saveData(); renderAll(); } function syncProjectTasksToDailyTasks() { const today = dayKey(new Date()); let added = 0; (data.projects || []).forEach(p => { (p.tasks || []).forEach(pt => { if (pt.done || Number(pt.progress) >= 100) return; const exists = (data.tasks || []).some(t => t.sourceType === 'project-task' && t.projectId === p.id && t.projectTaskId === pt.id); if (exists) return; data.tasks.push({ id: Date.now() + Math.random(), text: `${projNick(p)}: ${pt.title}`, completed: false, date: pt.due || today, freq: 'once', sourceType: 'project-task', projectId: p.id, projectTaskId: pt.id }); added++; }); }); saveData(); renderAll(); const meta = document.getElementById('taskCountMeta'); if (meta) meta.textContent = added ? `Added ${added} project task${added===1?'':'s'}` : 'No new project tasks'; } // Finds (or creates) the Daily Task linked to a specific project task — doesn't save/render itself, // callers are responsible for that so it can be composed with other actions (e.g. scheduling). function ensureDailyTaskForProjectTask(projectId, taskId) { const p = data.projects.find(x => x.id === projectId); const pt = p?.tasks?.find(t => t.id === taskId); if (!p || !pt) return null; let dailyTask = (data.tasks || []).find(t => t.sourceType === 'project-task' && t.projectId === p.id && t.projectTaskId === pt.id); if (!dailyTask) { dailyTask = { id: Date.now() + Math.random(), text: `${projNick(p)}: ${pt.title}`, completed: false, date: pt.due || dayKey(new Date()), freq: 'once', sourceType: 'project-task', projectId: p.id, projectTaskId: pt.id }; data.tasks.push(dailyTask); } return dailyTask; } // "Add to Daily Tasks" button on a project task row function pushProjectTaskToDaily(projectId, taskId) { const dailyTask = ensureDailyTaskForProjectTask(projectId, taskId); saveData(); renderAll(); } // "Add to Schedule" button on a project task row — ensures a Daily Task exists, then schedules it function pushProjectTaskToSchedule(projectId, taskId) { const dailyTask = ensureDailyTaskForProjectTask(projectId, taskId); saveData(); renderAll(); if (dailyTask) planTask(dailyTask.id); } function importDailyTaskExcel(event) { const file = event.target.files[0]; if (!file) return; const msgEl = document.getElementById('taskExcelImportMsg'); if (typeof XLSX === 'undefined') { if (msgEl) msgEl.innerHTML = `Excel reader didn't load — check your internet connection and reload the page.`; event.target.value = ''; return; } if (msgEl) msgEl.innerHTML = `Reading ${escHtml(file.name)}…`; const reader = new FileReader(); reader.onload = e => { try { const wb = XLSX.read(new Uint8Array(e.target.result), { type:'array' }); const parsed = parseProjectWorkbook(wb); const items = [...(parsed.abstractTasks || [])]; const today = dayKey(new Date()); let added = 0; items.forEach((item, i) => { const text = item.title.replace(/^(Detailed|BOQ):\s*/i, ''); const exists = (data.tasks || []).some(t => (t.text || '').toLowerCase() === text.toLowerCase() && t.sourceFile === file.name); if (exists) return; data.tasks.push({ id: Date.now() + i + Math.random(), text, completed: false, date: today, freq: 'once', sourceType: item.kind === 'abstract' ? 'boq-excel' : 'details-excel', sourceFile: file.name, budget: Number(item.budget) || 0 }); added++; }); saveData(); renderAll(); if (msgEl) msgEl.innerHTML = added ? `Imported ${added} task${added===1?'':'s'} from Details / Abstract sheets.` : `No new task rows found in Details / Abstract sheets.`; } catch (err) { if (msgEl) msgEl.innerHTML = `Could not import ${escHtml(file.name)}: ${escHtml(err.message)}`; } }; reader.onerror = () => { if (msgEl) msgEl.innerHTML = `Could not read the file.`; }; reader.readAsArrayBuffer(file); event.target.value = ''; } const DEFAULT_TASK_CATEGORIES = ['Work', 'Site Visit', 'Admin', 'Finance', 'Meeting', 'Planning', 'Follow-up', 'Personal']; let taskFilters = { status: 'all', urgent: 'all', important: 'all', freq: 'all', category: 'all' }; function setTaskFilter(key, val) { taskFilters[key] = val; if (key !== 'freq') { const groupId = key === 'status' ? 'taskStatusFilter' : key === 'urgent' ? 'taskUrgentFilter' : 'taskImportantFilter'; document.querySelectorAll(`#${groupId} .filter-btn`).forEach(b => b.classList.toggle('active', b.dataset.tf === val)); } renderTasks(); } function applyTaskFilters(list) { let out = list; if (taskFilters.status === 'skipped') { out = out.filter(t => t.skipped); } else if (taskFilters.status === 'rescheduled') { out = out.filter(t => !t.skipped && (t.carryCount > 0 || !!t.originalDate)); } else { out = out.filter(t => !t.skipped); if (taskFilters.status === 'pending') out = out.filter(t => !t.completed); else if (taskFilters.status === 'done') out = out.filter(t => t.completed); } if (taskFilters.urgent === 'urgent') out = out.filter(t => t.urgent); if (taskFilters.important === 'important') out = out.filter(t => t.important); if (taskFilters.freq !== 'all') out = out.filter(t => (t.freq || 'once') === taskFilters.freq); if (taskFilters.category !== 'all') out = out.filter(t => (t.category || '') === taskFilters.category); return out; } const SKIP_REASONS = ['No longer needed', 'Postponed to later', 'Duplicate task', 'Blocked / waiting on something', 'Low priority', 'Other']; function skipTask(id) { const t = data.tasks.find(x => x.id === id); if (!t) return; openGenModal('Skip Task', `
${escHtml(t.text)}
`, () => { t.skipped = true; t.skipReason = document.getElementById('genSkipReason').value; t.skipRating = Number(document.getElementById('genSkipRating').value); t.skipDate = dayKey(new Date()); saveData(); renderAll(); closeGenModal(); }); } function restoreSkippedTask(id) { const t = data.tasks.find(x => x.id === id); if (!t) return; t.skipped = false; saveData(); renderAll(); } // ===== CARRY FORWARD / RESCHEDULE ===== let carryForwardTaskId = null; function openCarryForward(taskId) { carryForwardTaskId = taskId; const t = data.tasks.find(x => x.id === taskId); if (!t) return; const nameEl = document.getElementById('cfTaskName'); if (nameEl) nameEl.textContent = `"${t.text}" — currently due ${new Date(t.date).toLocaleDateString('en-IN', { weekday:'long', day:'numeric', month:'long' })}`; const customEl = document.getElementById('cfCustomDate'); if (customEl) { // Default custom date = tomorrow const tomorrow = new Date(t.date || new Date()); tomorrow.setDate(tomorrow.getDate() + 1); customEl.value = dayKey(tomorrow); } const msgEl = document.getElementById('cfResultMsg'); if (msgEl) msgEl.style.display = 'none'; const keepEl = document.getElementById('cfKeepOriginal'); if (keepEl) keepEl.checked = false; const modal = document.getElementById('carryForwardModal'); if (modal) modal.classList.add('show'); } function closeCarryForward() { const modal = document.getElementById('carryForwardModal'); if (modal) modal.classList.remove('show'); carryForwardTaskId = null; } function carryForwardToDate(newDateKey) { const t = data.tasks.find(x => x.id === carryForwardTaskId); if (!t || !newDateKey) return; const keepOriginal = document.getElementById('cfKeepOriginal')?.checked; if (keepOriginal) { // Duplicate: keep original task, add a new copy on the new date const clone = { ...t, id: Date.now(), date: newDateKey, completed: false, skipped: false, remindedOn: '', timeSpent: 0, carryCount: (t.carryCount||0)+1, originalDate: t.originalDate || t.date, // remember the first origin movedFrom: t.date, // direct source text: t.text }; data.tasks.push(clone); showCFResult(`📋 Copied to ${new Date(newDateKey + 'T00:00:00').toLocaleDateString('en-IN', { weekday:'short', day:'numeric', month:'short' })} (original kept)`, 'var(--sky)'); } else { // Move: update the existing task's date, track where it came from const oldDate = t.date; if (!t.originalDate) t.originalDate = oldDate; // preserve very first date t.movedFrom = oldDate; // direct previous date t.date = newDateKey; t.carryCount = (t.carryCount || 0) + 1; t.remindedOn = ''; showCFResult(`✅ Moved to ${new Date(newDateKey + 'T00:00:00').toLocaleDateString('en-IN', { weekday:'short', day:'numeric', month:'short' })} (was ${new Date(oldDate + 'T00:00:00').toLocaleDateString('en-IN', { day:'numeric', month:'short' })})`, 'var(--accent)'); } saveData(); renderAll(); } function carryForwardDays(days) { const t = data.tasks.find(x => x.id === carryForwardTaskId); if (!t) return; const base = new Date((t.date || dayKey(new Date())) + 'T00:00:00'); base.setDate(base.getDate() + days); carryForwardToDate(dayKey(base)); } function carryForwardToNextWeekday(targetDay) { // targetDay: 1=Mon, 6=Sat const t = data.tasks.find(x => x.id === carryForwardTaskId); if (!t) return; const base = new Date((t.date || dayKey(new Date())) + 'T00:00:00'); let d = new Date(base); d.setDate(d.getDate() + 1); // at least tomorrow while (d.getDay() !== targetDay) d.setDate(d.getDate() + 1); carryForwardToDate(dayKey(d)); } function carryForwardToCustomDate() { const dateEl = document.getElementById('cfCustomDate'); if (!dateEl || !dateEl.value) return; carryForwardToDate(dateEl.value); } function showCFResult(msg, color) { const el = document.getElementById('cfResultMsg'); if (!el) return; el.textContent = msg; el.style.display = ''; el.style.background = color === 'var(--accent)' ? 'rgba(48,209,88,0.12)' : 'rgba(10,132,255,0.12)'; el.style.color = color; el.style.border = `1px solid ${color}`; // Auto-close after short delay setTimeout(() => closeCarryForward(), 1400); } function renderTasks() { const range = getPeriodRange(); const rangeList = data.tasks.filter(t => inRange(t.date, range)); const list = applyTaskFilters(rangeList); const el = document.getElementById('taskList'); if (!el) return; if (currentPeriod === 'week') { renderTasksWeekBoard(el, range); } else if (taskFilters.status === 'rescheduled') { // Sort: most carries first, then by original date ascending const sorted = [...list].sort((a, b) => (b.carryCount||0) - (a.carryCount||0) || (a.originalDate||a.date).localeCompare(b.originalDate||b.date)); el.innerHTML = sorted.length ? sorted.map(t => { const si = taskStatusInfo(t); const origDate = t.originalDate ? new Date(t.originalDate + 'T00:00:00').toLocaleDateString('en-IN', { day:'numeric', month:'short', year:'numeric' }) : '—'; const movedDate = t.movedFrom ? new Date(t.movedFrom + 'T00:00:00').toLocaleDateString('en-IN', { day:'numeric', month:'short' }) : null; const curDate = t.date ? new Date(t.date + 'T00:00:00').toLocaleDateString('en-IN', { day:'numeric', month:'short', year:'numeric' }) : '—'; const overdueDays = t.originalDate ? daysBetween(t.originalDate, dayKey(new Date())) : 0; return `
${t.completed?'✓':''}
${escHtml(t.text)}
📅 Orig: ${origDate} ${movedDate ? `↷ From: ${movedDate}` : ''} Now: ${curDate} ${overdueDays > 0 ? `⏰ ${overdueDays}d overdue` : ''}
↷ ×${t.carryCount||0}
${si.label}
`; }).join('') : '
No rescheduled tasks — use the 📅 Carry Forward button on any task to reschedule it
'; } else if (taskFilters.status === 'skipped') { el.innerHTML = list.length ? list.map(t => `
${escHtml(t.text)}
${escHtml(t.skipReason||'No reason given')}
${'★'.repeat(t.skipRating||0)}${'☆'.repeat(5-(t.skipRating||0))}
${t.skipDate||'—'}
`).join('') : '
No skipped tasks
'; } else { if (!list.length) { el.innerHTML = '
No tasks match these filters
'; } else { // Group tasks by time of day using reminderTime or linked schedule item time const TIME_SLOTS = [ { key: 'early', label: '🌄 Early Morning', sub: '12:00 AM – 8:59 AM', from: 0, to: 539 }, { key: 'morning', label: '🌅 Morning', sub: '9:00 AM – 11:59 AM', from: 540, to: 719 }, { key: 'midnoon', label: '☀️ Mid Noon', sub: '12:00 PM – 1:59 PM', from: 720, to: 839 }, { key: 'afternoon', label: '🌤 Afternoon', sub: '2:00 PM – 5:59 PM', from: 840, to: 1079 }, { key: 'evening', label: '🌆 Evening', sub: '6:00 PM – 9:00 PM', from:1080, to: 1260 }, { key: 'night', label: '🌙 Night', sub: '9:01 PM – 11:59 PM', from:1261, to: 1439 }, { key: 'anytime', label: '📋 Anytime', sub: 'No time assigned', from: -1, to: -1 } ]; function getTaskTimeMins(t) { // Priority: reminderTime → linked schedule item → no time if (t.reminderTime) { const [hh, mm] = t.reminderTime.split(':').map(Number); if (!isNaN(hh)) return hh * 60 + (mm || 0); } // Check if linked to a schedule item const sched = (data.schedule || []).find(s => s.taskId === t.id); if (sched && sched.time) { const [hh, mm] = sched.time.split(':').map(Number); if (!isNaN(hh)) return hh * 60 + (mm || 0); } return -1; // no time } function getSlotFor(t) { const mins = getTaskTimeMins(t); if (mins < 0) return 'anytime'; for (const slot of TIME_SLOTS) { if (slot.key === 'anytime') continue; if (mins >= slot.from && mins <= slot.to) return slot.key; } return 'anytime'; } // Sort each group: by time asc, then completed last const grouped = {}; TIME_SLOTS.forEach(s => grouped[s.key] = []); list.forEach(t => grouped[getSlotFor(t)].push(t)); TIME_SLOTS.forEach(slot => { grouped[slot.key].sort((a, b) => { const ta = getTaskTimeMins(a), tb = getTaskTimeMins(b); if (ta !== tb) return ta - tb; return (a.completed ? 1 : 0) - (b.completed ? 1 : 0); }); }); function renderTaskRow(t) { const f = t.freq || 'once'; const freqCell = f !== 'once' ? `${freqLabel[f]}` : ''; const reminderBadge = t.reminderTime ? `🔔 ${t.reminderTime}` : ''; const timeSpentBadge = t.timeSpent > 0 ? `⏱ ${formatMinutes(t.timeSpent)}` : ''; const si = taskStatusInfo(t); return `
${t.completed?'✓':''}
${escHtml(t.text)}${t.carryCount ? ` ↷${t.carryCount}` : ''}
${(reminderBadge || timeSpentBadge || t.category) ? `
${t.category ? `🏷 ${escHtml(t.category)}` : ''}${reminderBadge}${timeSpentBadge}
` : ''}
${freqCell}
${new Date(t.date).toLocaleDateString('en-IN',{day:'numeric',month:'short'})}
${si.label}
${!t.completed ? `` : ` Done `}
`; } el.innerHTML = TIME_SLOTS .filter(slot => grouped[slot.key].length > 0) .map(slot => { const tasks = grouped[slot.key]; const done = tasks.filter(t => t.completed).length; return `
${slot.label} ${slot.sub} ${done}/${tasks.length}
${tasks.map(renderTaskRow).join('')}
`; }).join(''); } } const done = list.filter(t=>t.completed).length; const meta = document.getElementById('taskCountMeta'); if (meta) meta.textContent = `${done}/${list.length} done`; updateTaskChart(); renderTaskMatrix(); } function renderTasksWeekBoard(el, range) { const todayKey = dayKey(new Date()); // Week navigation header const weekStart = range.days[0]; const weekEnd = range.days[range.days.length - 1]; const { week, year } = getISOWeekNumber(weekStart); const totalWeeks = getWeeksInYear(year); const isCurrentWeek = new Date() >= range.start && new Date() <= range.end; const fmt = d => d.toLocaleDateString('en-IN', { day:'numeric', month:'short' }); const progressPct = Math.round((week / totalWeeks) * 100); const weekNav = `
Week ${week} / ${totalWeeks}${isCurrentWeek?' · Current':''} ${fmt(weekStart)} – ${fmt(weekEnd)}
${progressPct}% of ${year} elapsed
`; const MAX_ROWS = 4; // max task rows per column before "N more" const board = range.days.map(d => { const k = dayKey(d); const isToday = k === todayKey; const isPast = k < todayKey; const dayTasks = data.tasks.filter(t => dayKey(t.date) === k && !t.skipped); const movedHereTasks = dayTasks.filter(t => t.originalDate && dayKey(t.originalDate) !== k); const regularTasks = dayTasks.filter(t => !t.originalDate || dayKey(t.originalDate) === k); const movedAwayTasks = data.tasks.filter(t => !t.skipped && (dayKey(t.originalDate||'') === k || dayKey(t.movedFrom||'') === k) && dayKey(t.date) !== k ); const total = dayTasks.length; const doneCount = dayTasks.filter(t=>t.completed).length; const carriedCount = movedHereTasks.length; const actualCount = regularTasks.length; const actualPending = actualCount - regularTasks.filter(t=>t.completed).length; // Badge colour logic let badgeClass = total === 0 ? 'task-count-none' : doneCount === total ? 'task-count-done' : isPast ? 'task-count-overdue' : actualPending >= 4 ? 'task-count-busy' : 'task-count-normal'; // Build badge — single or dual const badge = (actualCount > 0 && carriedCount > 0) ? `
${actualCount}
Today
${carriedCount}
↷ Moved
` : `
${total}
`; // Row colour helper const rowCls = t => t.completed ? 'task-mini-row-done' : t.urgent ? 'task-mini-row-urgent' : t.important ? 'task-mini-row-important' : 'task-mini-row-normal'; // Gather all visible rows (moved-here first, then regular, moved-away last) const allRows = [ ...movedHereTasks.map(t => ({ cls: 'task-mini-row-moved-here', label: `↷ ${t.text}`, cat: t.category || '', id: t.id })), ...regularTasks.map(t => ({ cls: rowCls(t), label: t.text, cat: t.category || '', id: t.id })), ...movedAwayTasks.map(t => ({ cls: 'task-mini-row-moved-away', label: `↦ ${t.text}`, cat: t.category || '', id: null })) ]; const visible = allRows.slice(0, MAX_ROWS); const moreCount = allRows.length - visible.length; const rowHtml = visible.map(r => `
${r.cat ? `[${escHtml(r.cat)}]` : ''}${escHtml(r.label)}
` ).join(''); const moreHtml = moreCount > 0 ? `
+ ${moreCount} more
` : ''; return `
${d.toLocaleDateString('en-IN',{weekday:'short'})}
${d.getDate()}
${badge}
${total===0?'no tasks':total===1?'task':'tasks'}
${total>0?`
${doneCount}/${total} done
`:''}
${allRows.length ? `
${rowHtml}${moreHtml}
` : ''}
`; }).join(''); el.innerHTML = weekNav + `
${board}
Done Pending Important Urgent ↷ Carried here ↦ Moved away
`; } function jumpToTaskDay(k) { anchorDate = new Date(k + 'T00:00:00'); currentPeriod = 'day'; document.querySelectorAll('#periodFilter .filter-btn').forEach(b => b.classList.toggle('active', b.dataset.period === 'day')); const anchorEl = document.getElementById('anchorDate'); if (anchorEl) anchorEl.valueAsDate = anchorDate; renderAll(); } function getISOWeekNumber(date) { const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); const dayNum = d.getUTCDay() || 7; d.setUTCDate(d.getUTCDate() + 4 - dayNum); const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); return { week: Math.ceil((((d - yearStart) / 86400000) + 1) / 7), year: d.getUTCFullYear() }; } function getWeeksInYear(year) { // A year has 53 weeks if Jan 1 or Dec 31 is Thursday const dec28 = new Date(Date.UTC(year, 11, 28)); const dayNum = dec28.getUTCDay() || 7; dec28.setUTCDate(dec28.getUTCDate() + 4 - dayNum); const yearStart = new Date(Date.UTC(dec28.getUTCFullYear(), 0, 1)); return Math.ceil((((dec28 - yearStart) / 86400000) + 1) / 7); } function navigateWeek(dir) { // Move anchorDate by 7 days forward/backward anchorDate = new Date(anchorDate); anchorDate.setDate(anchorDate.getDate() + (dir * 7)); currentPeriod = 'week'; document.querySelectorAll('#periodFilter .filter-btn').forEach(b => b.classList.toggle('active', b.dataset.period === 'week')); const anchorEl = document.getElementById('anchorDate'); if (anchorEl) anchorEl.valueAsDate = anchorDate; renderAll(); } // ===== EISENHOWER PRIORITY MATRIX ===== function setTaskView(view) { document.querySelectorAll('#taskViewSwitch .filter-btn').forEach(b => b.classList.toggle('active', b.dataset.tview === view)); const listEl = document.getElementById('taskListView'); const matrixEl = document.getElementById('taskMatrixView'); if (listEl) listEl.style.display = view === 'list' ? '' : 'none'; if (matrixEl) matrixEl.style.display = view === 'matrix' ? '' : 'none'; if (view === 'matrix') renderTaskMatrix(); } function toggleTaskUrgent(id) { const t = data.tasks.find(x => x.id === id); if (t) { t.urgent = !t.urgent; saveData(); renderAll(); } } function toggleTaskImportant(id) { const t = data.tasks.find(x => x.id === id); if (t) { t.important = !t.important; saveData(); renderAll(); } } function clearMatrixFilters() { const catEl = document.getElementById('matrixCategoryFilter'); const freqEl = document.getElementById('matrixFreqFilter'); if (catEl) catEl.value = 'all'; if (freqEl) freqEl.value = 'all'; renderTaskMatrix(); } function renderTaskMatrix() { const range = getPeriodRange(); let list = data.tasks.filter(t => inRange(t.date, range) && !t.completed && !t.skipped); // Populate category dropdown const catFilterEl = document.getElementById('matrixCategoryFilter'); if (catFilterEl) { const curCat = catFilterEl.value; const allCats = Array.from(new Set([ ...DEFAULT_TASK_CATEGORIES, ...(Array.isArray(data.taskCategories) ? data.taskCategories : []), ...list.map(t => t.category).filter(Boolean) ])).sort(); catFilterEl.innerHTML = `` + allCats.map(c => ``).join(''); catFilterEl.value = allCats.includes(curCat) ? curCat : 'all'; } // Apply filters const catFilter = catFilterEl ? catFilterEl.value : 'all'; const freqFilter = document.getElementById('matrixFreqFilter')?.value || 'all'; if (catFilter !== 'all') list = list.filter(t => (t.category || '') === catFilter); if (freqFilter !== 'all') list = list.filter(t => (t.freq || 'once') === freqFilter); // Show filter count const countEl = document.getElementById('matrixFilterCount'); const hasFilter = catFilter !== 'all' || freqFilter !== 'all'; if (countEl) { countEl.textContent = hasFilter ? `${list.length} task${list.length===1?'':'s'} matching filters` : `${list.length} tasks total`; countEl.style.color = hasFilter ? 'var(--accent)' : ''; } const quadCard = t => { const hasReminder = !!(t.reminderTime || t.reminderDate); return `
${t.completed?'✓':''}
${escHtml(t.text)}
${t.category ? `
🏷 ${escHtml(t.category)}${t.freq && t.freq!=='once' ? ' · '+freqLabel[t.freq] : ''}
` : (t.freq && t.freq!=='once' ? `
${freqLabel[t.freq]}
` : '')}
${hasReminder ? `🔔` : ''}
`; }; const fill = (id, items) => { const el = document.getElementById(id); if (el) el.innerHTML = items.length ? items.map(quadCard).join('') : `
Nothing here${hasFilter?' matching this filter':''}
`; }; fill('ehQ1List', list.filter(t => t.urgent && t.important)); fill('ehQ2List', list.filter(t => !t.urgent && t.important)); fill('ehQ3List', list.filter(t => t.urgent && !t.important)); fill('ehQ4List', list.filter(t => !t.urgent && !t.important)); } function formatMinutes(mins) { mins = Math.round(mins); if (mins < 60) return `${mins}m`; const h = Math.floor(mins / 60), m = mins % 60; return m ? `${h}h ${m}m` : `${h}h`; } // ===== REMINDER SETTING MODAL (Apple-style) ===== let reminderTaskId = null; let rmRepeat = 'none'; let rmEarlyMins = 0; let reminderPopupTaskId = null; let reminderPopupSnoozeTimeout = null; function openReminderModal(taskId) { reminderTaskId = taskId; const t = data.tasks.find(x => x.id === taskId); if (!t) return; document.getElementById('reminderTaskName').textContent = `"${t.text}"`; ensureNotificationPermission(); // Pre-fill from existing reminder const rmDateEl = document.getElementById('rmDate'); const rmTimeEl = document.getElementById('rmTime'); if (rmDateEl) rmDateEl.value = t.reminderDate || dayKey(new Date()); if (rmTimeEl) rmTimeEl.value = t.reminderTime || '09:00'; rmRepeat = t.reminderRepeat || 'none'; rmEarlyMins = t.reminderEarly || 0; document.querySelectorAll('#rmRepeatGroup .filter-btn').forEach(b => b.classList.toggle('active', b.dataset.rmrep === rmRepeat)); document.querySelectorAll('#rmEarlyGroup .filter-btn').forEach(b => b.classList.toggle('active', Number(b.dataset.rme) === rmEarlyMins)); rmUpdateTimeBtns(); rmUpdateCurrentDisplay(t); document.getElementById('reminderSetModal').classList.add('show'); } function closeReminderModal() { document.getElementById('reminderSetModal').classList.remove('show'); reminderTaskId = null; } function rmSetDate(daysFromNow) { const d = new Date(); d.setDate(d.getDate() + daysFromNow); const el = document.getElementById('rmDate'); if (el) el.value = dayKey(d); } function rmSetTime(time) { const el = document.getElementById('rmTime'); if (el) el.value = time; rmUpdateTimeBtns(); } function rmUpdateTimeBtns() { const val = document.getElementById('rmTime')?.value || ''; document.querySelectorAll('#rmTimePresets .btn').forEach(b => b.classList.toggle('active', b.dataset.rmtime === val)); } function rmSetRepeat(val) { rmRepeat = val; document.querySelectorAll('#rmRepeatGroup .filter-btn').forEach(b => b.classList.toggle('active', b.dataset.rmrep === val)); } function rmSetEarly(mins) { rmEarlyMins = mins; document.querySelectorAll('#rmEarlyGroup .filter-btn').forEach(b => b.classList.toggle('active', Number(b.dataset.rme) === mins)); } function rmUpdateCurrentDisplay(t) { const el = document.getElementById('rmCurrentDisplay'); if (!el) return; if (t.reminderDate || t.reminderTime) { const d = t.reminderDate ? new Date(t.reminderDate + 'T00:00:00').toLocaleDateString('en-IN', { weekday:'short', day:'numeric', month:'short' }) : ''; el.style.display = ''; el.textContent = `🔔 Current reminder: ${d} at ${t.reminderTime || '—'}${t.reminderRepeat && t.reminderRepeat !== 'none' ? ' · Repeats ' + t.reminderRepeat : ''}`; } else { el.style.display = 'none'; } } function saveTaskReminder() { const t = data.tasks.find(x => x.id === reminderTaskId); if (!t) return; const dateEl = document.getElementById('rmDate'); const timeEl = document.getElementById('rmTime'); t.reminderDate = dateEl?.value || dayKey(new Date()); t.reminderTime = timeEl?.value || '09:00'; t.reminderRepeat = rmRepeat; t.reminderEarly = rmEarlyMins; t.remindedOn = ''; // reset so it fires again saveData(); renderAll(); closeReminderModal(); } function clearTaskReminder() { const t = data.tasks.find(x => x.id === reminderTaskId); if (!t) return; delete t.reminderDate; delete t.reminderTime; delete t.reminderRepeat; delete t.reminderEarly; delete t.remindedOn; saveData(); renderAll(); closeReminderModal(); } // ===== IN-APP REMINDER POPUP ===== function checkMatrixReminders() { const now = new Date(); const todayKey = dayKey(now); const currentMins = now.getHours() * 60 + now.getMinutes(); const currentHHMM = `${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}`; (data.tasks || []).forEach(t => { if (t.completed || t.skipped || !t.reminderTime || !t.reminderDate) return; const remKey = t.reminderDate; if (remKey !== todayKey) return; const already = t.remindedOn === currentHHMM; if (already) return; const [rh, rm] = t.reminderTime.split(':').map(Number); const remMins = rh * 60 + rm; const earlyMins = t.reminderEarly || 0; const triggerMins = remMins - earlyMins; if (currentMins >= triggerMins && currentMins < triggerMins + 1) { t.remindedOn = currentHHMM; showReminderPopup(t.id, earlyMins > 0 ? `In ${earlyMins} min: ` + t.text : t.text, t.reminderTime); saveData(); } }); } function showReminderPopup(taskId, title, time) { reminderPopupTaskId = taskId; const popup = document.getElementById('reminderPopup'); const titleEl = document.getElementById('reminderPopupTitle'); const timeEl = document.getElementById('reminderPopupTime'); if (!popup) return; if (titleEl) titleEl.textContent = title; if (timeEl) timeEl.textContent = `Scheduled for ${time} today`; popup.style.display = ''; // Auto-animate in popup.style.animation = 'reminderSlideIn 0.4s cubic-bezier(0.34,1.56,0.64,1) both'; // Fire system notification + beep fireNotification('🔔 Reminder', title); } function dismissReminderPopup(markDone) { if (markDone && reminderPopupTaskId) { const t = data.tasks.find(x => x.id === reminderPopupTaskId); if (t) { t.completed = true; saveData(); renderAll(); } } const popup = document.getElementById('reminderPopup'); if (popup) popup.style.display = 'none'; if (reminderPopupSnoozeTimeout) clearTimeout(reminderPopupSnoozeTimeout); reminderPopupTaskId = null; } function snoozeReminderPopup(mins) { const popup = document.getElementById('reminderPopup'); if (popup) popup.style.display = 'none'; if (reminderPopupSnoozeTimeout) clearTimeout(reminderPopupSnoozeTimeout); reminderPopupSnoozeTimeout = setTimeout(() => { const t = reminderPopupTaskId ? data.tasks.find(x => x.id === reminderPopupTaskId) : null; if (t && !t.completed) showReminderPopup(t.id, t.text, t.reminderTime || '—'); }, mins * 60 * 1000); const timeEl = document.getElementById('reminderPopupTime'); if (timeEl) timeEl.textContent = `Snoozed — will remind again in ${mins} min`; } // Hook checkMatrixReminders into the global reminder check loop // (runs every 20s alongside existing reminder check) // ===== TASK REMINDERS (browser notifications) ===== function ensureNotificationPermission() { if (!('Notification' in window)) return; if (Notification.permission === 'default') Notification.requestPermission(); } function fireNotification(title, body) { if ('Notification' in window && Notification.permission === 'granted') { try { new Notification(title, { body }); } catch (e) {} } playBeep(); } function playBeep() { try { const ctx = new (window.AudioContext || window.webkitAudioContext)(); const o = ctx.createOscillator(); const g = ctx.createGain(); o.connect(g); g.connect(ctx.destination); o.frequency.value = 880; g.gain.setValueAtTime(0.2, ctx.currentTime); o.start(); g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.5); o.stop(ctx.currentTime + 0.5); } catch (e) {} } function checkTaskReminders() { const now = new Date(); const todayKey = dayKey(now); const hhmm = `${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}`; let changed = false; (data.tasks || []).forEach(t => { if (!t.reminderTime || t.completed) return; if (dayKey(t.date) !== todayKey) return; if (t.remindedOn === todayKey) return; if (t.reminderTime <= hhmm) { fireNotification('⏰ Task reminder', t.text); t.remindedOn = todayKey; changed = true; } }); if (changed) saveData(); } // ===== FOCUS TIMER (Time Boxing + Stopwatch) ===== let focusTimerTaskId = null; let focusTimerMode = 'timebox'; // 'timebox' | 'stopwatch' let focusTimerDurationMins = 15; let focusTimerRemainingSecs = 15 * 60; let focusTimerElapsedSecs = 0; let focusTimerInterval = null; let focusTimerRunning = false; function openFocusTimer(taskId) { focusTimerTaskId = taskId; const t = data.tasks.find(x => x.id === taskId); const titleEl = document.getElementById('focusTimerTaskName'); if (titleEl) titleEl.textContent = t ? t.text : 'Focus Timer'; focusTimerMode = 'timebox'; focusTimerDurationMins = 15; focusTimerRemainingSecs = 15 * 60; focusTimerElapsedSecs = 0; focusTimerRunning = false; document.querySelectorAll('#focusTimerModeSwitch .filter-btn').forEach(b => b.classList.toggle('active', b.dataset.ftmode === 'timebox')); document.querySelectorAll('#focusTimerBoxPresets .filter-btn').forEach(b => b.classList.toggle('active', b.dataset.mins === '15')); const presetsEl = document.getElementById('focusTimerBoxPresets'); if (presetsEl) presetsEl.style.display = ''; const customEl = document.getElementById('focusTimerCustomMins'); if (customEl) { customEl.style.display = 'none'; customEl.value = ''; } const startBtn = document.getElementById('focusTimerStartBtn'); const pauseBtn = document.getElementById('focusTimerPauseBtn'); if (startBtn) startBtn.style.display = ''; if (pauseBtn) pauseBtn.style.display = 'none'; updateFocusTimerDisplay(); updateFocusTimerSessionMeta(); const modal = document.getElementById('focusTimerModal'); if (modal) modal.classList.add('show'); } function closeFocusTimer() { if (focusTimerRunning) pauseFocusTimer(); const modal = document.getElementById('focusTimerModal'); if (modal) modal.classList.remove('show'); hideFocusTimerBubble(); } function minimiseFocusTimer() { // Hide the modal but keep the timer running — show the floating bubble instead const modal = document.getElementById('focusTimerModal'); if (modal) modal.classList.remove('show'); showFocusTimerBubble(); } function expandFocusTimer() { // Re-open the modal from the bubble const modal = document.getElementById('focusTimerModal'); if (modal) modal.classList.add('show'); hideFocusTimerBubble(); updateFocusTimerDisplay(); updateFocusTimerSessionMeta(); } function showFocusTimerBubble() { const bubble = document.getElementById('focusTimerBubble'); if (!bubble) return; bubble.style.display = 'flex'; bubble.className = focusTimerRunning ? 'running' : 'paused'; updateBubbleDisplay(); } function hideFocusTimerBubble() { const bubble = document.getElementById('focusTimerBubble'); if (bubble) bubble.style.display = 'none'; } function updateBubbleDisplay() { const bubble = document.getElementById('focusTimerBubble'); if (!bubble || bubble.style.display === 'none') return; const timeEl = document.getElementById('bubbleTime'); const taskEl = document.getElementById('bubbleTaskName'); const statusEl = document.getElementById('bubbleStatus'); const t = focusTimerTaskId ? data.tasks.find(x => x.id === focusTimerTaskId) : null; if (timeEl) { const secs = focusTimerMode === 'timebox' ? focusTimerRemainingSecs : focusTimerElapsedSecs; const m = Math.floor(secs / 60); const s = secs % 60; timeEl.textContent = `${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`; timeEl.className = `bubble-time ${focusTimerRunning ? '' : 'paused'}`; } if (taskEl) taskEl.textContent = t ? (t.text.length > 28 ? t.text.slice(0,28)+'…' : t.text) : 'Focus Timer'; if (statusEl) statusEl.textContent = focusTimerRunning ? (focusTimerMode === 'timebox' ? '⏱ Running' : '⏱ Stopwatch') : '⏸ Paused — tap to open'; bubble.className = focusTimerRunning ? 'running' : 'paused'; } function setFocusTimerMode(mode) { if (focusTimerRunning) pauseFocusTimer(); focusTimerMode = mode; document.querySelectorAll('#focusTimerModeSwitch .filter-btn').forEach(b => b.classList.toggle('active', b.dataset.ftmode === mode)); const presetsEl = document.getElementById('focusTimerBoxPresets'); if (presetsEl) presetsEl.style.display = mode === 'timebox' ? '' : 'none'; const customEl = document.getElementById('focusTimerCustomMins'); if (customEl) customEl.style.display = 'none'; if (mode === 'timebox') focusTimerRemainingSecs = focusTimerDurationMins * 60; else focusTimerElapsedSecs = 0; updateFocusTimerDisplay(); } function setFocusTimerDuration(mins) { if (mins === 'custom') { document.querySelectorAll('#focusTimerBoxPresets .filter-btn').forEach(b => b.classList.toggle('active', b.dataset.mins === 'custom')); const customEl = document.getElementById('focusTimerCustomMins'); if (customEl) { customEl.style.display = ''; customEl.focus(); } return; } focusTimerDurationMins = mins; document.querySelectorAll('#focusTimerBoxPresets .filter-btn').forEach(b => b.classList.toggle('active', Number(b.dataset.mins) === mins)); const customEl = document.getElementById('focusTimerCustomMins'); if (customEl) customEl.style.display = 'none'; if (!focusTimerRunning) { focusTimerRemainingSecs = mins * 60; updateFocusTimerDisplay(); } } function setFocusTimerCustomDuration(val) { const mins = Math.max(1, Math.min(180, Number(val) || 0)); if (!mins) return; focusTimerDurationMins = mins; if (!focusTimerRunning) { focusTimerRemainingSecs = mins * 60; updateFocusTimerDisplay(); } } function startFocusTimer() { if (focusTimerRunning) return; ensureNotificationPermission(); focusTimerRunning = true; const startBtn = document.getElementById('focusTimerStartBtn'); const pauseBtn = document.getElementById('focusTimerPauseBtn'); if (startBtn) startBtn.style.display = 'none'; if (pauseBtn) pauseBtn.style.display = ''; focusTimerInterval = setInterval(() => { if (focusTimerMode === 'timebox') { focusTimerRemainingSecs--; if (focusTimerRemainingSecs <= 0) { focusTimerRemainingSecs = 0; updateFocusTimerDisplay(); completeFocusTimeBox(); return; } } else { focusTimerElapsedSecs++; } updateFocusTimerDisplay(); }, 1000); } function pauseFocusTimer() { if (!focusTimerRunning) return; clearInterval(focusTimerInterval); focusTimerRunning = false; const startBtn = document.getElementById('focusTimerStartBtn'); const pauseBtn = document.getElementById('focusTimerPauseBtn'); if (startBtn) startBtn.style.display = ''; if (pauseBtn) pauseBtn.style.display = 'none'; commitFocusTimerProgress(); } function commitFocusTimerProgress() { if (focusTimerMode !== 'stopwatch' || focusTimerElapsedSecs <= 0) return; const t = data.tasks.find(x => x.id === focusTimerTaskId); if (!t) return; t.timeSpent = (Number(t.timeSpent) || 0) + focusTimerElapsedSecs / 60; focusTimerElapsedSecs = 0; saveData(); renderAll(); updateFocusTimerSessionMeta(); } function completeFocusTimeBox() { clearInterval(focusTimerInterval); focusTimerRunning = false; const startBtn = document.getElementById('focusTimerStartBtn'); const pauseBtn = document.getElementById('focusTimerPauseBtn'); if (startBtn) startBtn.style.display = ''; if (pauseBtn) pauseBtn.style.display = 'none'; const t = data.tasks.find(x => x.id === focusTimerTaskId); if (t) { t.timeSpent = (Number(t.timeSpent) || 0) + focusTimerDurationMins; saveData(); renderAll(); } fireNotification('⏱ Time box complete', `"${t ? t.text : 'Task'}" — ${focusTimerDurationMins} minute session finished.`); updateFocusTimerSessionMeta(); focusTimerRemainingSecs = focusTimerDurationMins * 60; updateFocusTimerDisplay(); } function resetFocusTimer() { clearInterval(focusTimerInterval); focusTimerRunning = false; const startBtn = document.getElementById('focusTimerStartBtn'); const pauseBtn = document.getElementById('focusTimerPauseBtn'); if (startBtn) startBtn.style.display = ''; if (pauseBtn) pauseBtn.style.display = 'none'; if (focusTimerMode === 'stopwatch') { commitFocusTimerProgress(); focusTimerElapsedSecs = 0; } else { focusTimerRemainingSecs = focusTimerDurationMins * 60; } updateFocusTimerDisplay(); } function updateFocusTimerDisplay() { const el = document.getElementById('focusTimerDisplay'); if (el) { const secs = focusTimerMode === 'timebox' ? focusTimerRemainingSecs : focusTimerElapsedSecs; const m = Math.floor(secs / 60), s = secs % 60; el.textContent = `${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`; } updateBubbleDisplay(); } function updateFocusTimerSessionMeta() { const t = data.tasks.find(x => x.id === focusTimerTaskId); const el = document.getElementById('focusTimerSessionMeta'); if (el) el.textContent = `Total logged: ${formatMinutes(t ? (t.timeSpent || 0) : 0)}`; } function toggleTaskChartVisibility() { data.taskChartHidden = !data.taskChartHidden; applyTaskChartVisibility(); saveData(); } function applyTaskChartVisibility() { const panel = document.getElementById('taskProgressPanel'); const showBtn = document.getElementById('taskChartShowBtn'); const grid = document.getElementById('taskGridLayout'); const hidden = !!data.taskChartHidden; if (panel) panel.style.display = hidden ? 'none' : ''; if (showBtn) showBtn.style.display = hidden ? '' : 'none'; if (grid) grid.style.gridTemplateColumns = hidden ? '1fr' : ''; if (!hidden) updateTaskChart(); } function updateTaskChart() { const ctx = document.getElementById('taskRingChart'); if (!ctx) return; const typeEl = document.getElementById('taskChartType'); const type = typeEl ? typeEl.value : 'doughnut'; const range = getPeriodRange(); const list = data.tasks.filter(t => inRange(t.date, range)); const done = list.filter(t=>t.completed).length; const pending = Math.max(list.length - done, 0); if (charts.taskRing) charts.taskRing.destroy(); // Circular charts: Done vs Pending split if (type === 'doughnut' || type === 'pie' || type === 'polarArea') { charts.taskRing = new Chart(ctx, { type, data:{ labels:['Done','Pending'], datasets:[{ data:[done, pending || (list.length?0:1)], backgroundColor:[C.accent, type==='polarArea'?C.rose+'88':C.track], borderWidth:0 }] }, options:{ responsive:true, maintainAspectRatio:false, cutout: type==='doughnut' ? '66%' : 0, plugins:{ legend:{ labels:{ color:C.muted }, position:'bottom' } }, scales: type==='polarArea' ? { r:{ ticks:{ color:C.muted, backdropColor:'transparent' }, grid:{ color:C.grid }, angleLines:{ color:C.grid } } } : {} } }); return; } // Bar / Line: completion over time across the period days const days = range.days.slice(-30); const labels = days.map(d => d.toLocaleDateString('en-US',{ month:'short', day:'numeric' })); const doneData = days.map(d => data.tasks.filter(t => dayKey(t.date)===dayKey(d) && t.completed).length); const totalData = days.map(d => data.tasks.filter(t => dayKey(t.date)===dayKey(d)).length); if (type === 'bar') { charts.taskRing = new Chart(ctx, { type:'bar', data:{ labels, datasets:[ { label:'Completed', data:doneData, backgroundColor:C.accent+'cc', borderRadius:5 }, { label:'Total', data:totalData, backgroundColor:C.grid, borderRadius:5 } ]}, options:{ responsive:true, maintainAspectRatio:false, plugins:{ legend:{ labels:{ color:C.muted, boxWidth:12 } } }, scales:{ y:{ beginAtZero:true, ticks:{ color:C.muted, precision:0 }, grid:{ color:C.grid } }, x:{ ticks:{ color:C.muted }, grid:{ display:false } } } } }); } else if (type === 'line') { const rate = days.map((d,i) => totalData[i] ? Math.round(doneData[i]/totalData[i]*100) : 0); charts.taskRing = new Chart(ctx, { type:'line', data:{ labels, datasets:[{ label:'Completion %', data:rate, borderColor:C.accent, backgroundColor:C.accent+'18', borderWidth:2.5, tension:0.4, fill:true, pointRadius:3, pointBackgroundColor:C.accent }] }, options:{ responsive:true, maintainAspectRatio:false, plugins:{ legend:{ display:false }, tooltip:{ callbacks:{ label:c=>c.parsed.y+'%' } } }, scales:{ y:{ beginAtZero:true, max:100, ticks:{ color:C.muted, callback:v=>v+'%' }, grid:{ color:C.grid } }, x:{ ticks:{ color:C.muted }, grid:{ display:false } } } } }); } } // ===== HABITS ===== function addHabit() { const i = document.getElementById('habitInput'); const fEl = document.getElementById('habitFreq'); const freq = fEl ? fEl.value : 'daily'; if (i.value.trim()) { data.habits.push({ id:Date.now(), name:i.value.trim(), streak:0, completedDates:[], freq }); i.value=''; saveData(); renderAll(); } } function toggleHabit(id) { const h = data.habits.find(x=>x.id===id); if (h) { const today = new Date().toLocaleDateString(); const idx = h.completedDates.indexOf(today); if (idx === -1) h.completedDates.push(today); else h.completedDates.splice(idx,1); h.streak = calcStreak(h.completedDates); saveData(); renderAll(); } } function calcStreak(dates) { let s=0, d=new Date(); while (dates.includes(d.toLocaleDateString())) { s++; d.setDate(d.getDate()-1); } return s; } function delHabit(id) { data.habits = data.habits.filter(x=>x.id!==id); saveData(); renderAll(); } function editHabit(id) { const h = data.habits.find(x=>x.id===id); if (!h) return; openGenModal('Edit Habit', `
`, () => { const n = document.getElementById('genName').value.trim(); if (n) { h.name = n; h.freq = document.getElementById('genFreq').value; saveData(); renderAll(); closeGenModal(); } }); } function renderHabits() { const el = document.getElementById('habitList'); if (!el) return; const today = new Date().toLocaleDateString(); el.innerHTML = data.habits.length ? data.habits.map(h => { const done = h.completedDates.includes(today); const f = h.freq || 'daily'; return `
${done?'✓':''}
${h.name}
${freqLabel[f]} ${h.streak}🔥
`; }).join('') : '
No habits yet — add one to start
'; const meta = document.getElementById('habitCountMeta'); if (meta) meta.textContent = `${data.habits.length} habits`; const prog = document.getElementById('habitProgress'); if (prog) prog.innerHTML = data.habits.length ? data.habits.map(h => `
${h.name}${h.streak} days
`).join('') : '
'; updateHabitTrend(); } function updateHabitTrend() { const ctx = document.getElementById('habitTrendChart'); if (!ctx) return; const range = getPeriodRange(); const days = range.days.slice(-14); const labels = days.map(d => d.toLocaleDateString('en-US',{ month:'short', day:'numeric' })); const colors = C.series; const datasets = data.habits.slice(0,5).map((h,i)=>({ label:h.name, data: days.map(d => h.completedDates.includes(d.toLocaleDateString())?1:0), borderColor: colors[i%5], backgroundColor:colors[i%5]+'20', tension:0.4, fill:false, pointRadius:3 })); const lbl = document.getElementById('habitTrendLabel'); if (lbl) lbl.textContent = periodLabel(); if (charts.habitTrend) charts.habitTrend.destroy(); charts.habitTrend = new Chart(ctx, { type:'line', data:{ labels, datasets }, options:{ responsive:true, maintainAspectRatio:false, plugins:{ legend:{ labels:{ color:C.muted, boxWidth:12 } } }, scales:{ y:{ min:0,max:1, ticks:{ display:false }, grid:{ color:C.grid } }, x:{ ticks:{ color:C.muted }, grid:{ display:false } } } } }); } // ===== SCHEDULE ===== function addSchedule() { const t = document.getElementById('schedTime').value; const endEl = document.getElementById('schedEndTime'); let endTime = endEl ? endEl.value : ''; const a = document.getElementById('schedActivity').value.trim(); const linkSel = document.getElementById('schedTaskLink'); const taskId = linkSel && linkSel.value ? Number(linkSel.value) : null; if (t && a) { if (!endTime) { // default to start + 60 minutes if "To" left blank const [hh, mm] = t.split(':').map(Number); const d = new Date(2000, 0, 1, hh, mm + 60); endTime = `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`; } data.schedule.push({ id:Date.now(), time:t, endTime, activity:a, date:new Date().toLocaleDateString(), taskId }); document.getElementById('schedTime').value=''; document.getElementById('schedActivity').value=''; if (endEl) endEl.value = ''; const woEl2 = document.getElementById('projWorkOrderDate'); if(woEl2) woEl2.value=''; const agEl2 = document.getElementById('projAgreementDate'); if(agEl2) agEl2.value=''; const ssEl = document.getElementById('projSubSubCategory'); if(ssEl) ssEl.value=''; refreshProjCatLists(); if (linkSel) linkSel.value = ''; saveData(); renderAll(); } } function onSchedTaskLinkChange() { const linkSel = document.getElementById('schedTaskLink'); if (!linkSel || !linkSel.value) return; const task = data.tasks.find(x => x.id === Number(linkSel.value)); const actEl = document.getElementById('schedActivity'); if (task && actEl && !actEl.value.trim()) actEl.value = task.text; } function populateSchedTaskSelect() { const sel = document.getElementById('schedTaskLink'); if (!sel) return; const prev = sel.value; const todayKey = dayKey(new Date()); const pending = data.tasks.filter(t => !t.completed); sel.innerHTML = '' + pending.map(t => ``).join(''); if (pending.some(t => String(t.id) === prev)) sel.value = prev; } // ===== ADD TO SCHEDULE MODAL (replaces prompt()) ===== let planTaskId = null; let ptDurationMins = 60; function planTask(taskId) { planTaskId = taskId; const task = data.tasks.find(x => x.id === taskId); if (!task) return; const nameEl = document.getElementById('ptTaskName'); if (nameEl) nameEl.textContent = `"${task.text}"`; // Default: current time rounded to next 30 min const now = new Date(); const mins = Math.ceil((now.getHours() * 60 + now.getMinutes()) / 30) * 30; const hh = String(Math.floor(mins / 60) % 24).padStart(2, '0'); const mm = String(mins % 60).padStart(2, '0'); const defaultTime = `${hh}:${mm}`; const startEl = document.getElementById('ptStartTime'); if (startEl) startEl.value = defaultTime; const dateEl = document.getElementById('ptDate'); if (dateEl) dateEl.value = dayKey(new Date()); ptDurationMins = 60; document.querySelectorAll('#ptDurationBtns .btn').forEach(b => b.classList.toggle('active', Number(b.dataset.dur) === 60)); const customRow = document.getElementById('ptCustomDurRow'); if (customRow) customRow.style.display = 'none'; ptUpdateEndTime(); document.getElementById('planTaskModal').classList.add('show'); } function closePlanTaskModal() { document.getElementById('planTaskModal').classList.remove('show'); planTaskId = null; } function ptSetTime(time) { const el = document.getElementById('ptStartTime'); if (el) { el.value = time; ptUpdateEndTime(); } } function ptSetDuration(mins, isCustom) { if (mins === 0 && !isCustom) { // Show custom input document.querySelectorAll('#ptDurationBtns .btn').forEach(b => b.classList.toggle('active', b.dataset.dur === '0')); const row = document.getElementById('ptCustomDurRow'); if (row) row.style.display = ''; return; } if (!isCustom) { const row = document.getElementById('ptCustomDurRow'); if (row) row.style.display = 'none'; } ptDurationMins = mins || 60; document.querySelectorAll('#ptDurationBtns .btn').forEach(b => b.classList.toggle('active', Number(b.dataset.dur) === ptDurationMins)); ptUpdateEndTime(); } function ptSetDate(daysFromToday) { const d = new Date(); d.setDate(d.getDate() + daysFromToday); const el = document.getElementById('ptDate'); if (el) el.value = dayKey(d); } function ptUpdateEndTime() { const startEl = document.getElementById('ptStartTime'); const endEl = document.getElementById('ptEndTime'); if (!startEl || !endEl || !startEl.value) return; const [hh, mm] = startEl.value.split(':').map(Number); const endD = new Date(2000, 0, 1, hh, mm + (ptDurationMins || 60)); endEl.value = `${String(endD.getHours()).padStart(2,'0')}:${String(endD.getMinutes()).padStart(2,'0')}`; } function savePlanTask() { const task = data.tasks.find(x => x.id === planTaskId); if (!task) return; const startEl = document.getElementById('ptStartTime'); const endEl = document.getElementById('ptEndTime'); const dateEl = document.getElementById('ptDate'); const time = startEl?.value; const endTime = endEl?.value || ''; const dateVal = dateEl?.value || dayKey(new Date()); if (!time) return; // Convert YYYY-MM-DD to localeDateString for consistency with schedule.date const dateObj = new Date(dateVal + 'T00:00:00'); const schedDate = dateObj.toLocaleDateString(); data.schedule.push({ id: Date.now(), time, endTime, activity: task.text, date: schedDate, taskId: task.id }); saveData(); renderAll(); closePlanTaskModal(); } function delSchedule(id) { data.schedule = data.schedule.filter(x=>x.id!==id); saveData(); renderAll(); } function editSchedule(id) { const s = data.schedule.find(x=>x.id===id); if (!s) return; openSchedEdit(s); } function unlinkSchedule(id) { const s = data.schedule.find(x=>x.id===id); if (s) { s.taskId = null; saveData(); renderAll(); } } function renderSchedule() { populateSchedTaskSelect(); const el = document.getElementById('schedList'); if (!el) return; const today = new Date().toLocaleDateString(); const list = data.schedule.filter(s=>s.date===today).sort((a,b)=>a.time.localeCompare(b.time)); el.innerHTML = list.length ? list.map(s => { const linkedTask = s.taskId ? data.tasks.find(t => t.id === s.taskId) : null; const isDone = linkedTask ? linkedTask.completed : false; return `
${s.time}
${s.endTime||''}
${linkedTask ? `
${isDone?'✓':''}
` : '
'}
${escHtml(s.activity)}${linkedTask ? ` 🔗 linked` : ''}
`; }).join('') : '
Nothing scheduled today — add an activity above
'; } // ===== PROJECTS ===== const projectCategoryLabel = { government:'Government', private:'Private', residential:'Residential', commercial:'Commercial', maintenance:'Maintenance', infrastructure:'Infrastructure', other:'Other' }; const projectSubCategoryLabel = { civil_work:'🚧 Civil Work', water_supply:'💧 Water Supply / Maintenance', building_maintenance:'🏗 Building Maintenance', motor_repair:'⚙️ Motor Repair', interior:'🚪 Interior', renovation:'🔨 Renovation' }; const projectCategoryPill = { government:'pill-violet', private:'pill-sky', residential:'pill-green', commercial:'pill-sky', maintenance:'pill-amber', infrastructure:'pill-rose', other:'pill-sky' }; let currentCompanyFilter = 'all'; let currentZoneFilter = 'all'; let currentProjCatFilter = 'all'; let currentProjSubCatFilter = 'all'; let currentProjSubSubCatFilter = 'all'; let currentProjStatusFilter = 'all'; let currentProjSearchQuery = ''; let activeProjectId = null; let pendingProjectImport = null; function clearNewProjectForm() { const textIds = ['projName','projNickname','projZone','projClient','projCompany', 'projEstimateNo','projFileNo','projCategory','projSubCategory', 'projSubSubCategory','projEstimate','projAmountNeeded']; textIds.forEach(id => { const el=document.getElementById(id); if(el) el.value=''; }); const dateIds = ['projWorkOrderDate','projAgreementDate','projEndDate']; dateIds.forEach(id => { const el=document.getElementById(id); if(el) el.value=''; }); const sel1=document.getElementById('projStatus'); if(sel1) sel1.value='planning'; const sel2=document.getElementById('projGstRate'); if(sel2) sel2.value='18'; const msg=document.getElementById('projImportMsg'); if(msg) msg.innerHTML=''; } function showNewProjectForm() { document.getElementById('newProjectFormPanel').style.display='block'; document.getElementById('projAddTaskRow').style.display='block'; document.getElementById('projName').focus(); refreshProjCatLists(); } function hideNewProjectForm() { document.getElementById('newProjectFormPanel').style.display='none'; } function addProject() { const n = document.getElementById('projName').value.trim(); const c = document.getElementById('projClient').value.trim(); const nickEl = document.getElementById('projNickname'); const nickname = nickEl ? nickEl.value.trim() : ''; const zoneEl = document.getElementById('projZone'); const zone = zoneEl ? zoneEl.value.trim() : ''; const company = document.getElementById('projCompany').value.trim() || 'Unassigned'; const category = (document.getElementById('projCategory')?.value||'').trim(); const subCategory = (document.getElementById('projSubCategory')?.value||'').trim(); const subSubCat = (document.getElementById('projSubSubCategory')?.value||'').trim(); const s = document.getElementById('projStatus').value; const estEl = document.getElementById('projEstimate'); const estimate = estEl ? (Number(estEl.value) || 0) : 0; const neededEl = document.getElementById('projAmountNeeded'); const amountNeeded = neededEl ? (Number(neededEl.value) || 0) : 0; const endEl = document.getElementById('projEndDate'); const endDate = endEl ? endEl.value : ''; if (n && c) { const gstRateEl = document.getElementById('projGstRate'); const gstRate = gstRateEl ? Number(gstRateEl.value) : 18; const woDateEl = document.getElementById('projWorkOrderDate'); const agDateEl = document.getElementById('projAgreementDate'); const estNoEl = document.getElementById('projEstimateNo'); const fileNoEl = document.getElementById('projFileNo'); const workOrderDate = woDateEl ? woDateEl.value : ''; const agreementDate = agDateEl ? agDateEl.value : ''; const estimateNo = estNoEl ? estNoEl.value.trim() : ''; const fileNo = fileNoEl? fileNoEl.value.trim(): ''; const project = { id:Date.now(), name:n, nickname, zone, client:c, company, category, subCategory, subSubCategory:subSubCat, status:s, estimate, gstRate, amountNeeded, endDate, workOrderDate, agreementDate, estimateNo, fileNo, tasks:[], docStatus:{}, date:new Date().toLocaleDateString() }; if (pendingProjectImport?.boqTasks?.length) importBoqTasksIntoProject(project, pendingProjectImport.boqTasks); data.projects.push(project); document.getElementById('projName').value=''; document.getElementById('projClient').value=''; document.getElementById('projCompany').value=''; if (nickEl) nickEl.value = ''; if (zoneEl) zoneEl.value = ''; if (estEl) estEl.value = ''; if (neededEl) neededEl.value = ''; if (endEl) endEl.value = ''; const msgEl = document.getElementById('projImportMsg'); if (msgEl) msgEl.innerHTML = ''; pendingProjectImport = null; hideNewProjectForm(); const estNoEl2 = document.getElementById('projEstimateNo'); if(estNoEl2) estNoEl2.value=''; const fileNoEl2= document.getElementById('projFileNo'); if(fileNoEl2) fileNoEl2.value=''; normalizeProjects(); saveData(); renderAll(); } } // ===== PROJECT IMPORT (Excel / PDF) ===== function importProjectFile(event) { const file = event.target.files[0]; if (!file) return; const msgEl = document.getElementById('projImportMsg'); const isPdf = /\.pdf$/i.test(file.name); const isCsv = /\.csv$/i.test(file.name); if (!isCsv && !isPdf && typeof XLSX === 'undefined') { if (msgEl) msgEl.innerHTML = `Excel reader didn't load — check your internet connection and reload the page, then try again.`; event.target.value = ''; return; } if (isPdf && !window.pdfjsLib) { if (msgEl) msgEl.innerHTML = `PDF reader didn't load — check your internet connection and reload the page, then try again.`; event.target.value = ''; return; } if (msgEl) msgEl.innerHTML = `Reading ${escHtml(file.name)}…`; const reader = new FileReader(); reader.onload = async (e) => { try { let text = ''; pendingProjectImport = null; if (isPdf) { text = await extractPdfText(e.target.result); } else if (isCsv) { text = e.target.result; } else { const wb = XLSX.read(new Uint8Array(e.target.result), { type:'array' }); pendingProjectImport = parseProjectWorkbook(wb); text = pendingProjectImport.text; } applyProjectImport(text, file.name); } catch (err) { if (msgEl) msgEl.innerHTML = `Could not read ${escHtml(file.name)}: ${escHtml(err.message)}`; } }; reader.onerror = () => { if (msgEl) msgEl.innerHTML = `Could not read the file.`; }; if (isPdf) reader.readAsArrayBuffer(file); else if (isCsv) reader.readAsText(file); else reader.readAsArrayBuffer(file); event.target.value = ''; } async function extractPdfText(arrayBuffer) { if (!window.pdfjsLib) throw new Error('PDF reader unavailable'); pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js'; const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise; let text = ''; for (let i = 1; i <= pdf.numPages; i++) { const page = await pdf.getPage(i); const content = await page.getTextContent(); text += content.items.map(it => it.str).join(' ') + '\n'; } return text; } function parseMoneyCell(v) { if (typeof v === 'number' && Number.isFinite(v)) return v; const cleaned = String(v ?? '').replace(/[₹,\s]/g, ''); const m = cleaned.match(/-?\d+(\.\d+)?/); return m ? Number(m[0]) : 0; } function parseProjectWorkbook(wb) { const chunks = []; const detailTasks = []; const abstractTasks = []; wb.SheetNames.forEach(sheetName => { const sheet = wb.Sheets[sheetName]; chunks.push(XLSX.utils.sheet_to_csv(sheet)); if (/details?|detailed/i.test(sheetName)) { const rows = XLSX.utils.sheet_to_json(sheet, { header:1, defval:'' }); detailTasks.push(...extractTaskRowsFromSheet(rows, sheetName, 'detail')); } else if (/\babs\b|abstract|\bboq\b/i.test(sheetName)) { const rows = XLSX.utils.sheet_to_json(sheet, { header:1, defval:'' }); abstractTasks.push(...extractTaskRowsFromSheet(rows, sheetName, 'abstract')); } }); // Only Abstract/BOQ sheets are reliable enough to become tasks — they're properly costed line items. // "Detail"/measurement sheets are raw dimensional working data (location names, sub-measurements) and // produce noisy, near-zero "budget" rows if imported, so they're parsed but excluded from boqTasks. const boqTasks = [...abstractTasks]; return { text: chunks.join('\n'), detailTasks, abstractTasks, boqTasks }; } const BOQ_HEADER_WORDS = new Set(['description of work','description','particulars','particular','item no','item no.','sl. no','sl no','sl.no','schedule','quantity','qty','rate','amount','per','units','unit']); const BOQ_SKIP_TITLE = /^(total|grand total|sub total|net total|name of work|detailed measurement|measurement|estimate amount|abstract of estimate|thoothukudi|assit|assistant|junior engineer|executive engineer|deputy city engineer)/i; function isPureNumericCell(cellStr) { const cleaned = cellStr.replace(/[₹,\s]/g, ''); return /^-?\d+(\.\d+)?$/.test(cleaned); } function extractTaskRowsFromSheet(rowsIn, sheetName, kind='detail') { const source = kind === 'abstract' ? 'BOQ' : 'Detailed'; // Stop at the formal TOTAL row — anything after it (signatures, leftover rate-card rows) isn't part of the costed estimate. let rows = rowsIn; const totalIdx = rowsIn.findIndex(row => row.some(c => /^total$/i.test(String(c ?? '').trim()))); if (totalIdx !== -1) rows = rowsIn.slice(0, totalIdx + 1); const tasks = []; rows.forEach(row => { const cells = row.map(v => String(v ?? '').trim()); if (!cells.some(Boolean)) return; // Only treat as a header row if at least 2 cells EXACTLY match a known header label — // avoids false positives where a real item description merely contains a word like "rate". const headerMatches = cells.filter(c => BOQ_HEADER_WORDS.has(c.toLowerCase())).length; if (headerMatches >= 2) return; const firstCell = cells.find(c => c !== ''); const slNo = firstCell !== undefined ? parseFloat(firstCell) : NaN; const looksLikeItemRow = firstCell !== undefined && ( (/^\d+(\.\d+)?$/.test(firstCell) && slNo > 0 && slNo < 500) || /^[a-z]$/i.test(firstCell) // lettered sub-items like 'a', 'b' ); // Exclude unit/PER boilerplate cells like "1 No (One Number)" / "1 metre (One metre)" from title candidates const textCells = cells.filter(v => /[a-zA-Z]/.test(v) && v.length > 3 && !/\(one /i.test(v)); if (!textCells.length) return; const title = textCells.sort((a, b) => b.length - a.length)[0].replace(/\s+/g, ' ').slice(0, 140); if (BOQ_SKIP_TITLE.test(title)) return; // Amount = largest purely-numeric cell (ignoring the serial-number column and any text cells) — // far more reliable than "last number in the row", which breaks on sheets with a trailing // duplicate-quantity column or numbers embedded inside the description prose. const numericVals = cells.slice(1).filter(isPureNumericCell).map(Number).filter(n => n > 0); const amount = numericVals.length ? Math.round(Math.max(...numericVals)) : 0; if (!looksLikeItemRow && !amount) return; if (!amount && title.length < 8) return; tasks.push({ title: `${source}: ${title}`, budget: amount, kind, sheetName }); }); const seen = new Set(); return tasks.filter(t => { const key = `${t.title}|${t.budget}`; if (seen.has(key)) return false; seen.add(key); return true; }).slice(0, 120); } function extractBoqTasksFromRows(rows, sheetName) { return extractTaskRowsFromSheet(rows, sheetName, /abstract|boq/i.test(sheetName) ? 'abstract' : 'detail'); } function importBoqTasksIntoProject(project, boqTasks) { project.tasks = Array.isArray(project.tasks) ? project.tasks : []; boqTasks.forEach((item, i) => { project.tasks.push({ id: Date.now() + i + Math.random(), title: item.title, done: false, subtasks: [], created: new Date().toLocaleDateString(), assignee: '', due: '', budget: Number(item.budget) || 0, spent: 0, progress: 0 }); }); } function cleanupDetailSheetTasks(projectId) { const p = data.projects.find(x => x.id === projectId); if (!p) return; const before = (p.tasks || []).length; p.tasks = (p.tasks || []).filter(t => !/^detailed:/i.test(t.title || '')); const removed = before - p.tasks.length; if (removed > 0) { saveData(); renderAll(); } } function importProjectTaskExcel(event, projectId) { const file = event.target.files[0]; if (!file) return; const msgEl = document.getElementById('projectTaskImportMsg'); const p = data.projects.find(x => x.id === projectId); if (!p) return; if (typeof XLSX === 'undefined') { if (msgEl) msgEl.innerHTML = `Excel reader didn't load — check your internet connection and reload the page.`; event.target.value = ''; return; } if (msgEl) msgEl.innerHTML = `Reading ${escHtml(file.name)}…`; const reader = new FileReader(); reader.onload = e => { try { const wb = XLSX.read(new Uint8Array(e.target.result), { type:'array' }); const parsed = parseProjectWorkbook(wb); const items = [...(parsed.abstractTasks || [])]; const before = (p.tasks || []).length; const existing = new Set((p.tasks || []).map(t => `${String(t.title || '').toLowerCase()}|${Number(t.budget) || 0}`)); const fresh = items.filter(item => !existing.has(`${String(item.title || '').toLowerCase()}|${Number(item.budget) || 0}`)); importBoqTasksIntoProject(p, fresh); if (!Number(p.estimate) && parsed.abstractTasks?.length) { p.estimate = parsed.abstractTasks.reduce((sum, t) => sum + (Number(t.budget) || 0), 0); } normalizeProjects(); saveData(); renderAll(); const added = (p.tasks || []).length - before; const nextMsg = document.getElementById('projectTaskImportMsg'); if (nextMsg) nextMsg.innerHTML = added ? `Imported ${added} project task${added===1?'':'s'} from Details / Abstract sheets.` : `No new project tasks found in Details / Abstract sheets.`; } catch (err) { if (msgEl) msgEl.innerHTML = `Could not import ${escHtml(file.name)}: ${escHtml(err.message)}`; } }; reader.onerror = () => { if (msgEl) msgEl.innerHTML = `Could not read the file.`; }; reader.readAsArrayBuffer(file); event.target.value = ''; } function parseProjectImportText(text) { const lines = String(text || '').split(/\r?\n/).map(l => l.trim()).filter(Boolean); const fullText = lines.join('\n'); const result = { name:'', client:'', estimate:0, category:'' }; const orgLine = lines.find(l => /corporation|municipal|panchayat|twad|pwd|government of|department of|board\b/i.test(l)); if (orgLine) result.client = orgLine.replace(/\s{2,}/g, ' ').trim().slice(0, 120); let m = fullText.match(/name\s+of\s+work[^:]*:\s*([^\n]+)/i); if (!m) m = fullText.match(/name\s+of\s+(the\s+)?project\s*[:\-]?\s*([^\n]+)/i); if (m) result.name = (m[2] || m[1] || '').trim(); m = fullText.match(/estimate\s+amount\s*:?\s*([\d.,]+)\s*(lakh|lakhs|crore|crores)?/i); if (m) { let amt = parseFloat(m[1].replace(/,/g, '')); if (/lakh/i.test(m[2] || '')) amt *= 100000; if (/crore/i.test(m[2] || '')) amt *= 10000000; result.estimate = Math.round(amt); } if (!result.estimate) { m = fullText.match(/estimate\s+amount[^\d]*([\d,]{4,})/i); if (m) result.estimate = parseInt(m[1].replace(/,/g, ''), 10); } if (!result.estimate) { m = fullText.match(/total[^\d\n]{0,20}([\d,]{5,})/i); if (m) result.estimate = parseInt(m[1].replace(/,/g, ''), 10); } if (/water\s*supply|municipal|panchayat|twad|pwd|government|govt|corporation/i.test(fullText)) { result.category = 'government'; } return result; } function applyProjectImport(text, fileName) { const parsed = parseProjectImportText(text); if (!parsed.estimate && pendingProjectImport?.boqTasks?.length) { parsed.estimate = pendingProjectImport.boqTasks.reduce((sum, t) => sum + (Number(t.budget) || 0), 0); } const nameEl = document.getElementById('projName'); const clientEl = document.getElementById('projClient'); const estEl = document.getElementById('projEstimate'); const catEl = document.getElementById('projCategory'); if (nameEl && parsed.name) nameEl.value = parsed.name; if (clientEl && parsed.client) clientEl.value = parsed.client; if (estEl && parsed.estimate) estEl.value = parsed.estimate; if (catEl && parsed.category) catEl.value = parsed.category; const msgEl = document.getElementById('projImportMsg'); if (!msgEl) return; const found = []; if (parsed.name) found.push('name'); if (parsed.client) found.push('client/department'); if (parsed.estimate) found.push('estimate ₹' + parsed.estimate.toLocaleString('en-IN')); if (parsed.category) found.push('category'); if (pendingProjectImport?.boqTasks?.length) found.push(`${pendingProjectImport.boqTasks.length} BOQ tasks`); msgEl.innerHTML = found.length ? `Imported from ${escHtml(fileName)} — found ${found.join(', ')}. Review the fields above, then click Add.` : `Could not auto-detect details from ${escHtml(fileName)}. Please fill the form in manually.`; } function addProjectTaskFromFields() { const latest = [...data.projects].reverse().find(p => p.status !== 'completed') || data.projects[data.projects.length - 1]; if (!latest) return; const taskEl = document.getElementById('projTaskInput'); const subEl = document.getElementById('projSubtaskInput'); addProjectTask(latest.id, taskEl.value, subEl.value); taskEl.value = ''; subEl.value = ''; } function addProjectTask(projectId, title, subtaskText='') { const p = data.projects.find(x=>x.id===projectId); const cleanTitle = String(title || '').trim(); if (!p || !cleanTitle) return; p.tasks = Array.isArray(p.tasks) ? p.tasks : []; const subtasks = String(subtaskText || '') .split(',') .map(x => x.trim()) .filter(Boolean) .map(title => ({ id:Date.now()+Math.random(), title, done:false })); p.tasks.push({ id:Date.now(), title:cleanTitle, done:false, subtasks, created:new Date().toLocaleDateString(), assignee:'', due:'', budget:0, spent:0, progress:0 }); saveData(); renderAll(); } function addProjectTaskFull(projectId) { const p = data.projects.find(x=>x.id===projectId); if (!p) return; const nameEl = document.getElementById('ptName'); const name = nameEl ? nameEl.value.trim() : ''; if (!name) return; const assignee = document.getElementById('ptAssignee')?.value.trim() || ''; const due = document.getElementById('ptDue')?.value || ''; const budget = Number(document.getElementById('ptBudget')?.value) || 0; const progress = Math.max(0, Math.min(100, Number(document.getElementById('ptProgress')?.value) || 0)); p.tasks = Array.isArray(p.tasks) ? p.tasks : []; p.tasks.push({ id:Date.now(), title:name, done:progress>=100, subtasks:[], created:new Date().toLocaleDateString(), assignee, due, budget, spent:0, progress }); ['ptName','ptAssignee','ptDue','ptBudget','ptProgress'].forEach(id => { const el=document.getElementById(id); if (el) el.value=''; }); saveData(); renderAll(); } // ----- Select BOQ/project tasks via checkbox to bundle into a Purchase Order ----- let selectedProjectTasks = new Set(); function toggleProjectTaskSelection(key, checked) { if (checked) selectedProjectTasks.add(key); else selectedProjectTasks.delete(key); // Bridge: ticking a BOQ/task item also adds it to "Materials to Order" const [pidStr, tidStr] = String(key).split(':'); const pid = Number(pidStr), tid = Number(tidStr); const p = data.projects.find(x => x.id === pid); if (!p) return; const t = (p.tasks || []).find(x => x.id === tid); if (!t) return; p.materials = Array.isArray(p.materials) ? p.materials : []; const matName = (t.title || '').replace(/^(BOQ|Detailed):\s*/i, '').trim(); if (checked) { // Avoid duplicates: only add if no material is already linked to this task if (!p.materials.some(m => m.fromTaskId === tid)) { p.materials.push({ id: Date.now() + Math.floor(Math.random()*1000), name: matName, qty: 1, unit: '', vendorName: '', estRate: Number(t.budget) || 0, status: 'to-order', dateNeeded: t.due || '', fromTaskId: tid }); } } else { // Untick removes the auto-linked material (only the one we added, still 'to-order') p.materials = p.materials.filter(m => !(m.fromTaskId === tid && m.status === 'to-order')); } saveData(); renderProjects(); } function toggleSelectAllProjectTasks(projectId, checked) { const p = data.projects.find(x => x.id === projectId); if (!p) return; (p.tasks || []).forEach(t => { const key = `${projectId}:${t.id}`; if (checked) selectedProjectTasks.add(key); else selectedProjectTasks.delete(key); }); renderAll(); } function createPOFromSelectedTasks(projectId) { const p = data.projects.find(x => x.id === projectId); if (!p) return; const selectedIds = (p.tasks || []).filter(t => selectedProjectTasks.has(`${projectId}:${t.id}`)).map(t => t.id); if (!selectedIds.length) { alert('Tick at least one task/BOQ item to create a Purchase Order.'); return; } const selectedTasks = (p.tasks || []).filter(t => selectedIds.includes(t.id)); const items = selectedTasks.map(t => ({ desc: t.title.replace(/^(BOQ|Detailed):\s*/i, ''), qty: 1, unit: '', rate: t.budget || 0 })); pendingPOMaterialIds = null; // this PO is sourced from tasks, not the materials list pendingPOTaskIds = { projectId, taskIds: selectedIds }; openDocBuilder('po', items, projectId); selectedProjectTasks.clear(); } function openProjectDetail(id) { activeProjectId = id; renderProjects(); } function closeProjectDetail() { activeProjectId = null; renderProjects(); } function setProjStatus(id, st) { const p = data.projects.find(x=>x.id===id); if (p) { p.status = st; saveData(); renderAll(); } } function delProject(id) { data.projects = data.projects.filter(x=>x.id!==id); saveData(); renderAll(); } function toggleProjectTask(projectId, taskId) { const t = data.projects.find(p=>p.id===projectId)?.tasks?.find(x=>x.id===taskId); if (t) { t.done = !t.done; saveData(); renderAll(); } } function toggleProjectSubtask(projectId, taskId, subtaskId) { const s = data.projects.find(p=>p.id===projectId)?.tasks?.find(t=>t.id===taskId)?.subtasks?.find(x=>x.id===subtaskId); if (s) { s.done = !s.done; saveData(); renderAll(); } } function delProjectTask(projectId, taskId) { const p = data.projects.find(x=>x.id===projectId); if (p) { p.tasks = (p.tasks || []).filter(t=>t.id!==taskId); saveData(); renderAll(); } } function editProjectTask(projectId, taskId) { const task = data.projects.find(p=>p.id===projectId)?.tasks?.find(t=>t.id===taskId); if (!task) return; openGenModal('Edit Project Task', `
`, () => { const title = document.getElementById('genProjectTask').value.trim(); const lines = document.getElementById('genProjectSubtasks').value.split(/\n|,/).map(x=>x.trim()).filter(Boolean); if (!title) return; const existing = task.subtasks || []; task.title = title; task.assignee = document.getElementById('genProjectAssignee').value.trim(); task.due = document.getElementById('genProjectDue').value; task.budget = Number(document.getElementById('genProjectBudget').value) || 0; task.spent = Number(document.getElementById('genProjectSpent').value) || 0; task.progress = Math.max(0, Math.min(100, Number(document.getElementById('genProjectProgress').value) || 0)); task.done = task.progress >= 100; task.subtasks = lines.map(line => { const old = existing.find(s=>s.title===line); return old ? old : { id:Date.now()+Math.random(), title:line, done:false }; }); saveData(); renderAll(); closeGenModal(); }); } function editProjectSubtask(projectId, taskId, subtaskId) { const subtask = data.projects.find(p=>p.id===projectId)?.tasks?.find(t=>t.id===taskId)?.subtasks?.find(s=>s.id===subtaskId); if (!subtask) return; openGenModal('Edit Subtask', `
`, () => { const title = document.getElementById('genProjectSubtask').value.trim(); if (title) { subtask.title = title; saveData(); renderAll(); closeGenModal(); } }); } function delProjectSubtask(projectId, taskId, subtaskId) { const task = data.projects.find(p=>p.id===projectId)?.tasks?.find(t=>t.id===taskId); if (task) { task.subtasks = (task.subtasks || []).filter(s=>s.id!==subtaskId); saveData(); renderAll(); } } function projectStats(p) { const tasks = Array.isArray(p.tasks) ? p.tasks : []; const doneTasks = tasks.filter(t=>t.done).length; const subtasks = tasks.flatMap(t=>Array.isArray(t.subtasks) ? t.subtasks : []); const doneSubtasks = subtasks.filter(s=>s.done).length; return { tasks:tasks.length, doneTasks, subtasks:subtasks.length, doneSubtasks }; } function renderCompanyFilter() { const el = document.getElementById('companyFilter'); if (!el) return; const companies = Array.from(new Set((data.projects || []).map(p => p.company || 'Unassigned'))).sort(); if (!companies.includes(currentCompanyFilter) && currentCompanyFilter !== 'all') currentCompanyFilter = 'all'; const chips = ['all', ...companies]; el.innerHTML = chips.map(c => ``).join(''); const list = document.getElementById('companyList'); if (list) list.innerHTML = companies.map(c => ``).join(''); } function setCompanyFilter(c) { currentCompanyFilter = c; renderAll(); } function setProjSearch(q) { currentProjSearchQuery = q.toLowerCase().trim(); renderAll(); } function setZoneFilter(v) { currentZoneFilter = v; renderAll(); } function setProjCatFilter(v) { currentProjCatFilter=v; currentProjSubCatFilter='all'; currentProjSubSubCatFilter='all'; renderProjSubCatFilter(); renderAll(); } function setProjSubCatFilter(v) { currentProjSubCatFilter=v; currentProjSubSubCatFilter='all'; renderProjSubCatFilter(); renderAll(); } function setProjSubSubCatFilter(v) { currentProjSubSubCatFilter=v; renderAll(); } function setProjStatusFilter(v) { currentProjStatusFilter=v; const el=document.getElementById('projStatusFilter'); if(el&&el.value!==v) el.value=v; renderAll(); } function updateProjSubCatOptions() { const cat = document.getElementById('projCategory')?.value; const sub = document.getElementById('projSubCategory'); if (!sub) return; const opts = cat === 'government' ? [['civil_work','🚧 Civil Work'],['water_supply','💧 Water Supply / Maintenance'],['building_maintenance','🏗 Building Maintenance'],['motor_repair','⚙️ Motor Repair'],['custom','+ Custom...']] : [['civil_work','🚧 Civil Work'],['building_maintenance','🏗 Building Maintenance'],['interior','🚪 Interior'],['renovation','🔨 Renovation'],['water_supply','💧 Water Supply'],['motor_repair','⚙️ Motor Repair'],['custom','+ Custom...']]; sub.innerHTML = opts.map(([v,l]) => ``).join(''); const cust = document.getElementById('projSubCatCustom'); if (cust) cust.style.display = 'none'; sub.onchange = function() { if (cust) cust.style.display = this.value === 'custom' ? '' : 'none'; }; } function renderProjSubCatFilter() { const sectorSel = document.getElementById("projCatFilter"); if (sectorSel) { const sectors = [...new Set((data.projects||[]).map(p=>p.category||"").filter(Boolean))].sort(); sectorSel.innerHTML = "" + sectors.map(s=>``).join(""); if (!sectors.includes(currentProjCatFilter)) currentProjCatFilter = "all"; } const catSel = document.getElementById("projSubCatFilter"); if (catSel) { const cats = [...new Set((data.projects||[]).filter(p=>currentProjCatFilter==="all"||p.category===currentProjCatFilter).map(p=>p.subCategory||"").filter(Boolean))].sort(); catSel.innerHTML = "" + cats.map(c=>``).join(""); if (!cats.includes(currentProjSubCatFilter)) currentProjSubCatFilter = "all"; } const subSel = document.getElementById("projSubSubCatFilter"); if (subSel) { const subs = [...new Set((data.projects||[]).filter(p=>(currentProjCatFilter==="all"||p.category===currentProjCatFilter)&&(currentProjSubCatFilter==="all"||p.subCategory===currentProjSubCatFilter)).map(p=>p.subSubCategory||"").filter(Boolean))].sort(); subSel.innerHTML = "" + subs.map(s=>``).join(""); if (!subs.includes(currentProjSubSubCatFilter)) currentProjSubSubCatFilter = "all"; } } let expandedProjects = new Set(); function toggleProjectExpand(id) { if (expandedProjects.has(id)) expandedProjects.delete(id); else expandedProjects.add(id); renderProjects(); } // Project task column visibility function getProjColVis() { try{return JSON.parse(localStorage.getItem('mydas_proj_cols')||'{}')}catch(e){return{}} } function setProjColVis(col,vis){const s=getProjColVis();s[col]=vis;localStorage.setItem('mydas_proj_cols',JSON.stringify(s));renderProjects();} function isColVisible(col){return getProjColVis()[col]!==false;} function renderProjects() { renderCompanyFilter(); renderProjSubCatFilter(); const el = document.getElementById('projList'); if (!el) return; const allProjects = data.projects || []; let filtered = currentCompanyFilter === 'all' ? allProjects : allProjects.filter(p => (p.company || 'Unassigned') === currentCompanyFilter); if (currentZoneFilter !== 'all') filtered = filtered.filter(p => (p.zone||'') === currentZoneFilter); if (currentProjCatFilter !== 'all') filtered = filtered.filter(p => (p.category||'government') === currentProjCatFilter); if (currentProjSubCatFilter !== 'all') filtered = filtered.filter(p => (p.subCategory||'') === currentProjSubCatFilter); if (currentProjSubSubCatFilter !== 'all') filtered = filtered.filter(p => (p.subSubCategory||'') === currentProjSubSubCatFilter); if (currentProjStatusFilter !== 'all') filtered = filtered.filter(p => { const s = p.status||'planning'; if (currentProjStatusFilter==='pending') return s!=='completed'&&s!=='in-progress'; return s===currentProjStatusFilter; }); // Rebuild zone filter dropdown const zoneSel = document.getElementById('projZoneFilter'); if (zoneSel) { const zones = Array.from(new Set(allProjects.map(p=>p.zone||'').filter(Boolean))).sort(); const prevZ = zoneSel.value; zoneSel.innerHTML = '' + zones.map(z=>``).join(''); if (zones.includes(prevZ)) zoneSel.value = prevZ; } // Rebuild projCatFilter active const catF = document.getElementById('projCatFilter'); if (catF) catF.value = currentProjCatFilter; renderProjSubCatFilter(); // Apply search query if (currentProjSearchQuery) { filtered = filtered.filter(p => projNick(p).toLowerCase().includes(currentProjSearchQuery) || (p.zone||'').toLowerCase().includes(currentProjSearchQuery) || (p.client||'').toLowerCase().includes(currentProjSearchQuery) || (p.company||'').toLowerCase().includes(currentProjSearchQuery) || (p.subCategory||'').toLowerCase().includes(currentProjSearchQuery) ); } // Sync search input value const si = document.getElementById('projSearchInput'); if (si && si.value !== currentProjSearchQuery) si.value = currentProjSearchQuery; const _sort = window._projSort || 'workorder'; const _blank = '9999-99-99'; filtered = [...filtered].sort((a,b)=>{ if (_sort==='workorder') return (a.workOrderDate||_blank).localeCompare(b.workOrderDate||_blank); if (_sort==='agreement') return (a.agreementDate||_blank).localeCompare(b.agreementDate||_blank); if (_sort==='name') return projNick(a).localeCompare(projNick(b)); if (_sort==='status') { const o={'planning':0,'in-progress':1,'completed':2}; return (o[a.status]||0)-(o[b.status]||0); } if (_sort==='estimate') return (Number(b.estimate)||0)-(Number(a.estimate)||0); return (b.id||0)-(a.id||0); }); const ss=document.getElementById('projSortSelect'); if(ss&&ss.value!==_sort) ss.value=_sort; const sf=document.getElementById('projStatusFilter'); if(sf&&sf.value!==currentProjStatusFilter) sf.value=currentProjStatusFilter; refreshProjCatLists(); const vg=document.getElementById('projViewGrid'),vl=document.getElementById('projViewList'),_v=window._projView||'grid'; if(vg){vg.style.background=_v==='grid'?'var(--violet)':'transparent';vg.style.color=_v==='grid'?'#fff':'var(--text-muted)';} if(vl){vl.style.background=_v==='list'?'var(--violet)':'transparent';vl.style.color=_v==='list'?'#fff':'var(--text-muted)';} if (activeProjectId && filtered.some(p => p.id === activeProjectId)) { renderProjectDetail(el, filtered.find(p => p.id === activeProjectId)); } else { if (activeProjectId && !filtered.some(p => p.id === activeProjectId)) activeProjectId = null; renderProjectGrid(el, filtered); } // KPI grid (always reflects the company-filtered set) const totalBudget = filtered.reduce((s, p) => s + (Number(p.estimate) || 0), 0); // Budget ex-GST: reverse out GST component from each project's estimate const totalBudgetExGst = filtered.reduce((s, p) => { const est = Number(p.estimate) || 0; const gstRate = Number(p.gstRate ?? 18); // default 18% if not set const exGst = gstRate > 0 ? Math.round(est / (1 + gstRate/100) * 100) / 100 : est; return s + exGst; }, 0); const gstComponent = totalBudget - totalBudgetExGst; const totalMoneyNeeded = filtered.reduce((s, p) => s + computeProjectForecast(p).moneyNeeded, 0); const netBalance = totalBudgetExGst - totalMoneyNeeded; const overBudgetCount = filtered.filter(p => computeProjectForecast(p).overBudget).length; const activeCount = filtered.filter(p => p.status === 'in-progress').length; const set = (id, v) => { const e = document.getElementById(id); if (e) e.textContent = v; }; set('projTotal', filtered.length); set('projActiveMeta', `${activeCount} in progress`); set('projBudgetKpi', fmtINR(totalBudget)); set('projBudgetMeta', `All projects · GST: ${fmtINR(gstComponent)}`); set('projBudgetExGst', fmtINR(totalBudgetExGst)); set('projBudgetExGstMeta', `Excl. GST component (${fmtINR(gstComponent)})`); set('projMoneyNeeded', fmtINR(totalMoneyNeeded)); set('projOverBudget', overBudgetCount); set('projNetBalance', fmtINR(Math.abs(netBalance))); set('projNetBalanceMeta', netBalance >= 0 ? `▲ Surplus ex-GST after completion` : `▼ Shortfall — needs ${fmtINR(Math.abs(netBalance))} more`); // Upcoming from leads const leads = Array.isArray(data.leads) ? data.leads : []; const activePipelineLeads = leads.filter(l => !['won','lost'].includes(l.status)); const wonLeads = leads.filter(l => l.status === 'won'); const pipelineEst = activePipelineLeads.reduce((s,l)=>s+Number(l.estimate||0),0); const wonEst = wonLeads.reduce((s,l)=>s+Number(l.estimate||0),0); set('projUpcomingCount', activePipelineLeads.length); set('projUpcomingMeta', `${wonLeads.length} won · Pipeline: ${fmtINR(pipelineEst)}`); // Show won cost inside the card const wonCostEl = document.getElementById('projWonLeadsCost'); if (wonCostEl) { wonCostEl.textContent = wonEst > 0 ? `Won: ${fmtINR(wonEst)}` : ''; wonCostEl.style.display = wonEst > 0 ? '' : 'none'; } // Gross project cost = existing project budgets (ex-GST) + won leads + pipeline leads const grossActive = totalBudgetExGst; const grossWon = wonEst; const grossPipeline = pipelineEst; const grossTotal = grossActive + grossWon + grossPipeline; const grossEl = document.getElementById('projGrossCost'); if (grossEl) { grossEl.textContent = fmtINR(grossTotal); } const breakdownEl = document.getElementById('projGrossCostBreakdown'); if (breakdownEl) breakdownEl.innerHTML = `Active projects (ex.GST): ${fmtINR(grossActive)}
` + `Won leads: ${fmtINR(grossWon)}
` + `Pipeline leads: ${fmtINR(grossPipeline)}`; const upcomingCard = document.getElementById('projUpcomingCard'); if (upcomingCard) { upcomingCard.style.borderLeftColor = activePipelineLeads.length > 0 ? 'var(--violet)' : 'var(--border)'; const valEl = document.getElementById('projUpcomingCount'); if (valEl) valEl.style.color = activePipelineLeads.length > 0 ? 'var(--violet)' : 'var(--text-muted)'; } // Colour the net balance card const netCard = document.getElementById('projNetBalanceCard'); if (netCard) { netCard.style.borderLeftColor = netBalance >= 0 ? 'var(--accent)' : 'var(--rose)'; const valEl = document.getElementById('projNetBalance'); if (valEl) valEl.style.color = netBalance >= 0 ? 'var(--accent)' : 'var(--rose)'; } const overCard = document.getElementById('projOverBudgetCard'); if (overCard) overCard.className = 'kpi-card' + (overBudgetCount > 0 ? ' rose' : ''); } // ----- Project grid (card overview, ConstFi style) ----- function getProjListCols(){try{return JSON.parse(localStorage.getItem("mydas_proj_list_cols")||"{}")}catch(e){return{}}} function setProjListCol(col,vis){const s=getProjListCols();s[col]=vis;localStorage.setItem("mydas_proj_list_cols",JSON.stringify(s));renderProjects();} function isProjColOn(col){return getProjListCols()[col]!==false;} // Project field auto-suggest function getProjFieldValues(field) { return [...new Set((data.projects||[]).map(p=>p[field]||'').filter(Boolean))].sort(); } function fillDatalist(id, values) { const dl = document.getElementById(id); if (!dl) return; dl.innerHTML = values.map(v=>``).join(''); } function refreshProjCatLists() { fillDatalist('projSectorList', getProjFieldValues('category')); fillDatalist('projCategoryList', getProjFieldValues('subCategory')); fillDatalist('projSubCategoryList',getProjFieldValues('subSubCategory')); fillDatalist('editSectorList', getProjFieldValues('category')); fillDatalist('editCategoryList', getProjFieldValues('subCategory')); fillDatalist('editSubCatList', getProjFieldValues('subSubCategory')); fillDatalist('zoneList', getProjFieldValues('zone')); fillDatalist('clientList', getProjFieldValues('client')); fillDatalist('companyList', getProjFieldValues('company')); } function refreshProjSubCatList() { fillDatalist('projSubCategoryList',getProjFieldValues('subSubCategory')); fillDatalist('editSubCatList', getProjFieldValues('subSubCategory')); } function renderProjectGrid(el, filtered) { const pillClass = { planning:'pill-amber', 'in-progress':'pill-sky', completed:'pill-green' }; const view = window._projView || 'grid'; // grid | list if (!filtered.length) { el.innerHTML = '
No projects for this filter yet
'; return; } if (view === 'list') { // ── LIST VIEW ────────────────────────────────────────── el.innerHTML = `
Columns: ${['client','zone','category','wodate','agmtdate','estno','fileno','estimate','status','health'].map(col=>{ const vis=isProjColOn(col); const lbl={client:'Client',zone:'Zone',category:'Category',wodate:'WO Date',agmtdate:'Agmt Date',estno:'Est#',fileno:'File#',estimate:'Estimate',status:'Status',health:'Health'}[col]; return ``; }).join('')}
${isProjColOn('client')?'':''} ${isProjColOn('zone')?'':''} ${isProjColOn('category')?'':''} ${isProjColOn('wodate')?'':''} ${isProjColOn('agmtdate')?'':''} ${isProjColOn('estno')?'':''} ${isProjColOn('fileno')?'':''} ${isProjColOn('estimate')?'':''} ${isProjColOn('status')?'':''} ${isProjColOn('health')?'':''} ${filtered.map(p => { const fc = computeProjectForecast(p); const st = projectStats(p); const statusColor = { planning:'var(--amber)', 'in-progress':'var(--sky)', completed:'var(--accent)' }[p.status] || 'var(--text-muted)'; const health = fc.overBudget ? 'Over Budget' : fc.forecastOverrun > 0 ? 'Risk' : 'On Track'; return ` ${isProjColOn('client')?``:''} ${isProjColOn('zone')?``:''} ${isProjColOn('category')?``:''} ${isProjColOn('wodate')?``:''} ${isProjColOn('agmtdate')?``:''} ${isProjColOn('estno')?``:''} ${isProjColOn('fileno')?``:''} ${isProjColOn('estimate')?``:''} ${isProjColOn('status')?``:''} ${isProjColOn('health')?``:''} `; }).join('')}
ProjectClientZoneCategoryWO DateAgmt DateEst#File#EstimateStatusHealth
${escHtml(projNick(p))}
${p.name!==projNick(p)?`
${escHtml(p.name)}
`:''}
${escHtml(p.client||'—')}${escHtml(p.zone||'—')}${escHtml(p.subCategory||p.category||'—')}${p.workOrderDate||'—'}${p.agreementDate||'—'}${escHtml(p.estimateNo||'—')}${escHtml(p.fileNo||'—')}${p.estimate?fmtINR(p.estimate):'—'}${p.status||'planning'}${health}
`; } else { // ── GRID VIEW ────────────────────────────────────────── el.innerHTML = `
` + filtered.map(p => { const fc = computeProjectForecast(p); const category = p.category || 'other'; const st = projectStats(p); const statusBadge = fc.overBudget ? `Over Budget` : fc.forecastOverrun > 0 ? `Risk` : `On Track`; const daysLeftTxt = fc.daysLeft != null ? `${fc.daysLeft} days left` : 'No end date set'; return `
${escHtml(projNick(p))}
${escHtml(p.client||'')}${p.zone?' · '+escHtml(p.zone):''}
${p.category?`${escHtml(p.category)}`:''} ${p.subCategory?`${escHtml(p.subCategory)}`:''} ${p.subSubCategory?`${escHtml(p.subSubCategory)}`:''}
${p.status||'planning'} ${statusBadge}
${p.workOrderDate?`📅 WO: ${p.workOrderDate}`:''} ${p.agreementDate?`📋 Agmt: ${p.agreementDate}`:''} ${p.estimateNo?`Est#: ${escHtml(p.estimateNo)}`:''} ${p.fileNo?`File: ${escHtml(p.fileNo)}`:''} ${p.woPdfData?`📄 WO PDF`:''} ${!p.workOrderDate&&!p.agreementDate&&!p.estimateNo&&!p.fileNo?`No dates set`:''}
Budget${p.gstRate&&Number(p.gstRate)>0?' (incl. GST '+p.gstRate+'%)':''}
${p.estimate?fmtINR(p.estimate):'—'}
${p.estimate&&Number(p.gstRate)>0?`
ex.GST: ${fmtINR(Math.round(p.estimate/(1+Number(p.gstRate)/100)))}
`:''}
Spent
${fmtINR(fc.totalSpent||0)}
To Complete
${p.estimate?fmtINR(Math.max(0,p.estimate-(fc.totalSpent||0))):'—'}
Progress · ${st.doneTasks}/${st.tasks} tasks done ${st.tasks>0?Math.round(st.doneTasks/st.tasks*100):0}%
Budget used ${p.estimate>0?Math.min(Math.round((fc.totalSpent||0)/p.estimate*100),999):0}%
⏱ ${daysLeftTxt} ${escHtml(p.category||'—')} ${escHtml(projNick(p))}
`; }).join('') + `
`; } } function renderProjectDetail(el, p) { const fc = computeProjectForecast(p); const tStatusPill = s => s==='done' ? 'pill-green' : s==='active' ? 'pill-sky' : 'pill-amber'; const tStatusLabel = s => s==='done' ? 'Done' : s==='active' ? 'In Progress' : 'Pending'; const taskStatusOf = t => t.progress >= 100 ? 'done' : t.progress > 0 ? 'active' : 'pending'; const alertBanner = fc.overBudget ? `
⚠ Budget Exceeded by ${fmtINR(fc.spent-fc.budget)}
Spent ${fmtINR(fc.spent)} against allocated ${fmtINR(fc.budget)}. Immediate review required.
` : fc.forecastOverrun > 0 ? `
⚠ Forecast Overrun Risk: ${fmtINR(fc.forecastOverrun)}
At current burn rate, projected completion cost is ${fmtINR(fc.forecastTotal)} vs budget ${fmtINR(fc.budget)}.
` : `
✓ Within Budget
Forecast completion cost ${fmtINR(fc.forecastTotal)} — ${fmtINR(fc.budget-fc.forecastTotal)} under budget.
`; const taskBudgetTotal = (p.tasks||[]).reduce((s,t)=>s+(Number(t.budget)||0),0); const taskSpentTotal = (p.tasks||[]).reduce((s,t)=>s+(Number(t.spent)||0),0); el.innerHTML = `
${escHtml(p.name)}
${fc.overBudget?'Over Budget':fc.progress>=100?'Complete':'On Track'}
${alertBanner}
Allocated Budget
${fmtINR(fc.budget)}
Total project
Spent
${fmtINR(fc.spent)}
${fc.budget?Math.round(fc.spent/fc.budget*100):0}% of budget
Remaining
${fmtINR(fc.budget-fc.spent)}
Available
Money to Complete
${fmtINR(fc.moneyNeeded)}
${fc.moneyNeededIsManual ? 'Manually entered' : 'Forecast need'}
Budget vs Actual Tracking
Budget consumed${fc.budget?Math.round(fc.spent/fc.budget*100):0}%
Work progress${fc.progress}%
COST PERF. INDEX
${fc.cpi.toFixed(2)}
${fc.cpi>=1?'Efficient':'Over-spending'}
DAYS REMAINING
${fc.daysLeft==null?'—':fc.daysLeft}
${p.endDate?'Until '+p.endDate:'No end date set'}
Completion Forecast
Spent so far (${fc.progress}%)${fmtINR(fc.spent)}
Burn rate per 1%${fmtINR(Math.round(fc.burnRate))}
${fc.moneyNeededIsManual ? 'Money needed (manual)' : 'Money needed to finish'}${fmtINR(fc.moneyNeeded)}
Forecast Total Cost ${fmtINR(fc.forecastTotal)}
${fc.forecastOverrun>0?`⚠ Projected ${fmtINR(fc.forecastOverrun)} over budget`:`✓ ${fmtINR(Math.abs(fc.forecastOverrun))} under budget`}
${fc.moneyNeededIsManual ? `
Burn-rate forecast (if no manual figure were set): ${fmtINR(fc.forecastMoneyNeeded)}
` : ''}
Tasks & Progress (${(p.tasks||[]).length})
${(p.tasks||[]).some(t => /^detailed:/i.test(t.title||'')) ? `` : ''}
Only Abstract/BOQ sheets become tasks (proper costed line items) — Detail/measurement sheets are skipped to avoid noise.
Columns: ${['assignee','due','budget','spent','variance','progress','status'].map(col=>{ const vis=isColVisible(col); const lbl={assignee:'Assignee',due:'Due',budget:'Budget',spent:'Spent',variance:'Variance',progress:'Progress',status:'Status'}[col]; return ``; }).join('')}
${isColVisible('assignee')?'':''} ${isColVisible('due')?'':''} ${isColVisible('budget')?'':''} ${isColVisible('spent')?'':''} ${isColVisible('variance')?'':''} ${isColVisible('progress')?'':''} ${isColVisible('status')?'':''} ${(p.tasks||[]).length ? p.tasks.map(t => { const variance = (Number(t.budget)||0) - (Number(t.spent)||0); const tOver = (Number(t.spent)||0) > (Number(t.budget)||0) && Number(t.budget) > 0; const status = taskStatusOf(t); const inDaily = (data.tasks||[]).some(dt => dt.sourceType==='project-task' && dt.projectId===p.id && dt.projectTaskId===t.id); const tKey = `${p.id}:${t.id}`; const tChecked = selectedProjectTasks.has(tKey) || (p.materials||[]).some(m => m.fromTaskId === t.id && m.status === 'to-order'); return ` ${isColVisible('assignee')?``:''} ${isColVisible('due')?``:''} ${isColVisible('budget')?``:''} ${isColVisible('spent')?``:''} ${isColVisible('variance')?``:''} ${isColVisible('progress')?``:''} ${isColVisible('status')?``:''} `; }).join('') : ``} ${(p.tasks||[]).length ? `` : ''}
TaskAssigneeDueBudgetSpentVarianceProgressStatus
${escHtml((t.title||"").replace(/^BOQ[:\s]*/i,"").trim())}${t.poNumber?` ${t.poNumber}`:''}${escHtml(t.assignee||'\u2014')}${t.due||'\u2014'}${fmtINR(t.budget||0)}${fmtINR(t.spent||0)}${variance>=0?'+':''}${fmtINR(variance)}
${t.progress||0}%
${tStatusLabel(status)}
No tasks yet — add one above
Totals ${fmtINR(taskBudgetTotal)} ${fmtINR(taskSpentTotal)} ${taskBudgetTotal-taskSpentTotal>=0?'+':''}${fmtINR(taskBudgetTotal-taskSpentTotal)}
Materials to Order (${(p.materials||[]).length})
${(p.materials||[]).length ? p.materials.map(m => ` `).join('') : ``}
MaterialQtyVendorEst. RateEst. TotalNeeded ByStatus
${escHtml(m.name)} ${m.qty||0}${m.unit?' '+escHtml(m.unit):''} ${escHtml(m.vendorName||'—')} ${fmtINR(m.estRate||0)} ${fmtINR((m.qty||0)*(m.estRate||0))} ${m.dateNeeded||'—'} ${m.status==='received'?'Received':m.status==='ordered'?'Ordered'+(m.poNumber?' ('+m.poNumber+')':''):'To Order'}
No materials listed — add one above, or tap the status pill to cycle To Order → Ordered → Received
Project P&L
${(() => { const revenue = (data.invoices || []).filter(d => d.projectId === p.id).reduce((s,d) => s + Number(d.total||0), 0); const costSpent = fc.spent; const grossProfit = revenue - costSpent; const marginPct = revenue ? Math.round(grossProfit / revenue * 100) : 0; return `
Revenue (Invoiced)
${fmtINR(revenue)}
From Sales invoices
Cost (Spent)
${fmtINR(costSpent)}
From Accounts ledger
Gross Profit
${fmtINR(grossProfit)}
Revenue − Cost
Margin
${marginPct}%
Of invoiced revenue

Revenue is the sum of Sales → Invoices raised against this project. Create one from Project Settings below to update this.

`; })()}
Design Documents (${(p.designs||[]).length})
${(p.designs||[]).length ? p.designs.map(d => ` `).join('') : ``}
DocumentRev.StatusDateBy
${escHtml(d.name)} ${escHtml(d.rev||'—')} ${({pending:'Pending Review',approved:'Approved',revision:'Revision Needed',rejected:'Rejected'})[d.status]||d.status} ${d.date||'—'} ${escHtml(d.by||'—')}
No design documents logged yet
Quality Inspections (${(p.qualityChecks||[]).length})
${(p.qualityChecks||[]).length ? p.qualityChecks.map(q => ` `).join('') : ``}
Area / ItemDateInspectorResultRemarks
${escHtml(q.area)} ${q.date||'—'} ${escHtml(q.inspector||'—')} ${q.result==='pass'?'Pass':q.result==='fail'?'Fail':'Pending'} ${escHtml(q.remarks||'—')}
No inspections logged yet
Daily Production Log (${(p.production||[]).length})
${(p.production||[]).length ? [...p.production].sort((a,b)=>(b.date||'').localeCompare(a.date||'')).map(pr => ` `).join('') : ``}
DateActivityQuantity
${pr.date||'—'} ${escHtml(pr.activity)} ${pr.qty||0}${pr.unit?' '+escHtml(pr.unit):''}
No production entries logged yet
Project Settings
📄 Work Order PDF: ${p.woPdfData ? ` ✓ ${escHtml(p.woPdfName||'uploaded')} ` : ` No PDF uploaded `}
`; } async function handleWOPdfFromDetail(input, projectId) { const file = input.files[0]; if (!file) return; input.value = ''; const pid = Number(projectId)||projectId; const p = (data.projects||[]).find(x=>x.id===pid||x.id===projectId); if (!p) { alert('Project not found'); return; } // Read as base64 const base64 = await new Promise((res,rej)=>{ const r = new FileReader(); r.onload = ()=>res(r.result.split(',')[1]); r.onerror = ()=>rej(new Error('Read failed')); r.readAsDataURL(file); }); // Save PDF p.woPdfData = base64; p.woPdfName = file.name; autoTickProcessUpToWorkOrder(p); await savePdfData(pid, base64); await saveData(); normalizeProjects(); renderAll(); // Show mini confirmation const msg = `\u2713 ${file.name} uploaded for ${projNick(p)}`; const toast = document.createElement('div'); toast.style.cssText = 'position:fixed;bottom:24px;right:24px;background:#22c55e;color:#fff;padding:10px 18px;border-radius:10px;font-size:13px;font-weight:700;z-index:99999;box-shadow:0 4px 16px rgba(0,0,0,0.2);'; toast.textContent = msg; document.body.appendChild(toast); setTimeout(()=>toast.remove(), 3000); } function editProjCatChange(cat) { const sc = document.getElementById('genSubCategory'); const cv = document.getElementById('genSubCatCustom'); if (!sc) return; const govOpts = [['civil_work','🚧 Civil Work'],['water_supply','💧 Water Supply / Maint.'],['building_maintenance','🏗 Building Maint.'],['motor_repair','⚙️ Motor Repair'],['custom','+ Custom...']]; const pvtOpts= [['civil_work','🚧 Civil Work'],['building_maintenance','🏗 Building Maint.'],['interior','🚪 Interior'],['renovation','🔨 Renovation'],['water_supply','💧 Water Supply'],['motor_repair','⚙️ Motor Repair'],['custom','+ Custom...']]; const opts = cat === 'private' ? pvtOpts : govOpts; sc.innerHTML = opts.map(([v,l]) => '').join(''); if (cv) cv.style.display = 'none'; sc.onchange = function() { if (cv) cv.style.display = this.value === 'custom' ? '' : 'none'; }; } function editProject(id) { const p = data.projects.find(x=>x.id===id); if (!p) return; openProjEdit(p); } // ===== PROCESS / DOCUMENTATION STATUS ===== function updateWIPPct(projectId, pct) { const p = data.projects.find(x => x.id === projectId); if (!p) return; p.docStatus = p.docStatus || {}; const wipPct = Number(pct); p.docStatus['WORK IN PROGRESS'] = { done: wipPct >= 100, date: wipPct >= 100 ? dayKey(new Date()) : (p.docStatus['WORK IN PROGRESS']?.date || null), pct: wipPct }; saveData(); // Live-update just the progress bar and label without full re-render const slider = document.querySelector(`input[oninput="updateWIPPct(${projectId}, this.value)"]`); if (slider) { const wipColor = wipPct >= 100 ? 'var(--accent)' : wipPct >= 50 ? 'var(--sky)' : 'var(--amber)'; slider.style.accentColor = wipColor; const chip = slider.closest('.doc-stage-chip'); if (chip) { const bar = chip.querySelector('div[style*="border-radius:4px;overflow:hidden"]>div'); if (bar) { bar.style.width = wipPct + '%'; bar.style.background = wipColor; } const pctLabel = chip.querySelector('span[style*="font-weight:800"]'); if (pctLabel) { pctLabel.textContent = wipPct + '%'; pctLabel.style.color = wipColor; } } } } function toggleDocStage(projectId, stage) { const p = data.projects.find(x=>x.id===projectId); if (!p) return; p.docStatus = (p.docStatus && typeof p.docStatus === 'object') ? p.docStatus : {}; const cur = p.docStatus[stage] || { done:false, date:null }; cur.done = !cur.done; cur.date = cur.done ? new Date().toLocaleDateString() : null; p.docStatus[stage] = cur; saveData(); renderAll(); } // ----- Process dashboard state ----- let processView = 'list'; let processSearch = ''; let processCompanyFilter = 'all'; let processStatusFilter = 'all'; let processSort = 'recent'; function setProcessView(v) { processView = v; document.querySelectorAll('#processViewSwitch .filter-btn').forEach(b => b.classList.toggle('active', b.dataset.view === v)); renderProcess(); } function setProcessSearch(v) { processSearch = v; renderProcess(); } function setProcessCompanyFilter(c) { processCompanyFilter = c; renderProcess(); } function setProcessStatusFilter(v) { processStatusFilter = v; renderProcess(); } function setProcessSort(v) { processSort = v; renderProcess(); } function projectDocPct(p) { const docStatus = p.docStatus || {}; const doneCount = DOC_STAGES.filter(s => docStatus[s]?.done).length; const pct = Math.round((doneCount / DOC_STAGES.length) * 100); return { doneCount, pct }; } function currentStageOf(p) { const docStatus = p.docStatus || {}; const idx = DOC_STAGES.findIndex(s => !docStatus[s]?.done); return idx; // -1 means all stages complete } function milestoneStats(p, milestone) { const docStatus = p.docStatus || {}; const done = milestone.stages.filter(s => docStatus[s]?.done).length; const total = milestone.stages.length; const pct = Math.round((done / total) * 100); return { done, total, pct }; } function getProcessFiltered() { let list = (data.projects || []); if (processSearch.trim()) { const q = processSearch.trim().toLowerCase(); list = list.filter(p => (p.name||'').toLowerCase().includes(q) || (p.nickname||'').toLowerCase().includes(q) || (p.client||'').toLowerCase().includes(q)); } if (processCompanyFilter !== 'all') { list = list.filter(p => (p.company || 'Unassigned') === processCompanyFilter); } if (processStatusFilter !== 'all') { list = list.filter(p => { const { pct } = projectDocPct(p); if (processStatusFilter === 'not-started') return pct === 0; if (processStatusFilter === 'completed') return pct === 100; return pct > 0 && pct < 100; }); } if (processSort === 'pct-asc') list = [...list].sort((a,b) => projectDocPct(a).pct - projectDocPct(b).pct); else if (processSort === 'pct-desc') list = [...list].sort((a,b) => projectDocPct(b).pct - projectDocPct(a).pct); else if (processSort === 'name') list = [...list].sort((a,b) => (a.name||'').localeCompare(b.name||'')); else list = [...list].sort((a,b) => (b.id||0) - (a.id||0)); return list; } function renderProcessCompanyFilter(allGovProjects) { const el = document.getElementById('processCompanyFilter'); if (!el) return; const companies = Array.from(new Set(allGovProjects.map(p => p.company || 'Unassigned'))).sort(); if (!companies.includes(processCompanyFilter) && processCompanyFilter !== 'all') processCompanyFilter = 'all'; const chips = ['all', ...companies]; el.innerHTML = chips.map(c => ``).join(''); } function openProjectProcess(projectId) { // Navigate to Process tab and highlight this project setActiveTab('process'); window._highlightProcessProject = projectId; renderProcess(); // Scroll to the project card after render setTimeout(() => { const el = document.querySelector('[data-proc-id="'+projectId+'"]'); if (el) { el.scrollIntoView({behavior:'smooth', block:'center'}); el.style.outline='2px solid var(--violet)'; setTimeout(()=>el.style.outline='',2000); } }, 300); } function renderProcess() { const allGov = (data.projects || []); // Show all projects, not just 'government' renderProcessCompanyFilter(allGov); const list = getProcessFiltered(); // KPIs (based on filtered set, falls back to all if nothing filtered yet) const pcts = list.map(p => projectDocPct(p).pct); const avgPct = pcts.length ? Math.round(pcts.reduce((a,b)=>a+b,0) / pcts.length) : 0; const doneN = pcts.filter(x => x === 100).length; const progressN = pcts.filter(x => x > 0 && x < 100).length; const set = (id,v) => { const e=document.getElementById(id); if(e) e.textContent=v; }; set('procKpiTotal', list.length); set('procKpiAvg', avgPct + '%'); set('procKpiProgress', progressN); set('procKpiDone', doneN); const meta = document.getElementById('processCountMeta'); if (meta) meta.textContent = `${list.length} of ${allGov.length} project${allGov.length===1?'':'s'}`; // Toggle view containers document.getElementById('processListView').style.display = processView === 'list' ? '' : 'none'; document.getElementById('processKanbanView').style.display = processView === 'kanban' ? '' : 'none'; document.getElementById('processStagesView').style.display = processView === 'stages' ? '' : 'none'; if (processView === 'list') renderProcessListView(list, allGov); else if (processView === 'kanban') renderProcessKanbanView(list); else renderProcessStagesView(list, allGov); } let minimizedMilestones = new Set(); function toggleMilestoneBrief(key) { if (minimizedMilestones.has(key)) minimizedMilestones.delete(key); else minimizedMilestones.add(key); renderProcess(); } function minimizeAllMilestones() { const list = getProcessFiltered(); list.forEach(p => PROCESS_MILESTONES.forEach(ms => minimizedMilestones.add(`${p.id}-${ms.name}`))); renderProcess(); } function expandAllMilestones() { minimizedMilestones.clear(); renderProcess(); } function toggleMilestoneComplete(projectId, msIndex) { const p = data.projects.find(x => x.id === projectId); const ms = PROCESS_MILESTONES[msIndex]; if (!p || !ms) return; p.docStatus = (p.docStatus && typeof p.docStatus === 'object') ? p.docStatus : {}; const allDone = ms.stages.every(s => p.docStatus[s]?.done); const today = new Date().toLocaleDateString(); ms.stages.forEach(stage => { p.docStatus[stage] = { done: !allDone, date: !allDone ? today : null }; }); saveData(); renderAll(); } let minimizedProjectsInProcess = new Set(); function toggleProjectMinimizeInProcess(projectId) { if (minimizedProjectsInProcess.has(projectId)) minimizedProjectsInProcess.delete(projectId); else minimizedProjectsInProcess.add(projectId); renderProcess(); } function minimizeAllProjectsInProcess() { getProcessFiltered().forEach(p => minimizedProjectsInProcess.add(p.id)); renderProcess(); } function expandAllProjectsInProcess() { minimizedProjectsInProcess.clear(); renderProcess(); } function minimizeAllProcessEverything() { minimizeAllProjectsInProcess(); minimizeAllMilestones(); } function expandAllProcessEverything() { expandAllProjectsInProcess(); expandAllMilestones(); } function renderProcessListView(list, allGov) { const el = document.getElementById('processListView'); if (!el) return; // Preset: start every project minimized on first view (user expands as needed) if (!window._procMinimizedPreset) { window._procMinimizedPreset = true; list.forEach(p => minimizedProjectsInProcess.add(p.id)); } el.innerHTML = list.length ? list.map(p => { const docStatus = p.docStatus || {}; const { doneCount, pct } = projectDocPct(p); const projMinimized = minimizedProjectsInProcess.has(p.id); return `
${projMinimized?'▸':'▾'} ${escHtml(projNick(p))}
${escHtml(p.client || '')} · ${escHtml(p.company || 'Unassigned')}
${p.workOrderDate?`📅 WO: ${p.workOrderDate}`:''} ${p.agreementDate?`📋 Agmt: ${p.agreementDate}`:''} ${p.estimateNo?`Est#: ${escHtml(p.estimateNo)}`:''} ${p.fileNo?`File: ${escHtml(p.fileNo)}`:''}
${doneCount}/${DOC_STAGES.length} stages · ${pct}% ${p.estimate ? `₹${Number(p.estimate).toLocaleString('en-IN')}` : ''}
${projMinimized ? '' : `
${PROCESS_MILESTONES.map((ms, msIndex) => { const msStats = milestoneStats(p, ms); const allDone = ms.stages.every(s => docStatus[s]?.done); const briefKey = `${p.id}-${ms.name}`; const isMinimized = minimizedMilestones.has(briefKey); const isExpanded = !isMinimized; return `
${isExpanded?'▾':'▸'} ${ms.name}
${ms.label}
${allDone?'✓':''} ${msStats.done}/${msStats.total} · ${msStats.pct}%
${isExpanded ? `
Contains: ${ms.stages.join(', ')}
${ms.stages.map(stage => { const st = docStatus[stage] || { done:false, date:null, pct:0 }; const isWIP = stage === 'WORK IN PROGRESS'; if (isWIP) { // ── WIP Slider ──────────────────────────────────── const wipPct = Number(st.pct || 0); const wipColor = wipPct >= 100 ? 'var(--accent)' : wipPct >= 50 ? 'var(--sky)' : 'var(--amber)'; return `
🏗 WORK IN PROGRESS ${wipPct}%
${wipPct>=100 ? `
✓ Work complete — mark WORK COMPLETED next
` : ''}
`; } const tip = st.done && st.date ? `Completed ${st.date}` : 'Tap to mark complete'; const sc = STAGE_COLORS[stage] || { bg: 'var(--bg-elevated)', border: 'var(--border)', text: 'var(--text-secondary)' }; const chipStyle = st.done ? '' : `background:${sc.bg};border-color:${sc.border};color:${sc.text};`; return `
${st.done?'✓':''} ${stage} ${st.done && st.date ? `${st.date}` : ''}
`; }).join('')}
` : ''}
`; }).join('')} `}
`; }).join('') : (allGov.length ? '
No government projects match this filter.
' : '
No government-category projects yet. Set a project\'s category to "Government" under Projects to track its documentation status here.
'); } function renderProcessKanbanView(list) { const el = document.getElementById('processKanbanView'); if (!el) return; const completedCards = list.filter(p => currentStageOf(p) === -1); el.innerHTML = PROCESS_MILESTONES.map(ms => { const columns = ms.stages; const inMilestone = list.filter(p => { const idx = currentStageOf(p); return idx !== -1 && ms.stages.includes(DOC_STAGES[idx]); }).length; return `
${ms.name}
${ms.label}
${inMilestone}
${columns.map(stage => { const cards = list.filter(p => DOC_STAGES[currentStageOf(p)] === stage); const sc = STAGE_COLORS[stage] || { bg: 'var(--bg-elevated)', border: 'var(--border)', text: 'var(--text-secondary)' }; return `
${stage}${cards.length}
${cards.length ? cards.map(p => { const { pct } = projectDocPct(p); return `
${escHtml(projNick(p))}
${escHtml(p.company || 'Unassigned')}
${pct}% complete
`; }).join('') : '
Nothing here
'}
`; }).join('')}
`; }).join('') + `
Completed
All milestones finished
${completedCards.length}
COMPLETED${completedCards.length}
${completedCards.length ? completedCards.map(p => `
${escHtml(projNick(p))}
${escHtml(p.company || 'Unassigned')}
100% complete
`).join('') : '
Nothing here
'}
`; } function renderProcessStagesView(list, allGov) { const el = document.getElementById('processStagesView'); if (!el) return; if (!list.length) { el.innerHTML = '
No government projects match this filter.
'; return; } el.innerHTML = PROCESS_MILESTONES.map(ms => { const msTotal = list.length * ms.stages.length; const msDone = list.reduce((sum, p) => sum + ms.stages.filter(stage => p.docStatus?.[stage]?.done).length, 0); const msPct = msTotal ? Math.round((msDone / msTotal) * 100) : 0; return `
${ms.name}
${ms.label}
${msDone}/${msTotal} · ${msPct}%
${ms.stages.map(stage => { const count = list.filter(p => p.docStatus?.[stage]?.done).length; const pct = Math.round((count / list.length) * 100); return `
${stage}
${count}/${list.length} · ${pct}%
`; }).join('')}
`; }).join(''); } function editProcessNickname(projectId) { const p = data.projects.find(x => x.id === projectId); if (!p) return; openGenModal('Edit Process Nickname', `
${escHtml(p.name || '')}
`, () => { p.nickname = document.getElementById('genProcessNickname').value.trim(); saveData(); renderAll(); closeGenModal(); }); } // ===== ROUTINES ===== const routineTemplates = [ { name:'Morning Routine', freq:'daily', items:['Wake up early','Drink water','Exercise 20 min','Healthy breakfast','Review daily goals'] }, { name:'Work Startup', freq:'daily', items:['Check emails','Plan top 3 priorities','Stand-up / sync','Clear notifications'] }, { name:'Evening Wind-down', freq:'daily', items:['Plan tomorrow','Reflect on the day','Read 15 min','No screens before bed'] }, { name:'Weekly Review', freq:'weekly', items:['Review goals','Clean workspace','Plan the week ahead','Weekly finance check'] }, { name:'Site Inspection', freq:'weekly', items:['Safety walkthrough','Material stock check','Progress photos','Update client'] } ]; function addRoutine() { const nameEl = document.getElementById('routineName'); const freqEl = document.getElementById('routineFreq'); const name = nameEl.value.trim(); if (!name) return; data.routines.push({ id:Date.now(), name, freq:freqEl.value, items:[] }); nameEl.value = ''; saveData(); renderRoutines(); } function delRoutine(id) { data.routines = data.routines.filter(r=>r.id!==id); saveData(); renderRoutines(); } function editRoutine(id) { const r = data.routines.find(x=>x.id===id); if (!r) return; openGenModal('Edit Routine', `
`, () => { const n = document.getElementById('genName').value.trim(); if (n) { r.name = n; r.freq = document.getElementById('genFreq').value; saveData(); renderRoutines(); closeGenModal(); } }); } function addRoutineItem(id) { const r = data.routines.find(x=>x.id===id); if (!r) return; const input = document.getElementById('ritem-'+id); if (input && input.value.trim()) { r.items.push(input.value.trim()); input.value = ''; saveData(); renderRoutines(); setTimeout(()=>{ const el=document.getElementById('ritem-'+id); if(el) el.focus(); }, 30); } } function delRoutineItem(id, idx) { const r = data.routines.find(x=>x.id===id); if (r) { r.items.splice(idx,1); saveData(); renderRoutines(); } } // Deploy all routine items into the task list with the routine's frequency function deployRoutine(id) { const r = data.routines.find(x=>x.id===id); if (!r || !r.items.length) return; const today = dayKey(new Date()); r.items.forEach((text,i) => { data.tasks.push({ id:Date.now()+i, text:`${text}`, completed:false, date:today, freq:r.freq, routine:r.name }); }); saveData(); renderAll(); // jump to tasks view const taskNav = document.querySelector('.nav-item[data-tab="tasks"]'); if (taskNav) taskNav.click(); } function useTemplate(idx) { const t = routineTemplates[idx]; if (!t) return; data.routines.push({ id:Date.now(), name:t.name, freq:t.freq, items:[...t.items] }); saveData(); renderRoutines(); } function renderRoutines() { const el = document.getElementById('routineList'); if (el) { el.innerHTML = data.routines.length ? data.routines.map(r => `
${r.name}
${freqLabel[r.freq]||r.freq} ${r.items.length} item${r.items.length!==1?'s':''}
${r.items.map((it,idx)=>`
· ${it}
`).join('') || '
No items yet — add steps below
'}
`).join('') : '
No routines yet — create one or pick a template
'; } const meta = document.getElementById('routineCountMeta'); if (meta) meta.textContent = `${data.routines.length} routine${data.routines.length!==1?'s':''}`; const tpl = document.getElementById('routineTemplates'); if (tpl) tpl.innerHTML = routineTemplates.map((t,idx)=>`
${t.name}
${t.items.length} steps · ${freqLabel[t.freq]}
+ Use
`).join(''); } // ===== FINANCE ===== function importFinance(event) { const file = event.target.files[0]; if (!file) return; if (!file.name.endsWith('.csv') && typeof XLSX === 'undefined') { const m = document.getElementById('importMsg'); if (m) m.innerHTML = `
Excel reader didn't load — check your internet connection and reload the page, then try again.
`; event.target.value = ''; return; } const reader = new FileReader(); reader.onload = e => { try { let lines; if (file.name.endsWith('.csv')) { lines = e.target.result.split('\n').filter(l=>l.trim()); } else { const wb = XLSX.read(new Uint8Array(e.target.result), {type:'array'}); const sheet = wb.Sheets[wb.SheetNames[0]]; const csv = XLSX.utils.sheet_to_csv(sheet); lines = csv.split('\n').filter(l=>l.trim()); } parseFinanceLines(lines); } catch(err) { document.getElementById('importMsg').innerHTML = `
Error: ${err.message}
`; } }; if (file.name.endsWith('.csv')) reader.readAsText(file); else reader.readAsArrayBuffer(file); } function parseFinanceLines(lines) { let income=0, totalBudget=0, totalPaid=0, totalPayable=0; const expenses=[]; let section=''; const clean = s => (s||'').replace(/₹/g,'').replace(/,/g,'').replace(/"/g,'').trim(); for (const raw of lines) { const line = raw.trim(); if (!line) continue; if (/money in/i.test(line)) { section='income'; continue; } if (/money out|budget.*paid/i.test(line)) { section='expenses'; continue; } if (/inhand|income minus/i.test(line)) { section=''; continue; } if (section==='income' && /salary/i.test(line)) { const m = line.match(/[\d,]+/g); if (m) income = parseFloat(clean(m[m.length-1])) || income; continue; } if (section==='expenses' && !/total expenses/i.test(line)) { const parts = line.split(','); if (parts.length >= 3) { const cat = parts[0].replace(/"/g,'').trim(); const budget = parseFloat(clean(parts[1])) || 0; const paid = parseFloat(clean(parts[2])) || 0; const payable = Math.abs(parseFloat(clean(parts[3])) || 0); if (cat && (budget>0 || paid>0)) { totalBudget+=budget; totalPaid+=paid; totalPayable+=payable; expenses.push({ id:Date.now()+Math.random(), category:cat, budget, amount:paid, payable, date:new Date().toLocaleDateString(), type:'imported' }); } } } } data.finance = { income, budget:totalBudget, totalPaid, totalPayable, expenses }; saveData(); renderAll(); document.getElementById('importMsg').innerHTML = `
Imported ${expenses.length} categories · Income ${fmtINR(income)} · Budget ${fmtINR(totalBudget)} · Paid ${fmtINR(totalPaid)}
`; } function addExpense() { const c = document.getElementById('expCategory').value.trim(); const b = parseFloat(document.getElementById('expBudget').value)||0; const p = parseFloat(document.getElementById('expPaid').value)||0; if (c && (b||p)) { data.finance.expenses.push({ id:Date.now(), category:c, budget:b, amount:p, payable:Math.max(b-p,0), date:new Date().toLocaleDateString(), type:'manual' }); recalcFinance(); document.getElementById('expCategory').value=''; document.getElementById('expBudget').value=''; document.getElementById('expPaid').value=''; saveData(); renderAll(); } } function recalcFinance() { const e = data.finance.expenses; data.finance.budget = e.reduce((s,x)=>s+(x.budget||0),0); data.finance.totalPaid = e.reduce((s,x)=>s+(x.amount||0),0); data.finance.totalPayable = e.reduce((s,x)=>s+(x.payable||0),0); } function delExpense(id) { data.finance.expenses = data.finance.expenses.filter(x=>x.id!==id); recalcFinance(); saveData(); renderAll(); } // EDIT MODAL function openEdit(id) { editingId = id; const ex = data.finance.expenses.find(x=>x.id===id); if (!ex) return; document.getElementById('edCategory').value = ex.category; document.getElementById('edBudget').value = ex.budget||0; document.getElementById('edPaid').value = ex.amount||0; document.getElementById('edPayable').value = ex.payable||0; try { document.getElementById('edDate').value = new Date(ex.date).toISOString().split('T')[0]; } catch(e){} document.getElementById('editModal').classList.add('show'); } function closeModal() { document.getElementById('editModal').classList.remove('show'); editingId=null; } function saveEdit() { const ex = data.finance.expenses.find(x=>x.id===editingId); if (ex) { ex.category = document.getElementById('edCategory').value.trim(); ex.budget = parseFloat(document.getElementById('edBudget').value)||0; ex.amount = parseFloat(document.getElementById('edPaid').value)||0; ex.payable = parseFloat(document.getElementById('edPayable').value)||0; const d = document.getElementById('edDate').value; if (d) ex.date = new Date(d+'T00:00:00').toLocaleDateString(); recalcFinance(); saveData(); renderAll(); closeModal(); } } // ===== GENERIC EDIT MODAL ===== function openGenModal(title, fieldsHTML, onSave) { document.getElementById('genTitle').textContent = title; document.getElementById('genFields').innerHTML = fieldsHTML; genSaveCallback = onSave; document.getElementById('genModal').classList.add('show'); const first = document.querySelector('#genFields input, #genFields select'); if (first) setTimeout(()=>first.focus(), 50); } function closeGenModal() { document.getElementById('genModal').classList.remove('show'); genSaveCallback = null; } function saveGenEdit() { if (genSaveCallback) genSaveCallback(); } // Single text field editor (tasks, habits) function openTextEdit(title, value, apply) { openGenModal(title, `
`, () => { const v = document.getElementById('genText').value.trim(); if (v) { apply(v); closeGenModal(); } }); } // Schedule editor (time + activity) // ===== SCHEDULE EDIT + RESCHEDULE + BACKGROUND TIMEBOX ===== let seScheduleId = null; let seTimerInterval = null; let seTimerRunning = false; let seTimerMode = 'timebox'; let seTimerDurationMins = 25; let seTimerRemainingSecs = 25 * 60; function openSchedEdit(s) { seScheduleId = s.id; document.getElementById('seFrom').value = s.time || ''; document.getElementById('seTo').value = s.endTime || ''; document.getElementById('seActivity').value = s.activity || ''; const linkedTask = s.taskId ? data.tasks.find(t => t.id === s.taskId) : null; const ltPanel = document.getElementById('seLinkedTask'); const ltText = document.getElementById('seLinkedTaskText'); if (linkedTask && ltPanel && ltText) { ltPanel.style.display = ''; ltText.innerHTML = `🔗 ${escHtml(linkedTask.text)} — unlink`; } else if (ltPanel) { ltPanel.style.display = 'none'; } // Default reschedule date = tomorrow const dateEl = document.getElementById('seRescheduleDate'); if (dateEl) { const tomorrow = new Date(); tomorrow.setDate(tomorrow.getDate() + 1); dateEl.value = dayKey(tomorrow); } // Reset timer display seTimerDurationMins = 25; seTimerRemainingSecs = 25 * 60; seUpdateTimerDisplay(); document.querySelectorAll('#seTimerPresets .filter-btn').forEach(b => b.classList.toggle('active', b.dataset.mins === '15')); const customEl = document.getElementById('seTimerCustomMins'); if (customEl) customEl.style.display = 'none'; const startBtn = document.getElementById('seTimerStartBtn'); const pauseBtn = document.getElementById('seTimerPauseBtn'); const badge = document.getElementById('seTimerRunningBadge'); if (startBtn) startBtn.style.display = ''; if (pauseBtn) pauseBtn.style.display = 'none'; if (badge) badge.style.display = seTimerRunning ? '' : 'none'; document.getElementById('schedEditModal').classList.add('show'); } function closeSchedEditModal() { document.getElementById('schedEditModal').classList.remove('show'); // Timer keeps running in background — purposely not stopping it here } function saveSchedEdit() { const s = data.schedule.find(x => x.id === seScheduleId); if (!s) return; const t = document.getElementById('seFrom').value; const endT = document.getElementById('seTo').value; const a = document.getElementById('seActivity').value.trim(); if (t && a) { s.time = t; s.endTime = endT; s.activity = a; saveData(); renderAll(); closeSchedEditModal(); } } function seUnlinkTask() { const s = data.schedule.find(x => x.id === seScheduleId); if (s) { delete s.taskId; saveData(); renderAll(); } const ltPanel = document.getElementById('seLinkedTask'); if (ltPanel) ltPanel.style.display = 'none'; } // ----- Reschedule helpers ----- function seGetScheduleDayKey(s) { if (!s) return null; if (s.date) { try { const parts = s.date.split('/'); if (parts.length === 3) return `${parts[2]}-${parts[1].padStart(2,'0')}-${parts[0].padStart(2,'0')}`; if (s.date.match(/^\d{4}-\d{2}-\d{2}$/)) return s.date; } catch(e) {} } return dayKey(new Date()); } function rescheduleItem(newDateStr) { const s = data.schedule.find(x => x.id === seScheduleId); if (!s || !newDateStr) return; const newDate = new Date(newDateStr + 'T00:00:00'); const oldDateKey = seGetScheduleDayKey(s); s.date = newDate.toLocaleDateString(); // Sync the linked task's date too, if it exists if (s.taskId) { const linkedTask = data.tasks.find(t => t.id === s.taskId); if (linkedTask) { if (!linkedTask.originalDate) linkedTask.originalDate = linkedTask.date; linkedTask.movedFrom = linkedTask.date; linkedTask.date = newDateStr; linkedTask.remindedOn = ''; linkedTask.carryCount = (linkedTask.carryCount || 0) + 1; } } saveData(); renderAll(); // Show brief confirmation then close const badge = document.getElementById('seTimerRunningBadge'); if (badge) { badge.style.background = 'rgba(10,132,255,0.12)'; badge.style.color = 'var(--sky)'; badge.style.borderColor = 'var(--sky)'; badge.style.display = ''; badge.textContent = `📅 Rescheduled to ${newDate.toLocaleDateString('en-IN', { weekday:'short', day:'numeric', month:'short' })}${s.taskId ? ' (+ linked task moved)' : ''}`; } setTimeout(() => { if (badge) { badge.textContent = '⏱ Timer running in background — close modal anytime'; badge.style.background = 'rgba(48,209,88,0.12)'; badge.style.color = 'var(--accent)'; badge.style.borderColor = 'var(--accent)'; badge.style.display = seTimerRunning ? '' : 'none'; } }, 1800); } function rescheduleItemDays(days) { const s = data.schedule.find(x => x.id === seScheduleId); if (!s) return; const base = new Date(seGetScheduleDayKey(s) + 'T00:00:00'); base.setDate(base.getDate() + days); rescheduleItem(dayKey(base)); } function rescheduleItemToNextWeekday(targetDay) { const s = data.schedule.find(x => x.id === seScheduleId); if (!s) return; const base = new Date(seGetScheduleDayKey(s) + 'T00:00:00'); let d = new Date(base); d.setDate(d.getDate() + 1); while (d.getDay() !== targetDay) d.setDate(d.getDate() + 1); rescheduleItem(dayKey(d)); } function rescheduleItemToCustomDate() { const dateEl = document.getElementById('seRescheduleDate'); if (dateEl && dateEl.value) rescheduleItem(dateEl.value); } // ----- Background Timebox ----- function seSetTimerDuration(mins) { if (mins === 'custom') { document.querySelectorAll('#seTimerPresets .filter-btn').forEach(b => b.classList.toggle('active', b.dataset.mins === 'custom')); const el = document.getElementById('seTimerCustomMins'); if (el) { el.style.display = ''; el.focus(); } return; } seTimerDurationMins = mins; document.querySelectorAll('#seTimerPresets .filter-btn').forEach(b => b.classList.toggle('active', Number(b.dataset.mins) === mins)); const el = document.getElementById('seTimerCustomMins'); if (el) el.style.display = 'none'; if (!seTimerRunning) { seTimerRemainingSecs = mins * 60; seUpdateTimerDisplay(); } } function seSetCustomDuration(val) { const mins = Math.max(1, Math.min(180, Number(val) || 0)); if (!mins) return; seTimerDurationMins = mins; if (!seTimerRunning) { seTimerRemainingSecs = mins * 60; seUpdateTimerDisplay(); } } function seStartTimer() { if (seTimerRunning) return; ensureNotificationPermission(); seTimerRunning = true; document.getElementById('seTimerStartBtn').style.display = 'none'; document.getElementById('seTimerPauseBtn').style.display = ''; const badge = document.getElementById('seTimerRunningBadge'); if (badge) badge.style.display = ''; seTimerInterval = setInterval(() => { seTimerRemainingSecs--; seUpdateTimerDisplay(); if (seTimerRemainingSecs <= 0) { seTimerRemainingSecs = 0; seTimerRunning = false; clearInterval(seTimerInterval); fireNotification('⏱ Time box complete', `Schedule session finished — ${seTimerDurationMins} minutes.`); const startBtn = document.getElementById('seTimerStartBtn'); const pauseBtn = document.getElementById('seTimerPauseBtn'); if (startBtn) startBtn.style.display = ''; if (pauseBtn) pauseBtn.style.display = 'none'; seTimerRemainingSecs = seTimerDurationMins * 60; seUpdateTimerDisplay(); } }, 1000); } function sePauseTimer() { if (!seTimerRunning) return; clearInterval(seTimerInterval); seTimerRunning = false; const badge = document.getElementById('seTimerRunningBadge'); if (badge) badge.style.display = 'none'; document.getElementById('seTimerStartBtn').style.display = ''; document.getElementById('seTimerPauseBtn').style.display = 'none'; } function seResetTimer() { sePauseTimer(); seTimerRemainingSecs = seTimerDurationMins * 60; seUpdateTimerDisplay(); } function seUpdateTimerDisplay() { const el = document.getElementById('seTimerDisplay'); if (!el) return; const m = Math.floor(seTimerRemainingSecs / 60); const s = seTimerRemainingSecs % 60; el.textContent = `${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`; } // ── Work Order PDF: Upload, Extract, View ────────────────────────────── // ── Create Project from Work Order PDF ────────────────────────────── async function createProjectFromWO(input) { const file = input.files[0]; if (!file) return; input.value = ''; const base64 = await new Promise((res, rej) => { const r = new FileReader(); r.onload = () => res(r.result.split(',')[1]); r.onerror = () => rej(); r.readAsDataURL(file); }); // Show side-by-side: PDF viewer + pre-filled creation form document.getElementById('woCreateModal')?.remove(); const byteArr = Uint8Array.from(atob(base64), c => c.charCodeAt(0)); const blob = new Blob([byteArr], {type:'application/pdf'}); const pdfUrl = URL.createObjectURL(blob); const modal = document.createElement('div'); modal.id = 'woCreateModal'; modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.6);backdrop-filter:blur(4px);z-index:10000;display:flex;align-items:stretch;justify-content:center;padding:12px;'; modal.innerHTML = `
📄 Create Project from Work Order PDF
Read PDF on left → Fill fields on right → Create Project
📄 Work Order PDF — ${escHtml(file.name)}
A. Project Identity
Project Name *
Short Name
Zone / Area
Client
Company
B. Classification
Sector
Category
Sub-Category
Status
GST Rate
C. From Work Order
File No. (Ko.Pa.Enn)
Estimate No.
Estimate Amount (₹) *
WO Date (DD/MM/YYYY)
Agmt Date (DD/MM/YYYY)
Duration (months)
`; // Store PDF data in window vars (safe - no size limit in JS vars) window._wocPdfBase64 = base64; window._wocPdfName = file.name; document.body.appendChild(modal); setTimeout(() => document.getElementById('wocName')?.focus(), 200); } function confirmCreateFromWO() { const g = id => (document.getElementById(id)?.value||'').trim(); const name = g('wocName'); if (!name) { alert('Enter the project name'); document.getElementById('wocName')?.focus(); return; } const buildDate = (d,m,y) => (d&&m&&y) ? y+'-'+String(m).padStart(2,'0')+'-'+String(d).padStart(2,'0') : ''; const project = { id: Date.now(), name: name, nickname: g('wocNick') || name.substring(0,12).toUpperCase(), zone: g('wocZone'), client: g('wocClient'), company: g('wocCompany') || 'Unassigned', category: g('wocSector'), subCategory: g('wocCategory'), subSubCategory:g('wocSubCat'), status: g('wocStatus') || 'in-progress', estimate: Number(g('wocEstAmt'))||0, gstRate: Number(g('wocGst'))||18, amountNeeded: 0, fileNo: g('wocFileNo'), estimateNo: g('wocEstNo'), workOrderDate: buildDate(g('wocWOD'),g('wocWOM'),g('wocWOY')), agreementDate: buildDate(g('wocAGD'),g('wocAGM'),g('wocAGY')), duration: Number(g('wocDur'))||0, woPdfData: window._wocPdfBase64 || '', woPdfName: window._wocPdfName || '', tasks: [], docStatus: {}, date: new Date().toLocaleDateString() }; // Auto-tick process stages up to Work Order autoTickProcessUpToWorkOrder(project); data.projects = data.projects || []; data.projects.push(project); // Save PDF separately to avoid localStorage quota overflow if (project.woPdfData) { savePdfData(project.id, project.woPdfData); } saveData(); window._wocPdfBase64 = null; window._wocPdfName = null; document.getElementById('woCreateModal')?.remove(); renderAll(); alert('\u2713 Project created: ' + projNick(project)); setTimeout(() => setActiveTab('projects'), 100); } function autoTickProcessUpToWorkOrder(p) { // When WO PDF uploaded, auto-mark all stages up to WORK ORDER as done const stagesUpToWO = ['ESTIMATE PREPARATION','NOTE FILE','AS','AS PROCEEDINGS','TS','WORK ORDER']; p.docStatus = (p.docStatus && typeof p.docStatus === 'object') ? p.docStatus : {}; const today = dayKey(new Date()); stagesUpToWO.forEach(stage => { if (!p.docStatus[stage] || !p.docStatus[stage].done) { p.docStatus[stage] = { done: true, date: today, autoTicked: true }; } }); } async function handleWOPdfUpload(input, projectId) { const file = input.files[0]; if (!file) return; const statusEl = document.getElementById('genWOExtractStatus'); if (statusEl) statusEl.innerHTML = '📄 Saving PDF...'; const base64 = await new Promise((res, rej) => { const r = new FileReader(); r.onload = () => res(r.result.split(',')[1]); r.onerror = () => rej(new Error('Read failed')); r.readAsDataURL(file); }); const pid = Number(projectId) || projectId; const p = (data.projects||[]).find(x => x.id === pid || x.id === projectId); if (!p) { console.warn('project not found', projectId); return; } p.woPdfData = base64; p.woPdfName = file.name; autoTickProcessUpToWorkOrder(p); savePdfData(p.id, base64); saveData(); if (statusEl) statusEl.innerHTML = '✓ PDF saved. ' + ''; input.value = ''; } // Open the PDF in a viewer + show paste/parse helper modal function openPDFPasteHelper(projectId) { const p = (data.projects||[]).find(x => x.id === projectId); if (!p?.woPdfData) return; // Build PDF blob URL for embedded viewer const byteArr = Uint8Array.from(atob(p.woPdfData), c => c.charCodeAt(0)); const blob = new Blob([byteArr], {type:'application/pdf'}); const pdfUrl = URL.createObjectURL(blob); document.getElementById('pdfPasteModal')?.remove(); const modal = document.createElement('div'); modal.id = 'pdfPasteModal'; modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.6);backdrop-filter:blur(4px);z-index:10000;display:flex;align-items:stretch;justify-content:center;padding:16px;'; modal.addEventListener('click', e => { if(e.target===modal){ URL.revokeObjectURL(pdfUrl); modal.remove(); } }); modal.innerHTML = `
⚡ Auto-fill from Work Order PDF
View PDF on left → paste text on right → Parse & Fill
📄 PDF Preview
💡 Click inside PDF → Ctrl+A → Ctrl+C → paste in right panel. Or type manually from reading below.
📋 Paste PDF Text & Parse
Scanned PDF? Read values from PDF and type into fields below → Apply Manual.
Has text? Ctrl+A → Ctrl+C in PDF → paste below → Parse.
🖉 Type from PDF (scanned-friendly):
Project Name (optional)
File No. (Ko.Pa.Enn)
Estimate No.
WO Date (DD/MM/YYYY)
Estimate (₹)
`; document.body.appendChild(modal); setTimeout(() => document.getElementById('pdfPasteArea')?.focus(), 200); } function applyManualFields() { const get = id => (document.getElementById(id)?.value||"").trim(); const set = (id, val) => { const el=document.getElementById(id); if(el&&val) el.value=val; }; set('genFileNo', get('manFileNo')); set('genEstimateNo', get('manEstNo')); set('genEstimate', get('manEstAmt')); // Build date from DD/MM/YYYY parts const d=get('manWODay'), m=get('manWOMon'), y=get('manWOYear'); if (d && m && y) set('genWorkOrderDate', y+'-'+m.padStart(2,'0')+'-'+d.padStart(2,'0')); const pnEl = document.getElementById('manProjName'); const nameEl = document.getElementById('genName'); if (pnEl?.value && nameEl && !nameEl.value) nameEl.value = pnEl.value; document.getElementById('pdfPasteModal')?.remove(); const statusEl = document.getElementById('genWOExtractStatus'); if (statusEl) statusEl.innerHTML = '✓ Fields applied from PDF'; } // Parse Tamil + English work order text with regex patterns function parseAndFillFromPaste() { const txt = document.getElementById('pdfPasteArea')?.value || ''; if (!txt.trim()) { alert('Paste the PDF text first'); return; } const extracted = {}; // File No (Ko.Pa.Enn / Kopu Enn) - patterns like E3/4347-3/2025 const fileMatch = txt.match(/(?:Ko\.?Pa\.?Enn|Kopu Enn|File\s*No|\u0b95\u0bcb\u0baa\u0bcd\u0baa\u0bc1\s*\u0b8e\u0ba3\u0bcd)[:\s.]*([A-Z]\d+[\/\-][\d\/\-]+)/i) || txt.match(/([A-Z]\d+\/\d+[\-\/]\d+\/\d{4})/); if (fileMatch) extracted.fileNo = fileMatch[1].trim(); // Estimate No (Mathipeedu Enn) - patterns like 212/25-26/AEE(W) const estNoMatch = txt.match(/(?:Mathipeedu\s*Enn|Estimate\s*No|\u0bae\u0ba4\u0bbf\u0baa\u0bcd\u0baa\u0bc0\u0b9f\u0bcd\u0b9f\u0bc1\s*\u0b8e\u0ba3\u0bcd)[:\s.]*(\d+\/\d+[\-\/]\d+\/[A-Z()]+)/i) || txt.match(/(\d+\/\d+\-\d+\/AEE\([A-Z]\))/i); if (estNoMatch) extracted.estimateNo = estNoMatch[1].trim(); // Work Order Date - DD.MM.YYYY or DD/MM/YYYY format const dateMatches = txt.match(/(\d{1,2})[.\/](\d{1,2})[.\/](\d{4})/g); if (dateMatches && dateMatches.length > 0) { // Last date in document usually is the order issue date const lastDate = dateMatches[dateMatches.length - 1]; const parts = lastDate.split(/[.\/]/); if (parts.length === 3) { extracted.workOrderDate = parts[2] + '-' + parts[1].padStart(2,'0') + '-' + parts[0].padStart(2,'0'); } // First date might be agreement date if multiple exist if (dateMatches.length >= 2) { const firstDate = dateMatches[0]; const p2 = firstDate.split(/[.\/]/); if (p2.length === 3) { extracted.agreementDate = p2[2] + '-' + p2[1].padStart(2,'0') + '-' + p2[0].padStart(2,'0'); } } } // Estimate Amount in lakhs or full rupees // Pattern: 1.47 illatcham / 1.47 \u0b87\u0bb2\u0b9f\u0bcd\u0b9a\u0bae\u0bcd const lakhMatch = txt.match(/(?:\u0bae\u0ba4\u0bbf\u0baa\u0bcd\u0baa\u0bc0\u0b9f\u0bcd\u0b9f\u0bc1\s*\u0ba4\u0ba4\u0bbe\u0b95\u0bc8|Mathipeedu Tho?gai|Estimate\s*Amount|Estimate\s*Cost|\u0bb0\u0bc2\.|Rs\.?)[:\s.]*([\d.,]+)\s*(?:\u0b87\u0bb2\u0b9f\u0bcd\u0b9a\u0bae\u0bcd|lakh|illatcham|lacs?)?/i); if (lakhMatch) { const num = parseFloat(lakhMatch[1].replace(/,/g,'')); if (txt.toLowerCase().includes('lakh') || txt.toLowerCase().includes('illatcham') || /\u0b87\u0bb2\u0b9f\u0bcd\u0b9a\u0bae\u0bcd/.test(txt)) { extracted.estimateAmount = Math.round(num * 100000); } else if (num > 1000) { extracted.estimateAmount = Math.round(num); } else { extracted.estimateAmount = Math.round(num * 100000); } } // Fill the form const setVal = (id, val) => { const el = document.getElementById(id); if (el && val != null && val !== '') el.value = val; }; if (extracted.workOrderDate) setVal('genWorkOrderDate', extracted.workOrderDate); if (extracted.agreementDate) setVal('genAgreementDate', extracted.agreementDate); if (extracted.fileNo) setVal('genFileNo', extracted.fileNo); if (extracted.estimateNo) setVal('genEstimateNo', extracted.estimateNo); if (extracted.estimateAmount) setVal('genEstimate', extracted.estimateAmount); const filled = Object.entries(extracted) .filter(([k,v]) => v != null && v !== '') .map(([k]) => k); const statusEl = document.getElementById('genWOExtractStatus'); document.getElementById('pdfPasteModal')?.remove(); if (statusEl) { statusEl.innerHTML = filled.length ? '✓ Auto-filled: ' + filled.join(', ') + '' : '⚠ No patterns matched — fill manually'; } } function viewWOPdf(projectId) { const p = (data.projects||[]).find(x=>x.id===projectId); if (!p?.woPdfData) { alert('No Work Order PDF uploaded for this project.'); return; } // Open in new tab const byteChars = atob(p.woPdfData); const byteArr = new Uint8Array(byteChars.length); for (let i=0; iURL.revokeObjectURL(url), 30000); } function removeWOPdf(projectId) { const id = Number(projectId)||projectId; const p = (data.projects||[]).find(x=>x.id===id||x.id===projectId); if (!p) { console.warn("removeWOPdf: project not found", projectId); return; } if (!confirm('Remove the Work Order PDF for ' + projNick(p) + '?')) return; deletePdfData(p.id); delete p.woPdfData; delete p.woPdfName; saveData(); // Refresh the edit modal closeGenModal(); renderProjects(); editProject(id); } function openProjEdit(p) { // Build subcategory options based on current category const _editSubOpts = (cat) => { const govOpts = [['civil_work','🚧 Civil Work'],['water_supply','💧 Water Supply / Maintenance'],['building_maintenance','🏗 Building Maintenance'],['motor_repair','⚙️ Motor Repair'],['custom','+ Custom...']]; const pvtOpts = [['civil_work','🚧 Civil Work'],['building_maintenance','🏗 Building Maintenance'],['interior','🚪 Interior'],['renovation','🔨 Renovation'],['water_supply','💧 Water Supply'],['motor_repair','⚙️ Motor Repair'],['custom','+ Custom...']]; return (cat === 'private' ? pvtOpts : govOpts).map(([v,l]) => ``).join(''); }; const _curSubCat = p.subCategory || 'civil_work'; const _curCat = p.category || 'government'; openGenModal('Edit Project', `
Work Order PDF
${p.woPdfName?`✓ ${escHtml(p.woPdfName)} `:'No PDF uploaded'}
Key Dates & References (auto-filled from PDF)
`, () => { const n = document.getElementById('genName').value.trim(); const nick = document.getElementById('genNickname').value.trim(); const zoneV = document.getElementById('genZone').value.trim(); const c = document.getElementById('genClient').value.trim(); const comp = document.getElementById('genCompany').value.trim(); const est = Number(document.getElementById('genEstimate').value) || 0; const needed= Number(document.getElementById('genAmountNeeded').value) || 0; const endD = document.getElementById('genEndDate').value; const cat = document.getElementById('genCategory').value; const st = document.getElementById('genStatus').value; const gst = document.getElementById('genGstRate').value; const subRaw= document.getElementById('genSubCategory')?.value || 'civil_work'; const custV = document.getElementById('genSubCatCustom')?.value.trim() || ''; const subCat= subRaw === 'custom' ? custV : subRaw; if (n && c) { const woDate = document.getElementById('genWorkOrderDate')?.value || ''; const agDate = document.getElementById('genAgreementDate')?.value || ''; const estNo = document.getElementById('genEstimateNo')?.value.trim() || ''; const fileNo = document.getElementById('genFileNo')?.value.trim() || ''; const dur = Number(document.getElementById('genDuration')?.value) || 0; p.name = n; p.nickname = nick; p.zone = zoneV; p.client = c; p.company = comp || 'Unassigned'; p.estimate = est; p.amountNeeded = needed; p.endDate = endD; p.category = cat; p.subCategory = subCat; p.subSubCategory = (document.getElementById('genSubCatCustom')?.value||p.subSubCategory||'').trim(); p.status = st; p.gstRate = gst; p.workOrderDate = woDate; p.agreementDate = agDate; p.estimateNo = estNo; p.fileNo = fileNo; if (dur) p.duration = dur; saveData(); renderAll(); closeGenModal(); } }); } function renderProjectCosts() { const projects = (data.projects || []).filter(p => Number(p.estimate) > 0 || Number(p.amountNeeded) > 0); const estProjects = (data.projects || []).filter(p => Number(p.estimate) > 0); const total = estProjects.reduce((sum, p) => sum + Number(p.estimate || 0), 0); const govt = estProjects.filter(p => (p.category || 'other') === 'government').reduce((s, p) => s + Number(p.estimate || 0), 0); const other = total - govt; const avg = estProjects.length ? Math.round(total / estProjects.length) : 0; const set = (id, v) => { const e = document.getElementById(id); if (e) e.textContent = v; }; set('pcTotal', fmtINR(total)); set('pcGovt', fmtINR(govt)); set('pcOther', fmtINR(other)); set('pcAvg', fmtINR(avg)); const meta = document.getElementById('projCostMeta'); if (meta) meta.textContent = `${projects.length} project${projects.length===1?'':'s'} with cost data`; const tbody = document.getElementById('projCostTableBody'); if (!tbody) return; const sorted = [...projects].sort((a, b) => Number(b.estimate || 0) - Number(a.estimate || 0)); tbody.innerHTML = sorted.length ? sorted.map(p => ` ${escHtml(projNick(p))} ${escHtml(p.company || 'Unassigned')} ${projectCategoryLabel[p.category || 'other'] || 'Other'} ${(p.status || 'planning').replace('-', ' ')} ${fmtINR(Number(p.estimate || 0))} ${Number(p.amountNeeded) > 0 ? fmtINR(Number(p.amountNeeded)) : '—'} `).join('') : 'No project cost data yet — add an estimate or amount needed under Projects'; } // ===== ACCOUNTS ===== const ACCOUNT_TYPES = { 'petty-cash': { label: 'Petty Cash', direction: 'out' }, 'project-expense': { label: 'Project Expense', direction: 'out' }, 'purchase': { label: 'Purchase', direction: 'out' }, 'payment-in': { label: 'Payment In', direction: 'in' }, 'expenditure': { label: 'Expenditure', direction: 'out' }, 'vendor-payment': { label: 'Vendor / Labor Payment', direction: 'out' } }; const BANK_LIST = ['DBS', 'IOB', 'KBL', 'INB', 'HDFC']; let acctFilterType = 'all'; let acctFilterStatus = 'all'; let acctCompanyFilter = 'all'; let acctFilterBank = 'all'; function setAcctFilterType(v) { acctFilterType = v; renderAccounts(); } function setAcctFilterStatus(v) { acctFilterStatus = v; renderAccounts(); } function setAcctCompanyFilter(c) { acctCompanyFilter = c; renderAccounts(); } function setAcctFilterBank(b) { acctFilterBank = b; renderAccounts(); } function isOverdue(e) { return e.status === 'pending' && e.dueDate && e.dueDate < dayKey(new Date()); } function onAcctStatusChange() { const statusEl = document.getElementById('acctStatus'); const dueEl = document.getElementById('acctDueDate'); if (!statusEl || !dueEl) return; dueEl.disabled = statusEl.value !== 'pending'; if (statusEl.value !== 'pending') dueEl.value = ''; } function populateAcctProjectSelect() { const sel = document.getElementById('acctProject'); if (!sel) return; const prev = sel.value; sel.innerHTML = '' + (data.projects || []).map(p => ``).join(''); if ((data.projects || []).some(p => String(p.id) === prev)) sel.value = prev; } function addAccountEntry() { const type = document.getElementById('acctType').value; const projectId = document.getElementById('acctProject').value || null; const bankEl = document.getElementById('acctBank'); const bank = bankEl ? (bankEl.value || null) : null; const party = document.getElementById('acctParty').value.trim(); const amount = Number(document.getElementById('acctAmount').value) || 0; const date = document.getElementById('acctDate').value || dayKey(new Date()); const status = document.getElementById('acctStatus').value; const dueDate = status === 'pending' ? (document.getElementById('acctDueDate').value || '') : ''; const note = document.getElementById('acctNote').value.trim(); if (!amount) return; const proj = projectId ? data.projects.find(p => String(p.id) === String(projectId)) : null; data.accounts.push({ id: Date.now(), type, projectId: proj ? proj.id : null, company: proj ? proj.company : null, bank, party, amount, date, status, dueDate, note }); document.getElementById('acctParty').value = ''; document.getElementById('acctAmount').value = ''; document.getElementById('acctDate').value = ''; document.getElementById('acctNote').value = ''; document.getElementById('acctDueDate').value = ''; document.getElementById('acctStatus').value = 'completed'; if (bankEl) bankEl.value = ''; onAcctStatusChange(); saveData(); renderAll(); } function delAccountEntry(id) { data.accounts = data.accounts.filter(x => x.id !== id); saveData(); renderAll(); } function editAccountEntry(id) { const e = data.accounts.find(x => x.id === id); if (!e) return; const projectOptions = '' + (data.projects || []).map(p => `` ).join(''); const bankOptions = [''].concat(BANK_LIST).map(b => `` ).join(''); const typeOptions = Object.entries(ACCOUNT_TYPES).map(([value, meta]) => `` ).join(''); openGenModal('Edit Account Entry', `
`, () => { const projectId = document.getElementById('genAcctProject').value || null; const proj = projectId ? data.projects.find(p => String(p.id) === String(projectId)) : null; e.type = document.getElementById('genAcctType').value; e.projectId = proj ? proj.id : null; e.company = proj ? proj.company : null; e.bank = document.getElementById('genAcctBank').value || null; e.party = document.getElementById('genAcctParty').value.trim(); e.amount = Number(document.getElementById('genAcctAmount').value) || 0; e.date = document.getElementById('genAcctDate').value || dayKey(new Date()); e.status = document.getElementById('genAcctStatus').value; e.dueDate = e.status === 'pending' ? document.getElementById('genAcctDueDate').value : ''; e.note = document.getElementById('genAcctNote').value.trim(); saveData(); renderAll(); closeGenModal(); }); } function toggleAccountStatus(id) { const e = data.accounts.find(x => x.id === id); if (!e) return; e.status = e.status === 'pending' ? 'completed' : 'pending'; saveData(); renderAll(); } function renderAcctCompanyFilter() { const el = document.getElementById('acctCompanyFilterBar'); if (!el) return; const companies = Array.from(new Set((data.projects || []).map(p => p.company || 'Unassigned'))).sort(); if (!companies.includes(acctCompanyFilter) && acctCompanyFilter !== 'all') acctCompanyFilter = 'all'; const chips = ['all', ...companies]; el.innerHTML = chips.map(c => ``).join(''); } function renderAccounts() { populateAcctProjectSelect(); renderAcctCompanyFilter(); const all = data.accounts || []; const sums = {}; Object.keys(ACCOUNT_TYPES).forEach(t => sums[t] = 0); all.forEach(e => { if (sums[e.type] !== undefined) sums[e.type] += Number(e.amount || 0); }); const set = (id, v) => { const el = document.getElementById(id); if (el) el.textContent = v; }; set('acctPetty', fmtINR(sums['petty-cash'])); set('acctExpense', fmtINR(sums['project-expense'])); set('acctPurchase', fmtINR(sums['purchase'])); set('acctPaymentIn', fmtINR(sums['payment-in'])); let list = all; if (acctFilterType !== 'all') list = list.filter(e => e.type === acctFilterType); if (acctFilterStatus === 'overdue') list = list.filter(e => isOverdue(e)); else if (acctFilterStatus !== 'all') list = list.filter(e => e.status === acctFilterStatus); if (acctCompanyFilter !== 'all') list = list.filter(e => (e.company || 'Unassigned') === acctCompanyFilter); if (acctFilterBank !== 'all') list = list.filter(e => e.bank === acctFilterBank); list = [...list].sort((a, b) => (b.id || 0) - (a.id || 0)); const meta = document.getElementById('acctCountMeta'); if (meta) meta.textContent = `${list.length} of ${all.length} entries`; // Bulk delete bar let bulkBar = document.getElementById('acctBulkBar'); if (!bulkBar) { bulkBar = document.createElement('div'); bulkBar.id = 'acctBulkBar'; bulkBar.style.cssText = 'display:none;align-items:center;gap:10px;padding:8px 12px;margin-bottom:10px;background:rgba(255,69,58,0.1);border:1px solid var(--rose);border-radius:10px;'; bulkBar.innerHTML = ` `; const tbody = document.getElementById('acctTableBody'); tbody?.closest('table')?.parentElement?.insertBefore(bulkBar, tbody?.closest('table')); } const tbody = document.getElementById('acctTableBody'); if (!tbody) return; tbody.innerHTML = list.length ? list.map(e => { const proj = e.projectId ? data.projects.find(p => p.id === e.projectId) : null; const dir = ACCOUNT_TYPES[e.type]?.direction || 'out'; const overdue = isOverdue(e); const statusLabel = overdue ? 'Overdue' : (e.status === 'pending' ? 'Pending' : 'Done'); const statusPill = overdue ? 'pill-rose' : (e.status === 'pending' ? 'pill-amber' : 'pill-green'); return ` ${ACCOUNT_TYPES[e.type]?.label || e.type} ${proj ? escHtml(projNick(proj)) : ''} ${escHtml(e.party || '—')} ${e.bank ? `${escHtml(e.bank)}` : ''} ${fmtINR(e.amount)} ${e.date || '—'} ${e.dueDate || '—'} ${statusLabel} `; }).join('') : 'No entries yet — add one above'; } // ── Ledger bulk-select helpers ── function acctCheckChange() { const checked = document.querySelectorAll('.acct-sel-cb:checked'); const bar = document.getElementById('acctBulkBar'); const countEl = document.getElementById('acctBulkCount'); if (bar) bar.style.display = checked.length ? 'flex' : 'none'; if (countEl) countEl.textContent = `${checked.length} item${checked.length===1?'':'s'} selected`; } function acctSelectAll() { document.querySelectorAll('.acct-sel-cb').forEach(cb => cb.checked = true); acctCheckChange(); } function acctClearSelection() { document.querySelectorAll('.acct-sel-cb').forEach(cb => cb.checked = false); const bar = document.getElementById('acctBulkBar'); if (bar) bar.style.display = 'none'; } function acctDeleteSelected() { const ids = [...document.querySelectorAll('.acct-sel-cb:checked')].map(cb => Number(cb.dataset.eid)); if (!ids.length) return; if (!confirm(`Delete ${ids.length} selected entr${ids.length===1?'y':'ies'}? This cannot be undone.`)) return; data.accounts = (data.accounts||[]).filter(e => !ids.includes(e.id)); saveData(); renderAccounts(); renderAll(); const bar = document.getElementById('acctBulkBar'); if (bar) bar.style.display = 'none'; } // ----- Finance: Receivables / Payables / Amount Needed ----- function computeReceivables() { return (data.accounts || []) .filter(e => ACCOUNT_TYPES[e.type]?.direction === 'in' && e.status === 'pending') .reduce((s, e) => s + Number(e.amount || 0), 0); } function computePayables() { return (data.accounts || []) .filter(e => ACCOUNT_TYPES[e.type]?.direction === 'out' && e.status === 'pending') .reduce((s, e) => s + Number(e.amount || 0), 0); } function computeAvailableBalance() { return (data.accounts || []) .filter(e => e.status !== 'pending') .reduce((sum, e) => { const dir = ACCOUNT_TYPES[e.type]?.direction || 'out'; return sum + (dir === 'in' ? Number(e.amount || 0) : -Number(e.amount || 0)); }, 0); } function computeAmountNeeded() { return (data.projects || []).reduce((sum, p) => sum + (Number(p.amountNeeded) || 0), 0); } // ----- Finance: Bank Accounts (auto-calculated from tagged ledger entries) ----- function renderBankAccounts() { const grid = document.getElementById('bankAccountsGrid'); if (!grid) return; const accts = data.accounts || []; let grandTotal = 0; grid.innerHTML = BANK_LIST.map(bank => { const bankEntries = accts.filter(e => e.bank === bank); const inflow = bankEntries.filter(e => e.status !== 'pending' && ACCOUNT_TYPES[e.type]?.direction === 'in').reduce((s,e)=>s+Number(e.amount||0),0); const outflow = bankEntries.filter(e => e.status !== 'pending' && ACCOUNT_TYPES[e.type]?.direction === 'out').reduce((s,e)=>s+Number(e.amount||0),0); const balance = inflow - outflow; grandTotal += balance; const pendingIn = bankEntries.filter(e => e.status === 'pending' && ACCOUNT_TYPES[e.type]?.direction === 'in').reduce((s,e)=>s+Number(e.amount||0),0); const pendingOut = bankEntries.filter(e => e.status === 'pending' && ACCOUNT_TYPES[e.type]?.direction === 'out').reduce((s,e)=>s+Number(e.amount||0),0); return `
${bank}
${fmtINR(balance)}
In ${fmtINR(inflow)} · Out ${fmtINR(outflow)}${(pendingIn||pendingOut) ? ` · Pending ${fmtINR(pendingIn-pendingOut)}` : ''}
`; }).join(''); const metaEl = document.getElementById('bankTotalMeta'); if (metaEl) metaEl.textContent = `${fmtINR(grandTotal)} total across ${BANK_LIST.length} banks`; } // ----- Finance: Expected Payments (quick entry) ----- // Statutory deduction model (Thoothukudi Corporation, govt civil works): // Basic Cost = Estimate / 1.192 (removes ~19.2% margin) // On Estimate : EMD & ASD 2%, LWF 1% // On Basic : IT 1%, CGST 1%, SGST 1%, WH 5% // Net Payment = Estimate − total deductions // Reusable project-status filter dropdown. // statuses: 'all' | 'completed' | 'in-progress' | 'on-hold' | 'planning' const PROJECT_STATUSES = [ ['all','All Projects'], ['completed','Completed'], ['in-progress','Ongoing'], ['on-hold','On Hold'], ['planning','Planning'] ]; function statusFilterDropdown(currentVal, onchangeJs, idAttr) { return ``; } function matchStatus(p, filterVal) { if (!filterVal || filterVal === 'all') return true; return p.status === filterVal; } function computeComplianceNet(estimate) { const est = Number(estimate) || 0; const basic = est / 1.192; const emd = est * 0.02; // EMD & ASD 2% on estimate const lwf = est * 0.01; // Labour Welfare Fund 1% on estimate const it = basic * 0.01; // Income Tax 1% on basic const cgst = basic * 0.01; // CGST 1% on basic const sgst = basic * 0.01; // SGST 1% on basic const wh = basic * 0.05; // Withholding 5% on basic const totalDed = emd + lwf + it + cgst + sgst + wh; const net = est - totalDed; // GST withheld at payout: CGST 8% + SGST 8% on basic cost const cgst8 = basic * 0.08; const sgst8 = basic * 0.08; const gstWithheld = cgst8 + sgst8; const grossCheque = net - gstWithheld; // actual cheque to contractor return { basic: Math.round(basic), emd: Math.round(emd), lwf: Math.round(lwf), it: Math.round(it), cgst: Math.round(cgst), sgst: Math.round(sgst), wh: Math.round(wh), totalDed: Math.round(totalDed), net: Math.round(net), cgst8: Math.round(cgst8), sgst8: Math.round(sgst8), gstWithheld: Math.round(gstWithheld), grossCheque: Math.round(grossCheque) }; } function populateExpectedProjectSelects() { // Net expected after statutory deductions (Thoothukudi Corp compliance model) const projNetExpected = (p) => { const est = Number(p.estimate) || 0; if (!est) return Number(p.amountNeeded) || 0; return computeComplianceNet(est).net; }; // Build option label: project name + net-after-deduction value const projOptLabel = (p) => { const val = projNetExpected(p); return escHtml(projNick(p)) + (val ? ' · ₹' + Math.round(val).toLocaleString('en-IN') : ''); }; // Project dropdowns (Expected IN uses "Select project…", OUT keeps "No project") const inSel = document.getElementById('expInProject'); if (inSel) { const prev = inSel.value; const statusF = window._expInStatus || 'completed'; // default Completed let inList = (data.projects || []); if (statusF !== 'all') inList = inList.filter(p => p.status === statusF); const emptyMsg = statusF==='all' ? 'No projects' : statusF==='completed' ? 'No completed projects' : statusF==='in-progress' ? 'No ongoing projects' : 'No '+statusF+' projects'; inSel.innerHTML = '' + (inList.length ? inList.map(p => ``).join('') : ``); if (inList.some(p => String(p.id) === prev)) inSel.value = prev; // keep the status dropdown in sync const sf = document.getElementById('expInStatusFilter'); if (sf) sf.value = statusF; } const outSel = document.getElementById('expOutProject'); if (outSel) { const prev = outSel.value; outSel.innerHTML = '' + (data.projects || []).map(p => ``).join(''); if ((data.projects || []).some(p => String(p.id) === prev)) outSel.value = prev; } // Vendor dropdown for Expected OUT const vSel = document.getElementById('expOutVendor'); if (vSel) { const prev = vSel.value; const vendors = (data.vendors || []).slice().sort((a,b)=>(a.name||'').localeCompare(b.name||'')); vSel.innerHTML = '' + vendors.map(v => ``).join(''); if (vendors.some(v => v.name === prev)) vSel.value = prev; } } // Expected IN: selecting a project auto-fetches client + net expected (after deductions) function expInProjectChanged() { const projectId = document.getElementById('expInProject')?.value || ''; const amtEl = document.getElementById('expInAmount'); const cliEl = document.getElementById('expInClient'); if (!projectId) return; const p = (data.projects||[]).find(x => String(x.id) === String(projectId)); if (!p) return; // Client name if (cliEl) cliEl.value = p.client || p.company || ''; // Net expected = estimate minus statutory deductions; fallback to amountNeeded const est = Number(p.estimate) || 0; if (est) { const c = computeComplianceNet(est); if (amtEl) { amtEl.value = c.net; amtEl.title = `Estimate ₹${est.toLocaleString('en-IN')} − deductions ₹${c.totalDed.toLocaleString('en-IN')}` + ` (EMD&ASD ${c.emd}, LWF ${c.lwf}, IT ${c.it}, CGST ${c.cgst}, SGST ${c.sgst}, WH ${c.wh})` + ` = Net ₹${c.net.toLocaleString('en-IN')}`; } } else { const amt = Number(p.amountNeeded) || 0; if (amtEl && amt) amtEl.value = Math.round(amt); } } // Expected OUT: selecting vendor (and optionally project) auto-fetches amount payable function expOutVendorChanged() { const vendorName = document.getElementById('expOutVendor')?.value || ''; const amtEl = document.getElementById('expOutAmount'); if (!vendorName) return; const v = (data.vendors||[]).find(x => x.name === vendorName); if (!v) return; // Base outstanding on the vendor record let payable = Number(v.outstanding) || 0; // Add any unpaid funds/accounts logged against this vendor const acc = (data.accounts||[]).filter(a => a.party === vendorName && a.status !== 'paid' && (a.type==='vendor-payment')); payable += acc.reduce((s,a) => s + Number(a.amount||0), 0); if (amtEl && payable) amtEl.value = Math.round(payable); } function addExpectedIn() { const date = document.getElementById('expInDate').value; const amount = Number(document.getElementById('expInAmount').value) || 0; const client = document.getElementById('expInClient').value.trim(); const projectId = document.getElementById('expInProject').value || null; if (!amount || !date) { alert('Select a project and enter the expected date.'); return; } const proj = projectId ? data.projects.find(p => String(p.id) === String(projectId)) : null; data.accounts.push({ id: Date.now(), type: 'payment-in', projectId: proj ? proj.id : null, company: proj ? proj.company : null, party: client, amount, date: dayKey(new Date()), status: 'pending', dueDate: date, note: '' }); document.getElementById('expInDate').value = ''; document.getElementById('expInAmount').value = ''; document.getElementById('expInClient').value = ''; const ip = document.getElementById('expInProject'); if (ip) ip.value = ''; saveData(); renderAll(); } function addExpectedOut() { const date = document.getElementById('expOutDate').value; const amount = Number(document.getElementById('expOutAmount').value) || 0; const vendor = document.getElementById('expOutVendor').value.trim(); const projectId = document.getElementById('expOutProject').value || null; if (!amount || !date) { alert('Select a vendor and enter the expected date.'); return; } const proj = projectId ? data.projects.find(p => String(p.id) === String(projectId)) : null; data.accounts.push({ id: Date.now(), type: 'vendor-payment', projectId: proj ? proj.id : null, company: proj ? proj.company : null, party: vendor, amount, date: dayKey(new Date()), status: 'pending', dueDate: date, note: '' }); document.getElementById('expOutDate').value = ''; document.getElementById('expOutAmount').value = ''; const ov = document.getElementById('expOutVendor'); if (ov) ov.value = ''; const op = document.getElementById('expOutProject'); if (op) op.value = ''; saveData(); renderAll(); } // ----- Finance: Cash Flow Calendar ----- let calendarDate = new Date(); let calMode = 'due'; // 'due' | 'actual' | 'projected' let calTypeFilter = 'all'; // 'all' | 'in' | 'out' function calShiftMonth(delta) { calendarDate = new Date(calendarDate.getFullYear(), calendarDate.getMonth() + delta, 1); renderFinanceCalendar(); } function setCalMode(m) { calMode = m; document.querySelectorAll('#finCalModeSwitch .filter-btn').forEach(b => b.classList.toggle('active', b.dataset.mode === m)); const typeFilterEl = document.getElementById('finCalTypeFilter'); if (typeFilterEl) typeFilterEl.style.display = m === 'due' ? '' : 'none'; renderFinanceCalendar(); } function setCalTypeFilter(f) { calTypeFilter = f; document.querySelectorAll('#finCalTypeFilter .filter-btn').forEach(b => b.classList.toggle('active', b.dataset.tf === f)); renderFinanceCalendar(); } function buildBalanceMap(mode) { // Returns map of dateKey -> cumulative balance immediately after that date's transactions const events = []; (data.accounts || []).forEach(e => { const dir = ACCOUNT_TYPES[e.type]?.direction || 'out'; const sign = dir === 'in' ? 1 : -1; if (e.status !== 'pending') { events.push({ k: e.date, amount: sign * Number(e.amount || 0) }); } else if (mode === 'projected') { events.push({ k: e.dueDate || e.date, amount: sign * Number(e.amount || 0) }); } }); events.sort((a, b) => (a.k || '').localeCompare(b.k || '')); const cum = {}; let running = 0; events.forEach(ev => { running += ev.amount; if (ev.k) cum[ev.k] = running; }); return Object.entries(cum).sort((a, b) => a[0].localeCompare(b[0])); } function balanceAsOf(sortedPairs, dateKeyVal) { let bal = 0; for (const [dk, b] of sortedPairs) { if (dk <= dateKeyVal) bal = b; else break; } return bal; } function renderFinanceCalendar() { const grid = document.getElementById('finCalendarGrid'); const label = document.getElementById('finCalLabel'); const legend = document.getElementById('finCalLegend'); if (!grid) return; const year = calendarDate.getFullYear(); const month = calendarDate.getMonth(); if (label) label.textContent = calendarDate.toLocaleDateString('en-IN', { month: 'long', year: 'numeric' }); const firstDay = new Date(year, month, 1); const startOffset = firstDay.getDay(); const daysInMonth = new Date(year, month + 1, 0).getDate(); const todayKey = dayKey(new Date()); const dayHeads = ['S','M','T','W','T','F','S'].map(d => `
${d}
`).join(''); let cells = ''; for (let i = 0; i < startOffset; i++) cells += '
'; if (calMode === 'due') { if (legend) legend.innerHTML = ` Amount needed / payable due Receivable due`; const byDate = {}; (data.accounts || []).forEach(e => { if (e.status !== 'pending' || !e.dueDate) return; const dir = ACCOUNT_TYPES[e.type]?.direction || 'out'; if (!byDate[e.dueDate]) byDate[e.dueDate] = { payable: 0, receivable: 0, projects: {} }; if (dir === 'in') byDate[e.dueDate].receivable += Number(e.amount || 0); else byDate[e.dueDate].payable += Number(e.amount || 0); // Per-project split (respecting the in/out type filter) if ((calTypeFilter === 'in' && dir !== 'in') || (calTypeFilter === 'out' && dir !== 'out')) return; const proj = e.projectId ? (data.projects||[]).find(p=>p.id===e.projectId) : null; const pName = proj ? projNick(proj) : (e.party || 'Other'); if (!byDate[e.dueDate].projects[pName]) byDate[e.dueDate].projects[pName] = { in:0, out:0 }; byDate[e.dueDate].projects[pName][dir==='in'?'in':'out'] += Number(e.amount || 0); }); for (let d = 1; d <= daysInMonth; d++) { const k = dayKey(new Date(year, month, d)); const amounts = byDate[k]; const isToday = k === todayKey; const showPayable = calTypeFilter !== 'in' && amounts && amounts.payable; const showReceivable = calTypeFilter !== 'out' && amounts && amounts.receivable; const hasEntries = showPayable || showReceivable; // Total for the day (respecting filter) let dayTotal = 0; if (amounts) { if (calTypeFilter === 'in') dayTotal = amounts.receivable; else if (calTypeFilter === 'out') dayTotal = amounts.payable; else dayTotal = amounts.receivable + amounts.payable; } // Per-project split chips let projChips = ''; if (hasEntries && amounts.projects) { const entries = Object.entries(amounts.projects) .map(([name,v]) => ({ name, amt: (v.in||0)+(v.out||0), isIn: (v.in||0)>=(v.out||0) })) .filter(x => x.amt > 0) .sort((a,b)=>b.amt-a.amt); projChips = entries.slice(0,3).map(x => `${escHtml(x.name.length>14?x.name.slice(0,13)+'…':x.name)} ₹${Math.round(x.amt).toLocaleString('en-IN')}` ).join(''); if (entries.length > 3) projChips += `+${entries.length-3} more`; } cells += `
${d}
${showPayable ? `₹${Math.round(amounts.payable).toLocaleString('en-IN')}` : ''} ${showReceivable ? `₹${Math.round(amounts.receivable).toLocaleString('en-IN')}` : ''} ${projChips ? `
${projChips}
` : ''} ${hasEntries && (showPayable && showReceivable) ? `Σ ₹${Math.round(dayTotal).toLocaleString('en-IN')}` : ''}
`; } } else { // actual or projected balance mode if (legend) legend.innerHTML = ` Positive balance Negative balance`; const sortedPairs = buildBalanceMap(calMode); for (let d = 1; d <= daysInMonth; d++) { const k = dayKey(new Date(year, month, d)); const isToday = k === todayKey; const bal = balanceAsOf(sortedPairs, k); const cls = bal >= 0 ? 'cal-amt-balance-pos' : 'cal-amt-balance-neg'; cells += `
${d}
₹${Math.round(Math.abs(bal)).toLocaleString('en-IN')}${bal<0?' DR':''}
`; } } grid.innerHTML = dayHeads + cells; } // Day-detail popup for the cash-flow calendar — view & delete pending entries function openCalDayDetail(dayK) { const entries = (data.accounts||[]).filter(e => e.status==='pending' && e.dueDate===dayK) .filter(e => { const dir = ACCOUNT_TYPES[e.type]?.direction || 'out'; if (calTypeFilter==='in') return dir==='in'; if (calTypeFilter==='out') return dir==='out'; return true; }); if (!entries.length) return; const M=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; const [y,m,d]=dayK.split('-'); const dateLabel = `${parseInt(d)} ${M[parseInt(m)-1]} ${y}`; const rows = entries.map(e => { const dir = ACCOUNT_TYPES[e.type]?.direction || 'out'; const inFlow = dir==='in'; const proj = e.projectId ? (data.projects||[]).find(p=>p.id===e.projectId) : null; return `
${inFlow?'↓':'↑'}
${escHtml(e.party||'—')}
${ACCOUNT_TYPES[e.type]?.label||e.type}${proj?' · '+escHtml(projNick(proj)):''}
${inFlow?'+':'−'} ${fmtINR(e.amount)}
`; }).join(''); // Totals + per-project split let totalIn = 0, totalOut = 0; const projSplit = {}; entries.forEach(e => { const dir = ACCOUNT_TYPES[e.type]?.direction || 'out'; const amt = Number(e.amount||0); if (dir==='in') totalIn += amt; else totalOut += amt; const proj = e.projectId ? (data.projects||[]).find(p=>p.id===e.projectId) : null; const pName = proj ? projNick(proj) : (e.party || 'Other'); if (!projSplit[pName]) projSplit[pName] = { in:0, out:0 }; projSplit[pName][dir==='in'?'in':'out'] += amt; }); const splitRows = Object.entries(projSplit).sort((a,b)=>((b[1].in+b[1].out)-(a[1].in+a[1].out))).map(([name,v]) => `
${escHtml(name)} ${v.in?`+${fmtINR(v.in)}`:''}${v.in&&v.out?' / ':''}${v.out?`−${fmtINR(v.out)}`:''}
`).join(''); const summaryHtml = `
Total ${totalIn?`+${fmtINR(totalIn)}`:''}${totalIn&&totalOut?' · ':''}${totalOut?`−${fmtINR(totalOut)}`:''}
Split by project
${splitRows}
`; document.getElementById('calDayModal')?.remove(); const modal = document.createElement('div'); modal.id = 'calDayModal'; modal.style.cssText = 'position:fixed;inset:0;z-index:10000;background:rgba(0,0,0,0.4);backdrop-filter:blur(5px);-webkit-backdrop-filter:blur(5px);display:flex;align-items:center;justify-content:center;padding:20px;'; modal.innerHTML = `
📅 ${dateLabel} ${entries.length} entr${entries.length===1?'y':'ies'}
${summaryHtml}${rows}
`; modal.addEventListener('click', e => { if (e.target===modal) modal.remove(); }); document.body.appendChild(modal); } function renderFinance() { const f = data.finance; const remaining = (f.budget||0) - (f.totalPaid||0); const pct = f.budget>0 ? Math.round(f.totalPaid/f.budget*100) : 0; const set = (id,v) => { const e=document.getElementById(id); if(e) e.textContent=v; }; set('finIncome', fmtINR(f.income)); set('finBudget', fmtINR(f.budget)); set('finPaid', fmtINR(f.totalPaid)); set('finRemaining', fmtINR(remaining)); const pctEl = document.getElementById('finPaidPct'); if (pctEl) { pctEl.textContent = pct+'% of budget'; pctEl.className = 'kpi-trend ' + (pct>100?'trend-down':'trend-up'); } const availableBalance = computeAvailableBalance(); set('finAvailableBalance', fmtINR(availableBalance)); const availableMeta = document.getElementById('finAvailableMeta'); if (availableMeta) { availableMeta.textContent = availableBalance >= 0 ? 'Completed ledger balance' : 'Completed ledger deficit'; availableMeta.className = 'kpi-trend ' + (availableBalance >= 0 ? 'trend-up' : 'trend-down'); } set('finReceivable', fmtINR(computeReceivables())); set('finPayable', fmtINR(computePayables())); set('finNeeded', fmtINR(computeAmountNeeded())); populateExpectedProjectSelects(); renderFinanceCalendar(); renderBankAccounts(); renderProjectCosts(); const tbody = document.getElementById('finTableBody'); if (tbody) tbody.innerHTML = f.expenses.length ? f.expenses.map(ex => ` ${ex.category} ${fmtINR(ex.budget||0)} ${fmtINR(ex.amount||0)} ${fmtINR(ex.payable||0)} ${ex.date} `).join('') : 'No transactions — import a file or add manually'; } // ===== OVERVIEW ===== function renderOverview() { const range = getPeriodRange(); const f = data.finance; const tasksInRange = data.tasks.filter(t=>inRange(t.date,range)); const tasksDone = tasksInRange.filter(t=>t.completed).length; const today = new Date().toLocaleDateString(); const habitRate = data.habits.length ? Math.round(data.habits.filter(h=>h.completedDates.includes(today)).length / data.habits.length * 100) : 0; const activeProj = data.projects.filter(p=>p.status==='in-progress').length; const set = (id,v) => { const e=document.getElementById(id); if(e) e.textContent=v; }; set('kpiTasks', tasksDone); set('kpiHabits', habitRate+'%'); set('kpiProjects', activeProj); set('kpiSpent', fmtINR(f.totalPaid)); const trend = (id, txt, cls) => { const e=document.getElementById(id); if(e){ e.textContent=txt; e.className='kpi-trend '+cls; } }; trend('kpiTasksTrend', `${tasksInRange.length} total`, 'trend-flat'); trend('kpiHabitsTrend', habitRate>=70?'On track':'Keep going', habitRate>=70?'trend-up':'trend-flat'); trend('kpiProjectsTrend', `${data.projects.length} total`, 'trend-flat'); const pct = f.budget>0?Math.round(f.totalPaid/f.budget*100):0; trend('kpiSpentTrend', `${pct}% of budget`, pct>90?'trend-down':'trend-up'); const lbl = document.getElementById('overviewPeriodLabel'); if (lbl) lbl.textContent = periodLabel(); updateOverviewChart(range); updateSplitChart(); renderOverviewInsights(habitRate, tasksDone, tasksInRange.length, pct); renderOverviewBusiness(); set('ovReceivable', fmtINR(computeReceivables())); set('ovPayable', fmtINR(computePayables())); set('ovNeeded', fmtINR(computeAmountNeeded())); renderOverviewCalendar(); renderUnifiedCalendar(); } // ----- Overview: Business summary (Projects + Process + Accounts) ----- function renderOverviewBusiness() { const projects = data.projects || []; const total = projects.length; const active = projects.filter(p => p.status === 'in-progress').length; const completed = projects.filter(p => p.status === 'completed').length; const planning = projects.filter(p => p.status === 'planning').length; const set = (id, v) => { const e = document.getElementById(id); if (e) e.textContent = v; }; const trend = (id, txt) => { const e = document.getElementById(id); if (e) e.textContent = txt; }; set('bizTotalProjects', total); set('bizActiveProjects', active); trend('bizProjectsTrend', `${planning} planning · ${completed} done`); const govt = projects.filter(p => (p.category || 'other') === 'government'); const avgPct = govt.length ? Math.round(govt.reduce((s, p) => s + projectDocPct(p).pct, 0) / govt.length) : 0; set('bizProcessAvg', avgPct + '%'); trend('bizProcessMeta', `${govt.length} govt project${govt.length===1?'':'s'}`); const acctList = data.accounts || []; set('bizAcctTotal', acctList.length); const pendingCount = acctList.filter(e => e.status === 'pending').length; trend('bizAcctMeta', `${pendingCount} pending`); const companyEl = document.getElementById('bizCompanyBreakdown'); if (companyEl) { const counts = {}; projects.forEach(p => { const c = p.company || 'Unassigned'; counts[c] = (counts[c] || 0) + 1; }); const entries = Object.entries(counts).sort((a, b) => b[1] - a[1]); companyEl.innerHTML = entries.length ? entries.map(([c, n]) => `${escHtml(c)}: ${n}`).join('') : 'No projects yet'; } } // ----- Overview: mini Finance calendar (due amounts only) ----- let overviewCalDate = new Date(); function ovCalShiftMonth(delta) { overviewCalDate = new Date(overviewCalDate.getFullYear(), overviewCalDate.getMonth() + delta, 1); renderOverviewCalendar(); } function renderOverviewCalendar() { const grid = document.getElementById('ovCalendarGrid'); const label = document.getElementById('ovCalLabel'); if (!grid) return; const year = overviewCalDate.getFullYear(); const month = overviewCalDate.getMonth(); if (label) label.textContent = overviewCalDate.toLocaleDateString('en-IN', { month: 'long', year: 'numeric' }); const byDate = {}; (data.accounts || []).forEach(e => { if (e.status !== 'pending' || !e.dueDate) return; const dir = ACCOUNT_TYPES[e.type]?.direction || 'out'; if (!byDate[e.dueDate]) byDate[e.dueDate] = { payable: 0, receivable: 0 }; if (dir === 'in') byDate[e.dueDate].receivable += Number(e.amount || 0); else byDate[e.dueDate].payable += Number(e.amount || 0); }); const firstDay = new Date(year, month, 1); const startOffset = firstDay.getDay(); const daysInMonth = new Date(year, month + 1, 0).getDate(); const todayKey = dayKey(new Date()); const dayHeads = ['S','M','T','W','T','F','S'].map(d => `
${d}
`).join(''); let cells = ''; for (let i = 0; i < startOffset; i++) cells += '
'; for (let d = 1; d <= daysInMonth; d++) { const k = dayKey(new Date(year, month, d)); const amounts = byDate[k]; const isToday = k === todayKey; cells += `
${d}
${amounts && amounts.payable ? `₹${Math.round(amounts.payable).toLocaleString('en-IN')}` : ''} ${amounts && amounts.receivable ? `₹${Math.round(amounts.receivable).toLocaleString('en-IN')}` : ''}
`; } grid.innerHTML = dayHeads + cells; } // ----- Overview: view switcher (Dashboard <-> Unified Calendar) ----- // ===== DRAGGABLE TAB REORDER ===== function initDraggableTabs() { const container = document.getElementById('overviewViewSwitch'); if (!container) return; let dragSrc = null; // Restore saved order from localStorage try { const saved = JSON.parse(localStorage.getItem('mydas_tab_order') || '[]'); if (saved.length) { const btns = [...container.querySelectorAll('[data-ov-view]')]; const sorted = saved .map(v => btns.find(b => b.dataset.ovView === v)) .filter(Boolean); // Append any new tabs not in saved order at the end btns.filter(b => !saved.includes(b.dataset.ovView)).forEach(b => sorted.push(b)); sorted.forEach(b => container.appendChild(b)); } } catch(e) {} container.querySelectorAll('[data-ov-view]').forEach(btn => { btn.addEventListener('dragstart', e => { dragSrc = btn; btn.style.opacity = '0.4'; e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', btn.dataset.ovView); }); btn.addEventListener('dragend', () => { btn.style.opacity = ''; container.querySelectorAll('[data-ov-view]').forEach(b => b.classList.remove('drag-over')); saveTabOrder(); }); btn.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; if (btn !== dragSrc) btn.classList.add('drag-over'); }); btn.addEventListener('dragleave', () => btn.classList.remove('drag-over')); btn.addEventListener('drop', e => { e.preventDefault(); btn.classList.remove('drag-over'); if (btn !== dragSrc) { // Insert dragSrc before or after btn depending on mouse position const rect = btn.getBoundingClientRect(); const mid = rect.left + rect.width / 2; if (e.clientX < mid) { container.insertBefore(dragSrc, btn); } else { container.insertBefore(dragSrc, btn.nextSibling); } } }); }); } function saveTabOrder() { const container = document.getElementById('overviewViewSwitch'); if (!container) return; const order = [...container.querySelectorAll('[data-ov-view]')].map(b => b.dataset.ovView); try { localStorage.setItem('mydas_tab_order', JSON.stringify(order)); } catch(e) {} } function setOverviewView(mode) { document.querySelectorAll('#overviewViewSwitch .filter-btn').forEach(b => b.classList.toggle('active', b.dataset.ovView === mode)); const dashEl = document.getElementById('overviewDashboardView'); const calEl = document.getElementById('overviewCalendarView'); const balEl = document.getElementById('overviewBalancesView'); const todayEl = document.getElementById('overviewTodayView'); if (dashEl) dashEl.style.display = mode === 'dashboard' ? '' : 'none'; if (calEl) calEl.style.display = mode === 'calendar' ? '' : 'none'; if (balEl) balEl.style.display = mode === 'balances' ? '' : 'none'; if (todayEl) todayEl.style.display = mode === 'today' ? '' : 'none'; if (mode === 'calendar') renderUnifiedCalendar(); if (mode === 'balances') { document.querySelectorAll('#ovBalViewGroup .filter-btn').forEach(b => b.classList.toggle('active', b.dataset.bv === balView)); renderBalanceViews(); } if (mode === 'today') renderTodayCommand(); } // ----- Overview: Unified Calendar (Tasks + Schedule + Finance/Accounts) ----- let unifiedCalDate = new Date(); let unifiedSelectedDay = dayKey(new Date()); let unifiedCalView = 'month'; // 'day' | 'week' | 'month' function setUnifiedCalView(mode) { unifiedCalView = mode; document.querySelectorAll('#unifiedCalViewSwitch .filter-btn').forEach(b => b.classList.toggle('active', b.dataset.ucview === mode)); renderUnifiedCalendar(); } function unifiedCalGoToday() { unifiedCalDate = new Date(); unifiedSelectedDay = dayKey(new Date()); renderUnifiedCalendar(); } function unifiedCalShift(delta) { if (unifiedCalView === 'day') { unifiedCalDate = new Date(unifiedCalDate.getFullYear(), unifiedCalDate.getMonth(), unifiedCalDate.getDate() + delta); } else if (unifiedCalView === 'week') { unifiedCalDate = new Date(unifiedCalDate.getFullYear(), unifiedCalDate.getMonth(), unifiedCalDate.getDate() + delta * 7); } else { unifiedCalDate = new Date(unifiedCalDate.getFullYear(), unifiedCalDate.getMonth() + delta, 1); } renderUnifiedCalendar(); } function scheduleDayKey(s) { const d = new Date(s.date); return isNaN(d) ? '' : dayKey(d); } function selectUnifiedDay(k) { unifiedSelectedDay = k; renderUnifiedCalendar(); openDayDetailModal(k); } function openDayDetailModal(k) { renderUnifiedDayDetails(k); const modal = document.getElementById('dayDetailModal'); if (modal) modal.classList.add('show'); } function closeDayDetailModal() { const modal = document.getElementById('dayDetailModal'); if (modal) modal.classList.remove('show'); } let unifiedCalFilter = 'all'; // 'all' | 'tasks' | 'budgets' | 'finances' function setUnifiedCalFilter(type) { unifiedCalFilter = type; document.querySelectorAll('#unifiedCalContentFilter .filter-btn').forEach(b => b.classList.toggle('active', b.dataset.ucfilter === type)); // Update legend visibility const isAll = type === 'all', isTasks = type === 'tasks', isBudgets = type === 'budgets', isFinances = type === 'finances'; const show = (id, v) => { const el = document.getElementById(id); if (el) el.style.display = v ? '' : 'none'; }; show('legendTasks', isAll || isTasks); show('legendDone', isTasks); show('legendPending', isTasks); show('legendOverdue', isTasks); show('legendRescheduled',isTasks); show('legendSched', isAll || isTasks); show('legendPayable', isAll || isBudgets); show('legendReceivable', isAll || isFinances); show('legendFunds', isAll || isBudgets); renderUnifiedCalendar(); } function getUnifiedBadgeMap() { const byDate = {}; const ensure = k => { if (!byDate[k]) byDate[k] = { tasks: 0, sched: 0, payable: 0, receivable: 0, completed: 0, pending: 0, overdue: 0, rescheduled: 0, funds: 0, fundsAmt: 0, fundItems: [] }; return byDate[k]; }; const todayKey = dayKey(new Date()); (data.tasks || []).forEach(t => { const k = dayKey(t.date); if (!k) return; const b = ensure(k); b.tasks += 1; if (t.completed) { b.completed += 1; } else if (t.skipped) { // skipped: don't count in pending } else if (t.carryCount > 0 || !!t.originalDate) { b.rescheduled += 1; if (k < todayKey) b.overdue += 1; else b.pending += 1; } else if (k < todayKey) { b.overdue += 1; } else { b.pending += 1; } if (t.originalDate && dayKey(t.originalDate) !== k) { const ok = dayKey(t.originalDate); if (ok) { const ob = ensure(ok); ob.rescheduled += 1; } } }); (data.schedule || []).forEach(s => { const k = scheduleDayKey(s); if (!k) return; ensure(k).sched += 1; }); (data.accounts || []).forEach(e => { if (e.status !== 'pending' || !e.dueDate) return; const dir = ACCOUNT_TYPES[e.type]?.direction || 'out'; const b = ensure(e.dueDate); if (dir === 'in') b.receivable += Number(e.amount || 0); else b.payable += Number(e.amount || 0); }); // ── Money Management Fund Requirements ── (data.funds || []).forEach(f => { if (f.status === 'paid' || !f.date) return; const k = f.date; const b = ensure(k); b.funds += 1; b.fundsAmt += Number(f.amount || 0); b.fundItems.push(f); }); return byDate; } function unifiedDayCellHtml(dateObj, byDate, todayKey, opts = {}) { const k = dayKey(dateObj); const b = byDate[k]; const isToday = k === todayKey; const isPast = k < todayKey; const isSelected = k === unifiedSelectedDay; const dayLabel = opts.showWeekday ? `${dateObj.toLocaleDateString('en-IN',{weekday:'short'})} ${dateObj.getDate()}` : dateObj.getDate(); const showTasks = unifiedCalFilter === 'all' || unifiedCalFilter === 'tasks'; const showPayable = unifiedCalFilter === 'all' || unifiedCalFilter === 'budgets'; const showReceivable = unifiedCalFilter === 'all' || unifiedCalFilter === 'finances'; const tasksOnly = unifiedCalFilter === 'tasks'; let taskBadges = ''; if (showTasks && b) { if (tasksOnly) { // Full breakdown: Planned / Completed / Pending / Overdue / Rescheduled if (b.tasks > 0) taskBadges += `📋 ${b.tasks} planned`; if (b.completed > 0) taskBadges += `✓ ${b.completed} done`; if (b.overdue > 0) taskBadges += `⏰ ${b.overdue} overdue`; if (b.pending > 0 && !isPast) taskBadges += `• ${b.pending} pending`; if (b.rescheduled > 0) taskBadges += `↷ ${b.rescheduled} moved`; if (b.sched > 0) taskBadges += `⏱ ${b.sched} sched`; } else { // Compact summary for All view if (b.tasks) taskBadges += `📋 ${b.tasks}`; if (b.sched) taskBadges += `⏰ ${b.sched}`; } } return `
${dayLabel}
${taskBadges} ${showPayable && b && b.payable ? `₹${Math.round(b.payable).toLocaleString('en-IN')}` : ''} ${showReceivable && b && b.receivable ? `₹${Math.round(b.receivable).toLocaleString('en-IN')}` : ''} ${showPayable && b && b.fundsAmt ? `💵 ${b.funds} · ₹${Math.round(b.fundsAmt).toLocaleString('en-IN')}` : ''} ${showPayable && opts.cumulative ? `Σ ${fmtINR(opts.cumulative)}` : ''}
`; } function renderUnifiedYearStats() { const el = document.getElementById('unifiedCalYearStats'); if (!el) return; const today = new Date(); const year = today.getFullYear(); const startOfYear = new Date(year, 0, 1); const dayOfYear = Math.ceil((today - startOfYear) / 86400000) + 1; const daysInYear = ((year % 4 === 0 && year % 100 !== 0) || year % 400 === 0) ? 366 : 365; const dayPct = Math.round(dayOfYear / daysInYear * 100); const { week: weekOfYear, year: weekYear } = getISOWeekNumber(today); const totalWeeks = getWeeksInYear(weekYear); const weekPct = Math.round(weekOfYear / totalWeeks * 100); const monthOfYear = today.getMonth() + 1; const monthPct = Math.round(monthOfYear / 12 * 100); const endOfYear = new Date(year, 11, 31); const daysLeft = Math.ceil((endOfYear - today) / 86400000); const quarter = Math.ceil(monthOfYear / 3); const quarterStart = new Date(year, (quarter-1)*3, 1); const quarterEnd = new Date(year, quarter*3, 0); const quarterDay = Math.ceil((today - quarterStart) / 86400000) + 1; const quarterDays = Math.ceil((quarterEnd - quarterStart) / 86400000) + 1; const quarterPct = Math.round(quarterDay / quarterDays * 100); const STAT_DEFS = { day: { label: 'Day of Year', value: `${dayOfYear} / ${daysInYear}`, sub: `${daysLeft} days left · ${dayPct}% elapsed`, pct: dayPct, color: 'var(--accent)' }, week: { label: 'Week of Year', value: `${weekOfYear} / ${totalWeeks}`, sub: `ISO week ${weekOfYear} · ${100-weekPct}% of weeks remain`, pct: weekPct, color: 'var(--sky)' }, month: { label: 'Month of Year', value: `${monthOfYear} / 12`, sub: `${today.toLocaleDateString('en-IN',{month:'long'})} · Q${quarter}`, pct: monthPct, color: 'var(--amber)' }, quarter: { label: `Quarter ${quarter}`, value: `${quarterDay} / ${quarterDays}`, sub: `Q${quarter} ${year} · ${quarterPct}% elapsed`, pct: quarterPct, color: 'var(--violet)' } }; // Restore saved order let order = ['day','week','month','quarter']; try { const saved = JSON.parse(localStorage.getItem('mydas_yearstat_order')||'[]'); if(saved.length===4) order = saved; } catch(e){} el.innerHTML = order.map(key => { const d = STAT_DEFS[key]; if (!d) return ''; return `
${d.label}
${d.value}
${d.sub}
`; }).join(''); // Wire drag-and-drop let dragSrc = null; el.querySelectorAll('[data-stat]').forEach(card => { card.addEventListener('dragstart', e => { dragSrc = card; card.style.opacity = '0.4'; e.dataTransfer.effectAllowed = 'move'; }); card.addEventListener('dragend', () => { card.style.opacity = ''; el.querySelectorAll('[data-stat]').forEach(c => c.classList.remove('drag-over')); // Save order const newOrder = [...el.querySelectorAll('[data-stat]')].map(c => c.dataset.stat); try { localStorage.setItem('mydas_yearstat_order', JSON.stringify(newOrder)); } catch(e){} }); card.addEventListener('dragover', e => { e.preventDefault(); if (card !== dragSrc) card.classList.add('drag-over'); }); card.addEventListener('dragleave', () => card.classList.remove('drag-over')); card.addEventListener('drop', e => { e.preventDefault(); card.classList.remove('drag-over'); if (card !== dragSrc) { const rect = card.getBoundingClientRect(); if (e.clientX < rect.left + rect.width / 2) el.insertBefore(dragSrc, card); else el.insertBefore(dragSrc, card.nextSibling); } }); }); } function renderUnifiedCalendar() { const grid = document.getElementById('unifiedCalGrid'); const agenda = document.getElementById('unifiedCalAgenda'); const label = document.getElementById('unifiedCalLabel'); if (!grid) return; const byDate = getUnifiedBadgeMap(); const todayKey = dayKey(new Date()); // Always render year stats bar renderUnifiedYearStats(); if (unifiedCalView === 'month') { grid.style.display = ''; if (agenda) agenda.style.display = 'none'; const year = unifiedCalDate.getFullYear(); const month = unifiedCalDate.getMonth(); if (label) label.textContent = unifiedCalDate.toLocaleDateString('en-IN', { month: 'long', year: 'numeric' }); const firstDay = new Date(year, month, 1); const startOffset = firstDay.getDay(); const daysInMonth = new Date(year, month + 1, 0).getDate(); const dayHeads = ['S','M','T','W','T','F','S'].map(d => `
${d}
`).join(''); let cells = ''; for (let i = 0; i < startOffset; i++) cells += '
'; const showBudgetCumul = (unifiedCalFilter === 'budgets' || unifiedCalFilter === 'all'); let runningCumul = 0; for (let d = 1; d <= daysInMonth; d++) { const dd = new Date(year, month, d); const dk = dayKey(dd); if (showBudgetCumul && byDate[dk]) runningCumul += (byDate[dk].payable||0) + (byDate[dk].fundsAmt||0); cells += unifiedDayCellHtml(dd, byDate, todayKey, showBudgetCumul && runningCumul > 0 ? { cumulative: runningCumul } : {}); } grid.innerHTML = dayHeads + cells; // Clear any old cumulative row const ec = document.getElementById('calCumulativeRow'); if (ec) ec.innerHTML = ''; } else if (unifiedCalView === 'week') { grid.style.display = ''; if (agenda) agenda.style.display = 'none'; const start = new Date(unifiedCalDate); start.setDate(start.getDate() - start.getDay()); const end = new Date(start); end.setDate(end.getDate() + 6); if (label) label.textContent = `${start.toLocaleDateString('en-IN',{day:'numeric',month:'short'})} – ${end.toLocaleDateString('en-IN',{day:'numeric',month:'short',year:'numeric'})}`; const dayHeads = ['S','M','T','W','T','F','S'].map(d => `
${d}
`).join(''); let cells = ''; for (let i = 0; i < 7; i++) { const d = new Date(start); d.setDate(start.getDate() + i); cells += unifiedDayCellHtml(d, byDate, todayKey, { minHeight: '130px', showWeekday: true }); } grid.innerHTML = dayHeads + cells; } else { grid.style.display = 'none'; if (agenda) agenda.style.display = ''; if (label) label.textContent = unifiedCalDate.toLocaleDateString('en-IN', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }); unifiedSelectedDay = dayKey(unifiedCalDate); if (agenda) agenda.innerHTML = buildDayAgendaHtml(unifiedSelectedDay); } renderUnifiedDayDetails(unifiedSelectedDay); } function buildDayAgendaHtml(k) { const tasks = (data.tasks || []).filter(t => dayKey(t.date) === k); const sched = (data.schedule || []).filter(s => scheduleDayKey(s) === k).sort((a, b) => a.time.localeCompare(b.time)); const accts = (data.accounts || []).filter(e => e.status === 'pending' && e.dueDate === k); const funds = (data.funds || []).filter(f => f.date === k && f.status !== 'paid'); let html = ''; if (tasks.length) { html += `
Tasks
` + tasks.map(t => `
${t.completed?'✓':''}
${escHtml(t.text)}
`).join(''); } if (sched.length) { html += `
Schedule
` + sched.map(s => `
${s.time}
${escHtml(s.activity)}
`).join(''); } if (funds.length) { const total = funds.reduce((s,f)=>s+Number(f.amount||0),0); html += `
💵 Money Management Funds Needed ${fmtINR(total)}
` + funds.map(f => { const cat = getFundCat(f.cat); const proj = f.projectId ? data.projects.find(p=>p.id===Number(f.projectId)) : null; const projLabel = proj ? projNick(proj) : (f.projectLabel||''); const prioColor = f.priority==='high'?'var(--rose)':f.priority==='medium'?'var(--amber)':'var(--text-muted)'; return `
${cat.label}
${escHtml(f.desc)}
${projLabel?'📁 '+escHtml(projLabel)+' · ':''}${f.vendor?'🏪 '+escHtml(f.vendor)+' · ':''}● ${f.priority}
${fmtINR(f.amount)}
`; }).join(''); } if (accts.length) { html += `
Finance / Accounts
` + accts.map(e => { const dir = ACCOUNT_TYPES[e.type]?.direction || 'out'; const proj = e.projectId ? data.projects.find(p => p.id === e.projectId) : null; return `
${ACCOUNT_TYPES[e.type]?.label || e.type}
${escHtml(e.party || '—')}${proj ? ' · ' + escHtml(projNick(proj)) : ''}
${fmtINR(e.amount)}
`; }).join(''); } return html || '
Nothing scheduled, due, or pending on this day
'; } function renderUnifiedDayDetails(k) { const el = document.getElementById('unifiedDayDetails'); const titleEl = document.getElementById('unifiedDayTitle'); const modalEl = document.getElementById('dayDetailContent'); const modalTitleEl = document.getElementById('dayDetailTitle'); const dateObj = new Date(k + 'T00:00:00'); const titleText = isNaN(dateObj) ? 'Selected day' : dateObj.toLocaleDateString('en-IN', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }); if (titleEl) titleEl.textContent = titleText; if (modalTitleEl) modalTitleEl.textContent = titleText; const finalHtml = buildDayAgendaHtml(k); if (el) el.innerHTML = finalHtml; if (modalEl) modalEl.innerHTML = finalHtml; const agenda = document.getElementById('unifiedCalAgenda'); if (agenda && unifiedCalView === 'day' && agenda.style.display !== 'none') agenda.innerHTML = finalHtml; } // Re-render day detail (used after toggling a task from inside the modal/panel) without closing the modal function refreshDayDetail(k) { renderUnifiedDayDetails(k); } // ===== EXPORT TO IPHONE CALENDAR (.ics) ===== function icsPad(n) { return String(n).padStart(2, '0'); } function icsEscape(str) { return String(str || '') .replace(/\\/g, '\\\\') .replace(/;/g, '\\;') .replace(/,/g, '\\,') .replace(/\n/g, '\\n'); } // Builds a floating local datetime stamp 'YYYYMMDDTHHMMSS' (no timezone suffix, // so the device applies its own local timezone — correct for reminders on iPhone) function icsDateTime(dateStr, timeStr) { const parts = String(dateStr || '').split('-'); if (parts.length !== 3) return null; const [y, m, d] = parts; const [hh, mm] = (timeStr || '09:00').split(':'); return `${y}${m}${d}T${icsPad(hh)}${icsPad(mm)}00`; } function icsNowStamp() { const n = new Date(); return `${n.getUTCFullYear()}${icsPad(n.getUTCMonth()+1)}${icsPad(n.getUTCDate())}T${icsPad(n.getUTCHours())}${icsPad(n.getUTCMinutes())}${icsPad(n.getUTCSeconds())}Z`; } function icsEvent({ uid, dateStr, timeStr, durationMins, summary, description, alarmMinsBefore }) { const start = icsDateTime(dateStr, timeStr); if (!start) return ''; const endDate = new Date(`${dateStr}T${timeStr || '09:00'}:00`); endDate.setMinutes(endDate.getMinutes() + (durationMins || 30)); const end = `${endDate.getFullYear()}${icsPad(endDate.getMonth()+1)}${icsPad(endDate.getDate())}T${icsPad(endDate.getHours())}${icsPad(endDate.getMinutes())}${icsPad(endDate.getSeconds())}`; const lines = [ 'BEGIN:VEVENT', `UID:${uid}@nexus-dashboard`, `DTSTAMP:${icsNowStamp()}`, `DTSTART:${start}`, `DTEND:${end}`, `SUMMARY:${icsEscape(summary)}`, ]; if (description) lines.push(`DESCRIPTION:${icsEscape(description)}`); lines.push('BEGIN:VALARM'); lines.push('ACTION:DISPLAY'); lines.push('DESCRIPTION:Reminder'); lines.push(`TRIGGER:-PT${alarmMinsBefore != null ? alarmMinsBefore : 0}M`); lines.push('END:VALARM'); lines.push('END:VEVENT'); return lines.join('\r\n'); } function buildICS() { const events = []; // Daily Tasks (pending only) — default 9:00 AM reminder slot (data.tasks || []).forEach(t => { if (t.completed) return; const dk = dayKey(t.date); if (!dk) return; events.push(icsEvent({ uid: `task-${t.id}`, dateStr: dk, timeStr: '09:00', durationMins: 30, summary: `✓ ${t.text}`, description: 'Daily Task from MYDAS Dashboard', alarmMinsBefore: 0 })); }); // Schedule items — use their actual time range when set, 10-minute-before alarm (data.schedule || []).forEach(s => { const dk = scheduleDayKey(s); if (!dk || !s.time) return; let durationMins = 60; if (s.endTime) { const [sh, sm] = s.time.split(':').map(Number); const [eh, em] = s.endTime.split(':').map(Number); const diff = (eh * 60 + em) - (sh * 60 + sm); if (diff > 0) durationMins = diff; } events.push(icsEvent({ uid: `sched-${s.id}`, dateStr: dk, timeStr: s.time, durationMins, summary: `⏰ ${s.activity}`, description: 'Schedule item from MYDAS Dashboard', alarmMinsBefore: 10 })); }); // Pending Finance/Accounts due dates — payable/receivable reminders (data.accounts || []).forEach(e => { if (e.status !== 'pending' || !e.dueDate) return; const dir = ACCOUNT_TYPES[e.type]?.direction || 'out'; const proj = e.projectId ? data.projects.find(p => p.id === e.projectId) : null; const label = dir === 'in' ? '💰 Receive' : '💸 Pay'; const who = e.party ? ` ${dir === 'in' ? 'from' : 'to'} ${e.party}` : ''; const projTxt = proj ? ` (${projNick(proj)})` : ''; events.push(icsEvent({ uid: `acct-${e.id}`, dateStr: e.dueDate, timeStr: '09:00', durationMins: 15, summary: `${label} ${fmtINR(e.amount)}${who}${projTxt}`, description: `${ACCOUNT_TYPES[e.type]?.label || e.type} — ${e.note || ''}`, alarmMinsBefore: 0 })); }); const calLines = [ 'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//MYDAS Dashboard//Tasks Schedule Finance//EN', 'CALSCALE:GREGORIAN', 'METHOD:PUBLISH', ...events.filter(Boolean), 'END:VCALENDAR' ]; return calLines.join('\r\n'); } function exportToICS() { const msgEl = document.getElementById('icsExportMsg'); try { const icsContent = buildICS(); const blob = new Blob([icsContent], { type: 'text/calendar;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'nexus-calendar.ics'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); const count = (data.tasks||[]).filter(t=>!t.completed).length + (data.schedule||[]).length + (data.accounts||[]).filter(e=>e.status==='pending'&&e.dueDate).length; if (msgEl) msgEl.innerHTML = `Downloaded nexus-calendar.ics with ${count} reminder${count===1?'':'s'}. AirDrop it to your iPhone, or open it from Mail/Files, then tap "Add All" to import into Apple Calendar.`; } catch (err) { if (msgEl) msgEl.innerHTML = `Couldn't generate the calendar file: ${escHtml(err.message)}`; } } function updateOverviewChart(range) { const ctx = document.getElementById('overviewChart'); if (!ctx) return; const days = range.days.slice(-14); const labels = days.map(d=>d.toLocaleDateString('en-US',{month:'short',day:'numeric'})); const taskData = days.map(d => data.tasks.filter(t=>dayKey(t.date)===dayKey(d) && t.completed).length); const spendData = days.map(d => data.finance.expenses.filter(x=>dayKey(x.date)===dayKey(d)).reduce((s,x)=>s+(x.amount||0),0)); if (charts.overview) charts.overview.destroy(); charts.overview = new Chart(ctx, { data:{ labels, datasets:[ { type:'bar', label:'Tasks Done', data:taskData, backgroundColor:C.accent+'cc', borderRadius:5, yAxisID:'y' }, { type:'line', label:'Spend ₹', data:spendData, borderColor:C.sky, backgroundColor:C.sky+'20', tension:0.4, fill:true, yAxisID:'y1', pointRadius:3 } ]}, options:{ responsive:true, maintainAspectRatio:false, plugins:{ legend:{ labels:{ color:C.muted, boxWidth:12 } } }, scales:{ y:{ position:'left', beginAtZero:true, ticks:{ color:C.muted }, grid:{ color:C.grid } }, y1:{ position:'right', beginAtZero:true, ticks:{ color:C.muted, callback:v=>'₹'+v }, grid:{ display:false } }, x:{ ticks:{ color:C.muted }, grid:{ display:false } } } } }); } function updateSplitChart() { const ctx = document.getElementById('splitChart'); if (!ctx) return; const totals = {}; data.finance.expenses.forEach(e => { totals[e.category] = (totals[e.category]||0) + (e.amount||0); }); const sorted = Object.entries(totals).sort((a,b)=>b[1]-a[1]).slice(0,6); const palette = C.series; if (charts.split) charts.split.destroy(); charts.split = new Chart(ctx, { type:'doughnut', data:{ labels: sorted.length?sorted.map(s=>s[0]):['No data'], datasets:[{ data: sorted.length?sorted.map(s=>s[1]):[1], backgroundColor:palette, borderWidth:0 }] }, options:{ responsive:true, maintainAspectRatio:false, cutout:'66%', plugins:{ legend:{ position:'bottom', labels:{ color:C.muted, boxWidth:11, font:{size:11} } }, tooltip:{ callbacks:{ label:c=>c.label+': '+fmtINR(c.parsed) } } } } }); } function renderOverviewInsights(habitRate, tasksDone, tasksTotal, budgetPct) { const el = document.getElementById('overviewInsights'); if (!el) return; const items = []; if (budgetPct > 90 && data.finance.budget>0) items.push({c:'alert',i:'⚠',t:'Budget alert',b:`You've used ${budgetPct}% of your budget. ${fmtINR(data.finance.budget-data.finance.totalPaid)} remaining.`}); if (habitRate === 100 && data.habits.length) items.push({c:'',i:'✦',t:'Perfect habits today',b:`All ${data.habits.length} habits completed. Excellent consistency!`}); if (tasksTotal>0 && tasksDone/tasksTotal>=0.8) items.push({c:'',i:'✓',t:'High productivity',b:`${Math.round(tasksDone/tasksTotal*100)}% of tasks done this period.`}); const active = data.projects.filter(p=>p.status==='in-progress').length; if (active>3) items.push({c:'warn',i:'⌂',t:'Many active projects',b:`${active} projects in progress — consider prioritizing.`}); if (!items.length) items.push({c:'info',i:'◈',t:'All clear',b:'Everything is running smoothly. Keep it up!'}); el.innerHTML = items.map(x=>`
${x.i}
${x.t}
${x.b}
`).join(''); } // ===== ANALYTICS ===== function renderAnalytics() { const f = data.finance; const balance = (f.income||0) - (f.totalPaid||0); const set = (id,v) => { const e=document.getElementById(id); if(e) e.textContent=v; }; set('anBudget', fmtINR(f.budget)); set('anPaid', fmtINR(f.totalPaid)); set('anPayable', fmtINR(f.totalPayable)); set('anBalance', fmtINR(balance)); const pct = f.budget>0?Math.round(f.totalPaid/f.budget*100):0; const pt = document.getElementById('anPaidTrend'); if (pt) { pt.textContent = pct+'% of budget'; pt.className='kpi-trend '+(pct>100?'trend-down':'trend-up'); } const bt = document.getElementById('anBalanceTrend'); if (bt) { bt.textContent = balance>=0?'Surplus':'Deficit'; bt.className='kpi-trend '+(balance>=0?'trend-up':'trend-down'); } updateTrendChart(); updateCatChart(); updateBvpChart(); renderAnalyticsStats(); renderAnalyticsInsights(); } function updateTrendChart() { const ctx = document.getElementById('trendChart'); if (!ctx) return; const range = getPeriodRange(); const days = range.days.slice(-30); const labels = days.map(d=>d.toLocaleDateString('en-US',{month:'short',day:'numeric'})); const spend = days.map(d => data.finance.expenses.filter(x=>dayKey(x.date)===dayKey(d)).reduce((s,x)=>s+(x.amount||0),0)); const lbl = document.getElementById('trendPeriodLabel'); if (lbl) lbl.textContent = periodLabel(); if (charts.trend) charts.trend.destroy(); charts.trend = new Chart(ctx, { type:'line', data:{ labels, datasets:[{ label:'Daily Spend', data:spend, borderColor:C.accent, backgroundColor:C.accent+'18', borderWidth:2.5, tension:0.4, fill:true, pointRadius:3, pointBackgroundColor:C.accent }] }, options:{ responsive:true, maintainAspectRatio:false, plugins:{ legend:{ display:false }, tooltip:{ callbacks:{ label:c=>fmtINR(c.parsed.y) } } }, scales:{ y:{ beginAtZero:true, ticks:{ color:C.muted, callback:v=>'₹'+v }, grid:{ color:C.grid } }, x:{ ticks:{ color:C.muted }, grid:{ display:false } } } } }); } function updateCatChart() { const ctx = document.getElementById('catChart'); if (!ctx) return; const totals = {}; data.finance.expenses.forEach(e => { totals[e.category]=(totals[e.category]||0)+(e.amount||0); }); const sorted = Object.entries(totals).sort((a,b)=>b[1]-a[1]).slice(0,7); if (charts.cat) charts.cat.destroy(); charts.cat = new Chart(ctx, { type:'bar', data:{ labels: sorted.map(s=>s[0]), datasets:[{ label:'Spent', data:sorted.map(s=>s[1]), backgroundColor:C.sky+'cc', borderRadius:5 }] }, options:{ indexAxis:'y', responsive:true, maintainAspectRatio:false, plugins:{ legend:{ display:false }, tooltip:{ callbacks:{ label:c=>fmtINR(c.parsed.x) } } }, scales:{ x:{ beginAtZero:true, ticks:{ color:C.muted, callback:v=>'₹'+v }, grid:{ color:C.grid } }, y:{ ticks:{ color:C.muted, font:{size:11} }, grid:{ display:false } } } } }); } function updateBvpChart() { const ctx = document.getElementById('bvpChart'); if (!ctx) return; const top = [...data.finance.expenses].sort((a,b)=>(b.budget||0)-(a.budget||0)).slice(0,7); if (charts.bvp) charts.bvp.destroy(); charts.bvp = new Chart(ctx, { type:'bar', data:{ labels: top.map(e=>e.category), datasets:[ { label:'Budget', data:top.map(e=>e.budget||0), backgroundColor:C.violet+'aa', borderRadius:4 }, { label:'Paid', data:top.map(e=>e.amount||0), backgroundColor:C.accent+'cc', borderRadius:4 } ]}, options:{ responsive:true, maintainAspectRatio:false, plugins:{ legend:{ labels:{ color:C.muted, boxWidth:12 } }, tooltip:{ callbacks:{ label:c=>c.dataset.label+': '+fmtINR(c.parsed.y) } } }, scales:{ y:{ beginAtZero:true, ticks:{ color:C.muted, callback:v=>'₹'+v }, grid:{ color:C.grid } }, x:{ ticks:{ color:C.muted, font:{size:10} }, grid:{ display:false } } } } }); } function renderAnalyticsStats() { const el = document.getElementById('analyticsStats'); if (!el) return; const sp = document.getElementById('statsPeriod'); if (sp) sp.textContent = periodLabel(); const range = getPeriodRange(); const days = range.days; const spend = days.map(d => data.finance.expenses.filter(x=>dayKey(x.date)===dayKey(d)).reduce((s,x)=>s+(x.amount||0),0)); const total = spend.reduce((a,b)=>a+b,0); const active = spend.filter(s=>s>0); const avg = active.length ? total/active.length : 0; const max = spend.length ? Math.max(...spend) : 0; const cats = new Set(data.finance.expenses.map(e=>e.category)).size; const stat = (label,val,color) => `
${label}${val}
`; el.innerHTML = stat('Period total', fmtINR(total), C.accent) + stat('Daily average', fmtINR(avg), C.sky) + stat('Highest day', fmtINR(max), C.rose) + stat('Categories tracked', cats, C.amber) + stat('Transactions', data.finance.expenses.length, C.violet); } function renderAnalyticsInsights() { const el = document.getElementById('analyticsInsights'); if (!el) return; const f = data.finance; const items = []; const totals = {}; f.expenses.forEach(e => { totals[e.category]=(totals[e.category]||0)+(e.amount||0); }); const top = Object.entries(totals).sort((a,b)=>b[1]-a[1])[0]; if (top) items.push({c:'info',i:'◈',t:'Biggest expense',b:`${top[0]} accounts for ${fmtINR(top[1])} of your spending.`}); const overspent = f.expenses.filter(e=>(e.amount||0)>(e.budget||0) && e.budget>0); if (overspent.length) items.push({c:'alert',i:'⚠',t:'Over budget',b:`${overspent.length} categor${overspent.length>1?'ies':'y'} exceeded budget: ${overspent.slice(0,3).map(e=>e.category).join(', ')}.`}); const balance = (f.income||0)-(f.totalPaid||0); if (f.income>0) items.push({c:balance>=0?'':'alert',i:balance>=0?'✦':'⚠',t:balance>=0?'Positive balance':'Spending exceeds income',b:`Net ${balance>=0?'surplus':'deficit'} of ${fmtINR(Math.abs(balance))} this month.`}); if (f.totalPayable>0) items.push({c:'warn',i:'◷',t:'Pending payments',b:`You have ${fmtINR(f.totalPayable)} in outstanding payments.`}); if (!items.length) items.push({c:'info',i:'◈',t:'Import data to begin',b:'Upload your budget file to unlock detailed analytics and recommendations.'}); el.innerHTML = items.map(x=>`
${x.i}
${x.t}
${x.b}
`).join(''); } // ===== EXPORT ===== // ===== TAX COMPLIANCE MODULE ===== let gstActiveTab = 'gstr1'; let tdsActiveTab = 'deductions'; const GST_RATE = 0.18; // Standard 18% GST for construction/services function setGSTTab(tab) { gstActiveTab = tab; document.querySelectorAll('#gstTabSwitch .filter-btn').forEach(b => b.classList.toggle('active', b.dataset.gtab === tab)); renderGSTTab(); } function setTDSTab(tab) { tdsActiveTab = tab; document.querySelectorAll('#tdsTabSwitch .filter-btn').forEach(b => b.classList.toggle('active', b.dataset.ttab === tab)); renderTDSTab(); } function computeGSTFromInvoices() { return (data.invoices || []).map(inv => { const taxable = Number(inv.total || 0); const cgst = Math.round(taxable * 0.09 * 100) / 100; const sgst = Math.round(taxable * 0.09 * 100) / 100; const igst = 0; return { ...inv, taxable, cgst, sgst, igst, gstTotal: cgst + sgst }; }); } function computeGSTFromPurchases() { return [...(data.purchaseOrders || []), ...(data.vendorBills || [])].map(doc => { const taxable = Number(doc.total || 0); const cgst = Math.round(taxable * 0.09 * 100) / 100; const sgst = Math.round(taxable * 0.09 * 100) / 100; return { ...doc, taxable, cgst, sgst, gstTotal: cgst + sgst }; }); } function renderGSTTab() { const el = document.getElementById('gstContent'); if (!el) return; const todayKey = dayKey(new Date()); const thisMonthStart = todayKey.slice(0, 7) + '-01'; if (gstActiveTab === 'gstr1') { const rows = computeGSTFromInvoices(); const totalTaxable = rows.reduce((s, r) => s + r.taxable, 0); const totalGST = rows.reduce((s, r) => s + r.gstTotal, 0); el.innerHTML = `
Total Taxable Value
${fmtINR(totalTaxable)}
All invoices
Output CGST (9%)
${fmtINR(rows.reduce((s,r)=>s+r.cgst,0))}
Central GST
Output SGST (9%)
${fmtINR(rows.reduce((s,r)=>s+r.sgst,0))}
State GST
Total Output GST
${fmtINR(totalGST)}
CGST + SGST
GSTR-1 — Outward Supplies (Sales Invoices)
${rows.length ? rows.map(r => { const proj = r.projectId ? data.projects.find(p=>p.id===r.projectId) : null; return ``; }).join('') : ''}
Invoice No.DatePartyProjectTaxable ValueCGST 9%SGST 9%Total
${r.number}${r.date}${escHtml(r.party)}${proj?escHtml(projNick(proj)):(r.projectLabel||'—')}${fmtINR(r.taxable)}${fmtINR(r.cgst)}${fmtINR(r.sgst)}${fmtINR(r.taxable + r.gstTotal)}
No invoices yet. Create invoices under Sales → Invoice.
`; } else if (gstActiveTab === 'gstr3b') { const sales = computeGSTFromInvoices(); const purchases = computeGSTFromPurchases(); const outputGST = sales.reduce((s,r)=>s+r.gstTotal,0); const itcClaimed = purchases.reduce((s,r)=>s+r.gstTotal,0); const netPayable = Math.max(outputGST - itcClaimed, 0); const itcExcess = Math.max(itcClaimed - outputGST, 0); el.innerHTML = `
Output Tax Liability
${fmtINR(outputGST)}
From sales invoices
Input Tax Credit (ITC)
${fmtINR(itcClaimed)}
From purchases & bills
Net GST Payable
${fmtINR(netPayable)}
${netPayable>0?'To be paid to govt':'Nil / ITC excess'}
ITC Carry-Forward
${fmtINR(itcExcess)}
Excess ITC available
GSTR-3B Summary
DescriptionTaxable ValueCGSTSGSTTotal GST
3.1 Outward Taxable Supplies${fmtINR(sales.reduce((s,r)=>s+r.taxable,0))}${fmtINR(sales.reduce((s,r)=>s+r.cgst,0))}${fmtINR(sales.reduce((s,r)=>s+r.sgst,0))}${fmtINR(outputGST)}
4 Eligible ITC (Purchases)${fmtINR(purchases.reduce((s,r)=>s+r.taxable,0))}${fmtINR(purchases.reduce((s,r)=>s+r.cgst,0))}${fmtINR(purchases.reduce((s,r)=>s+r.sgst,0))}${fmtINR(itcClaimed)}
Net Tax Payable (3.1 − 4)${fmtINR(netPayable)}
`; } else if (gstActiveTab === 'itc') { const rows = computeGSTFromPurchases(); const total = rows.reduce((s,r)=>s+r.gstTotal,0); el.innerHTML = `
Total ITC Available
${fmtINR(total)}
From POs + Vendor Bills
CGST ITC
${fmtINR(rows.reduce((s,r)=>s+r.cgst,0))}
Central
SGST ITC
${fmtINR(rows.reduce((s,r)=>s+r.sgst,0))}
State
Input Tax Credit Register (Purchase Orders + Vendor Bills)
${rows.length ? rows.map(r => ``).join('') : ''}
Doc No.TypeDateVendorTaxable ValueCGST 9%SGST 9%ITC Total
${r.number}${r.number.startsWith('PO')?'PO':'Bill'}${r.date}${escHtml(r.party)}${fmtINR(r.taxable)}${fmtINR(r.cgst)}${fmtINR(r.sgst)}${fmtINR(r.gstTotal)}
No purchase orders or vendor bills yet.
`; } else if (gstActiveTab === 'liability') { const months = {}; [...(data.invoices||[])].forEach(inv => { const m = (inv.date||'').slice(0,7); if (!m) return; if (!months[m]) months[m] = { sales:0, purchases:0 }; months[m].sales += Number(inv.total||0) * GST_RATE; }); [...(data.purchaseOrders||[]), ...(data.vendorBills||[])].forEach(doc => { const m = (doc.date||'').slice(0,7); if (!m) return; if (!months[m]) months[m] = { sales:0, purchases:0 }; months[m].purchases += Number(doc.total||0) * GST_RATE; }); const sorted = Object.keys(months).sort().reverse(); el.innerHTML = `
Monthly GST Liability Statement
${sorted.length ? sorted.map(m => { const v = months[m]; const net = Math.max(v.sales - v.purchases, 0); return ``; }).join('') : ''}
MonthOutput GST (Sales)ITC (Purchases)Net PayableStatus
${m}${fmtINR(v.sales)}${fmtINR(v.purchases)}${fmtINR(net)}${net>0?'Payable':'NIL'}
No transactions yet to compute liability.
`; } } function exportGSTR1CSV() { const rows = computeGSTFromInvoices(); const csv = ['Invoice No.,Date,Party,Project,Taxable Value,CGST 9%,SGST 9%,Invoice Total', ...rows.map(r => { const proj = r.projectId ? data.projects.find(p=>p.id===r.projectId) : null; const pname = proj ? projNick(proj) : (r.projectLabel||''); return [r.number, r.date, `"${(r.party||'').replace(/"/g,'""')}"`, `"${pname.replace(/"/g,'""')}"`, r.taxable, r.cgst, r.sgst, r.taxable + r.gstTotal].join(','); })].join('\n'); const blob = new Blob([csv], { type: 'text/csv' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'GSTR1_' + dayKey(new Date()) + '.csv'; a.click(); } function renderTDSTab() { const el = document.getElementById('tdsContent'); if (!el) return; if (tdsActiveTab === 'deductions') { // TDS on contract payments (Section 194C — 1% individual, 2% company) const contractPayments = (data.accounts || []).filter(e => ['vendor-payment','purchase'].includes(e.type) && Number(e.amount||0) > 30000 ); const rows = contractPayments.map(e => { const rate = 0.02; // 2% TDS on contractor payments (companies) const tds = Math.round(Number(e.amount||0) * rate); const net = Number(e.amount||0) - tds; return { ...e, tdsRate: '2%', tds, net, section: '194C' }; }); const totalTDS = rows.reduce((s,r) => s + r.tds, 0); el.innerHTML = `
Total TDS Deducted
${fmtINR(totalTDS)}
Section 194C (2%)
Payments Above ₹30,000
${rows.length}
TDS applicable
Net Payable to Vendors
${fmtINR(rows.reduce((s,r)=>s+r.net,0))}
After TDS deduction
Note: TDS computed at 2% under Section 194C (contractor payments to companies). For individual contractors use 1%. Payments below ₹30,000 per transaction or ₹1L total per year are exempt. Consult your CA for exact applicability.
TDS Deductions Register
${rows.length ? rows.map(r => ``).join('') : ''}
DateVendor / PartySectionGross AmountTDS RateTDS AmountNet Payable
${r.date}${escHtml(r.party||'—')}${r.section}${fmtINR(r.amount)}${r.tdsRate}${fmtINR(r.tds)}${fmtINR(r.net)}
No vendor payments above ₹30,000 in Accounts yet.
`; } else if (tdsActiveTab === 'challan') { el.innerHTML = `
TDS Challan Tracker

Record TDS challans paid to the government (ITNS 281).

${renderTDSChallanRows()}
DateChallan No.SectionAmountRemark
`; } else if (tdsActiveTab === '26as') { const rows = (data.accounts || []).filter(e => ['vendor-payment','purchase'].includes(e.type) && Number(e.amount||0) > 30000); const tdsTotal = rows.reduce((s,e) => s + Math.round(Number(e.amount||0) * 0.02), 0); el.innerHTML = `
Form 26AS — TDS Summary
Total TDS (Sec 194C)
${fmtINR(tdsTotal)}
Computed from Accounts
TDS Challans Paid
${(data.tdsChallahs||[]).length}
Recorded challans
Difference
${fmtINR(tdsTotal - (data.tdsChallahs||[]).reduce((s,c)=>s+Number(c.amount||0),0))}
TDS due vs paid

This is a computed estimate. Download Form 26AS from the TRACES portal (https://www.tdscpc.gov.in) for the official statement.

`; } else if (tdsActiveTab === 'compliances') { // Project-wise statutory deductions (govt civil works compliance model) const projects = (data.projects || []).filter(p => (Number(p.estimate)||0) > 0); const rows = projects.map(p => ({ p, c: computeComplianceNet(Number(p.estimate)||0) })); const sum = (k) => rows.reduce((s,r) => s + (k==='est' ? (Number(r.p.estimate)||0) : r.c[k]), 0); const statusFilter = window._compStatusFilter || 'all'; const shown = rows.filter(r => matchStatus(r.p, statusFilter)); el.innerHTML = `
Total Estimate Value
${fmtINR(sum('est'))}
${rows.length} project${rows.length===1?'':'s'}
Total Deductions
${fmtINR(sum('totalDed'))}
All statutory heads
Total Withholding (5%)
${fmtINR(sum('wh'))}
Security / WH
Net Payable
${fmtINR(sum('net'))}
After all deductions
Deduction basis: EMD & ASD 2% + LWF 1% on Estimate; IT 1% + CGST 1% + SGST 1% + WH 5% on Basic Cost (Estimate ÷ 1.192). Net = Estimate − total deductions. Matches Thoothukudi Corporation bill format.
Project-wise Compliances
${statusFilterDropdown(statusFilter, "window._compStatusFilter=this.value;renderTDSTab();")}
${shown.length ? shown.map(({p,c}) => ``).join('') : ''} ${shown.length ? `` : ''}
ProjectStatus EstimateBasic EMD&ASD 2%LWF 1% IT 1%CGST 1%SGST 1% WH 5%Total Ded Net Payable
${escHtml(projNick(p))} ${escHtml(p.status||'—')} ${fmtINR(Number(p.estimate)||0)} ${fmtINR(c.basic)} ${fmtINR(c.emd)} ${fmtINR(c.lwf)} ${fmtINR(c.it)} ${fmtINR(c.cgst)} ${fmtINR(c.sgst)} ${fmtINR(c.wh)} ${fmtINR(c.totalDed)} ${fmtINR(c.net)}
No projects with an estimate value yet.
TOTAL ${fmtINR(shown.reduce((s,r)=>s+(Number(r.p.estimate)||0),0))} ${fmtINR(shown.reduce((s,r)=>s+r.c.basic,0))} ${fmtINR(shown.reduce((s,r)=>s+r.c.emd,0))} ${fmtINR(shown.reduce((s,r)=>s+r.c.lwf,0))} ${fmtINR(shown.reduce((s,r)=>s+r.c.it,0))} ${fmtINR(shown.reduce((s,r)=>s+r.c.cgst,0))} ${fmtINR(shown.reduce((s,r)=>s+r.c.sgst,0))} ${fmtINR(shown.reduce((s,r)=>s+r.c.wh,0))} ${fmtINR(shown.reduce((s,r)=>s+r.c.totalDed,0))} ${fmtINR(shown.reduce((s,r)=>s+r.c.net,0))}
`; } } function renderTDSChallanRows() { const list = data.tdsChallahs || []; if (!list.length) return 'No challans recorded yet'; return list.map((c,i) => `${c.date}${escHtml(c.challanNo)}${c.section}${fmtINR(c.amount)}${escHtml(c.remark||'—')}`).join(''); } function addTDSChallan() { const no = document.getElementById('tdsChallanNo')?.value.trim(); const date = document.getElementById('tdsChallanDate')?.value; const section = document.getElementById('tdsChallanSection')?.value; const amount = Number(document.getElementById('tdsChallanAmt')?.value || 0); const remark = document.getElementById('tdsChallanRemark')?.value.trim() || ''; if (!no || !date || !amount) return; data.tdsChallahs = Array.isArray(data.tdsChallahs) ? data.tdsChallahs : []; data.tdsChallahs.push({ challanNo: no, date, section, amount, remark }); saveData(); setTDSTab('challan'); } function delTDSChallan(idx) { (data.tdsChallahs || []).splice(idx, 1); saveData(); setTDSTab('challan'); } function exportTDSCSV() { const rows = (data.accounts || []).filter(e => ['vendor-payment','purchase'].includes(e.type) && Number(e.amount||0) > 30000); const csv = ['Date,Party,Section,Gross Amount,TDS Rate,TDS Amount,Net Payable', ...rows.map(e => { const tds = Math.round(Number(e.amount||0) * 0.02); return [e.date, `"${(e.party||'').replace(/"/g,'""')}"`, '194C', e.amount, '2%', tds, Number(e.amount||0) - tds].join(','); })].join('\n'); const blob = new Blob([csv], { type: 'text/csv' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'TDS_Register_' + dayKey(new Date()) + '.csv'; a.click(); } function renderProfessionalTax() { const el = document.getElementById('profitTaxContent'); if (!el) return; // PT slabs for Tamil Nadu (₹ per month) const TN_PT_SLABS = [ { min: 0, max: 21000, pt: 0 }, { min: 21001, max: 30000, pt: 135 }, { min: 30001, max: 45000, pt: 315 }, { min: 45001, max: 60000, pt: 690 }, { min: 60001, max: 75000, pt: 1025 }, { min: 75001, max: Infinity, pt: 1250 } ]; function ptForSalary(monthly) { const s = TN_PT_SLABS.find(sl => monthly >= sl.min && monthly <= sl.max); return s ? s.pt : 0; } const workers = (data.labours || []); const monthAttendance = {}; (data.attendance || []).forEach(att => { const m = (att.date||'').slice(0,7); if (!m) return; if (!monthAttendance[att.labourName]) monthAttendance[att.labourName] = {}; monthAttendance[att.labourName][m] = (monthAttendance[att.labourName][m] || 0) + att.wage; }); el.innerHTML = `
Workers on Register
${workers.length}
Human Resource roster
PT Slabs
Tamil Nadu
As per TN PT Act
Tamil Nadu Professional Tax Slabs (Monthly Salary)
Salary Range (₹/month)PT Amount (₹/month)
Up to ₹21,000Nil
₹21,001 – ₹30,000₹135
₹30,001 – ₹45,000₹315
₹45,001 – ₹60,000₹690
₹60,001 – ₹75,000₹1,025
Above ₹75,000₹1,250 (max)
Worker-wise Monthly PT Estimate
${workers.length ? workers.map(w => { const monthly = (w.dailyRate || 0) * 26; const pt = ptForSalary(monthly); return ``; }).join('') : ''}
WorkerDaily RateEst. Monthly Salary (26 days)PT / MonthAnnual PT
${escHtml(w.name)}${fmtINR(w.dailyRate||0)}/day${fmtINR(monthly)}${pt>0?fmtINR(pt):'Nil'}${pt>0?fmtINR(pt*12):'—'}
No workers in Human Resource roster yet.
`; } function renderTaxCalculator() { const el = document.getElementById('taxCalContent'); if (!el) return; el.innerHTML = `
🧮 GST Calculator
🧮 TDS Calculator
🧮 Professional Tax Calculator (Tamil Nadu)
`; } let gstCalMode = 'exclusive'; function setGSTCalMode(mode) { gstCalMode = mode; document.querySelectorAll('#gstCalMode .filter-btn').forEach(b => b.classList.toggle('active', b.dataset.mode === mode)); computeGSTCalc(); } function computeGSTCalc() { const amt = Number(document.getElementById('gstCalAmt')?.value || 0); const rate = Number(document.getElementById('gstCalRate')?.value || 18) / 100; const el = document.getElementById('gstCalResult'); if (!el || !amt) { if (el) el.innerHTML = ''; return; } let base, gst, total; if (gstCalMode === 'exclusive') { base = amt; gst = Math.round(amt * rate * 100) / 100; total = base + gst; } else { total = amt; base = Math.round(amt / (1 + rate) * 100) / 100; gst = total - base; } const half = Math.round(gst / 2 * 100) / 100; el.innerHTML = `
Taxable Value (Base)${fmtINR(base)}
CGST (${rate*50}%)${fmtINR(half)}
SGST (${rate*50}%)${fmtINR(half)}
Total (with GST)${fmtINR(total)}
`; } function computeTDSCalc() { const amt = Number(document.getElementById('tdsCalAmt')?.value || 0); const sectionVal = document.getElementById('tdsCalSection')?.value || '2'; const el = document.getElementById('tdsCalResult'); if (!el || !amt) { if (el) el.innerHTML = ''; return; } const rates = { '1': 0.01, '2': 0.02, '10': 0.10, '10r': 0.10 }; const rate = rates[sectionVal] || 0.02; const tds = Math.round(amt * rate * 100) / 100; const net = amt - tds; el.innerHTML = `
Gross Payment${fmtINR(amt)}
TDS Deduction (${(rate*100).toFixed(0)}%)${fmtINR(tds)}
Net Payable to Party${fmtINR(net)}
TDS to be deposited to Govt by 7th of next month${fmtINR(tds)}
`; } function computePTCalc() { const monthly = Number(document.getElementById('ptCalAmt')?.value || 0); const el = document.getElementById('ptCalResult'); if (!el || !monthly) { if (el) el.innerHTML = ''; return; } const slabs = [[0,21000,0],[21001,30000,135],[30001,45000,315],[45001,60000,690],[60001,75000,1025],[75001,Infinity,1250]]; const pt = (slabs.find(s => monthly >= s[0] && monthly <= s[1]) || [0,0,0])[2]; el.innerHTML = `
Monthly Salary${fmtINR(monthly)}
Professional Tax / Month${pt ? fmtINR(pt) : 'Nil'}
Professional Tax / Year${pt ? fmtINR(pt * 12) : 'Nil'}
`; } function renderTaxSection() { renderGSTTab(); renderTDSTab(); renderProfessionalTax(); renderTaxCalculator(); } // ═══════════════════ CAPEX / OPEX MODULE ═══════════════════ function capexAdd() { const name = document.getElementById('capexName')?.value.trim(); const amount = Math.round(Number(document.getElementById('capexAmount')?.value||0)); const cat = document.getElementById('capexCat')?.value || 'Machinery'; const date = document.getElementById('capexDate')?.value || dayKey(new Date()); const life = Number(document.getElementById('capexLife')?.value||0); const note = document.getElementById('capexNote')?.value.trim() || ''; if (!name || !amount) { alert('Enter asset name and amount.'); return; } data.capexEntries = Array.isArray(data.capexEntries) ? data.capexEntries : []; data.capexEntries.push({ id:Date.now(), name, amount, cat, date, life, note }); ['capexName','capexAmount','capexLife','capexNote'].forEach(id=>{const e=document.getElementById(id);if(e)e.value='';}); saveData(); renderCapexTab(); } function capexDel(id){ data.capexEntries=(data.capexEntries||[]).filter(x=>x.id!==id); saveData(); renderCapexTab(); } function capexEdit(id) { const e = (data.capexEntries||[]).find(x => x.id === id); if (!e) return; const catOpts = CAPEX_CATS.map(c=>``).join(''); openGenModal('Edit Capital Expenditure', `
`, () => { const name = document.getElementById('ecName').value.trim(); const amount = Math.round(Number(document.getElementById('ecAmount').value||0)); if (!name || !amount) { alert('Enter name and amount.'); return; } e.name = name; e.cat = document.getElementById('ecCat').value; e.amount = amount; e.life = Number(document.getElementById('ecLife').value||0); e.date = document.getElementById('ecDate').value || e.date; e.note = document.getElementById('ecNote').value.trim(); saveData(); renderCapexTab(); closeGenModal(); }); } function opexAdd() { const name = document.getElementById('opexName')?.value.trim(); const amount = Math.round(Number(document.getElementById('opexAmount')?.value||0)); const cat = document.getElementById('opexCat')?.value || 'Wages'; const date = document.getElementById('opexDate')?.value || dayKey(new Date()); const freq = document.getElementById('opexFreq')?.value || 'monthly'; const kind = document.getElementById('opexKind')?.value || 'fixed'; const note = document.getElementById('opexNote')?.value.trim() || ''; if (!name || !amount) { alert('Enter expense name and amount.'); return; } data.opexEntries = Array.isArray(data.opexEntries) ? data.opexEntries : []; data.opexEntries.push({ id:Date.now(), name, amount, cat, date, freq, kind, note, actuals:{} }); ['opexName','opexAmount','opexNote'].forEach(id=>{const e=document.getElementById(id);if(e)e.value='';}); saveData(); renderOpexTab(); } function opexDel(id){ data.opexEntries=(data.opexEntries||[]).filter(x=>x.id!==id); saveData(); renderOpexTab(); } function opexEdit(id) { const e = (data.opexEntries||[]).find(x => x.id === id); if (!e) return; const catOpts = OPEX_CATS.map(c=>``).join(''); const freqs = [['monthly','Monthly'],['weekly','Weekly'],['daily','Daily'],['yearly','Yearly'],['one-time','One-time']]; const freqOpts = freqs.map(([v,l])=>``).join(''); openGenModal('Edit Operating Expense', `
`, () => { const name = document.getElementById('eoName').value.trim(); const amount = Math.round(Number(document.getElementById('eoAmount').value||0)); if (!name || !amount) { alert('Enter name and amount.'); return; } e.name = name; e.cat = document.getElementById('eoCat').value; e.amount = amount; e.freq = document.getElementById('eoFreq').value; e.kind = document.getElementById('eoKind').value; e.date = document.getElementById('eoDate').value || e.date; e.note = document.getElementById('eoNote').value.trim(); saveData(); renderOpexTab(); closeGenModal(); }); } const CAPEX_CATS = ['Machinery','Vehicles','Equipment & Tools','Land & Building','Furniture','IT & Software','Other']; const OPEX_CATS = ['Wages','Material','Transport','Fuel','Rent','Utilities','Repairs & Maintenance','Office','Subcontractor','Insurance','Other']; function renderCapexTab() { const el = document.getElementById('capexContent'); if (!el) return; data.capexEntries = Array.isArray(data.capexEntries) ? data.capexEntries : []; // Pull machinery/equipment from Assets register too (read-only reference) const assetCapex = (data.assets||[]).filter(a => Number(a.rate||0) >= 0).map(a => ({ id:'asset_'+a.id, name:a.name, amount:0, cat:'Equipment & Tools', date:'', life:0, note:'From Assets register', _asset:true })); const manual = data.capexEntries.slice().sort((a,b)=>(b.date||'').localeCompare(a.date||'')); const total = manual.reduce((s,e)=>s+e.amount,0); const byCat = {}; manual.forEach(e=>{ byCat[e.cat]=(byCat[e.cat]||0)+e.amount; }); const catCards = Object.entries(byCat).sort((a,b)=>b[1]-a[1]).map(([c,v])=> `
${escHtml(c)}
${fmtINR(v)}
`).join(''); el.innerHTML = `
Total CapEx
${fmtINR(total)}
${manual.length} item${manual.length===1?'':'s'}
${catCards}
+ Add Capital Expenditure
Capital Assets Register
${manual.length ? manual.map(e=>{ const dep = e.life>0 ? Math.round(e.amount/e.life) : 0; return ``; }).join('') : ''} ${manual.length?``:''}
DateAsset / ItemCategoryAmountLifeAnnual Deprec.Note
${e.date||'—'} ${escHtml(e.name)} ${escHtml(e.cat)} ${fmtINR(e.amount)} ${e.life?e.life+'y':'—'} ${dep?fmtINR(dep):'—'} ${escHtml(e.note||'')}
No capital expenditure recorded yet.
TOTAL${fmtINR(total)}
`; } // ===== OpEx helpers: actual-vs-budget + admin staff attendance ===== const OPEX_MONTHLY = (e) => e.freq==='yearly' ? e.amount/12 : e.freq==='weekly' ? e.amount*4.33 : e.freq==='daily' ? e.amount*30 : e.freq==='one-time' ? 0 : e.amount; function opexMonth() { // Driven by the header date picker (period bar shows "Month" on this tab) try { const k = dayKey(anchorDate); if (k) return k.slice(0,7); } catch(e){} return window._opexMonth || dayKey(new Date()).slice(0,7); } function opexActualFor(e, month) { const a = e.actuals && e.actuals[month]; if (a != null && a !== '') return Math.round(Number(a)||0); return Math.round(OPEX_MONTHLY(e)); // default actual = budgeted run-rate until you enter the real figure } function opexHasActual(e, month) { return e.actuals && e.actuals[month] != null && e.actuals[month] !== ''; } function setOpexMonth(v) { window._opexMonth = v || dayKey(new Date()).slice(0,7); renderOpexTab(); } function opexShiftMonth(n) { const d = new Date(anchorDate); d.setDate(1); d.setMonth(d.getMonth() + n); anchorDate = d; const ae = document.getElementById('anchorDate'); if (ae) ae.valueAsDate = anchorDate; renderOpexTab(); } function opexThisMonth() { anchorDate = new Date(); const ae = document.getElementById('anchorDate'); if (ae) ae.valueAsDate = anchorDate; renderOpexTab(); } function toggleOpexSummary() { window._opexSumCollapsed = !window._opexSumCollapsed; try { localStorage.setItem('mydas_opexSumCollapsed', window._opexSumCollapsed ? '1' : '0'); } catch(e){} renderOpexTab(); } function opexSetActual(id, val) { const e = (data.opexEntries||[]).find(x=>x.id===id); if (!e) return; e.actuals = e.actuals || {}; const m = opexMonth(); if (val === '' || val == null) delete e.actuals[m]; else e.actuals[m] = Math.round(Number(val)||0); saveData(); renderOpexTab(); } // Admin staff function staffWD(s) { return Number(s.workingDays)||26; } function staffAbsent(s, month) { return Number((data.adminAttendance||{})[s.id+'|'+month])||0; } function staffPayable(s, month) { const wd = staffWD(s), ab = Math.min(Math.max(staffAbsent(s,month),0), wd); return Math.round((Number(s.salary)||0) * (1 - ab/wd)); } function addAdminStaff() { const name = document.getElementById('adsName')?.value.trim(); const role = document.getElementById('adsRole')?.value.trim() || ''; const salary = Math.round(Number(document.getElementById('adsSalary')?.value||0)); const workingDays = Number(document.getElementById('adsWorking')?.value||26); if (!name || !salary) { alert('Enter staff name and monthly salary.'); return; } data.adminStaff = Array.isArray(data.adminStaff) ? data.adminStaff : []; data.adminStaff.push({ id:Date.now(), name, role, salary, workingDays }); ['adsName','adsRole','adsSalary'].forEach(id=>{const e=document.getElementById(id);if(e)e.value='';}); saveData(); renderOpexTab(); } function delAdminStaff(id) { if (!confirm('Remove this staff member?')) return; data.adminStaff = (data.adminStaff||[]).filter(x=>x.id!==id); saveData(); renderOpexTab(); } function editAdminStaff(id) { const s = (data.adminStaff||[]).find(x=>x.id===id); if (!s) return; openGenModal('Edit Admin Staff', `
`, () => { const name = document.getElementById('esName').value.trim(); const salary = Math.round(Number(document.getElementById('esSalary').value||0)); if (!name || !salary) { alert('Enter name and salary.'); return; } s.name = name; s.role = document.getElementById('esRole').value.trim(); s.salary = salary; s.workingDays = Number(document.getElementById('esWorking').value||26); saveData(); renderOpexTab(); closeGenModal(); }); } function setStaffAbsent(id, val) { data.adminAttendance = data.adminAttendance || {}; const m = opexMonth(); const v = Math.max(0, Number(val)||0); if (!v) delete data.adminAttendance[id+'|'+m]; else data.adminAttendance[id+'|'+m] = v; saveData(); renderOpexTab(); } function renderOpexTab() { const el = document.getElementById('opexContent'); if (!el) return; data.opexEntries = Array.isArray(data.opexEntries) ? data.opexEntries : []; data.adminStaff = Array.isArray(data.adminStaff) ? data.adminStaff : []; data.adminAttendance = data.adminAttendance || {}; const month = opexMonth(); const monthLabel = new Date(month+'-01').toLocaleDateString('en-IN',{month:'long',year:'numeric'}); const list = data.opexEntries.slice().sort((a,b)=>(b.date||'').localeCompare(a.date||'')); // Budget (normalized run-rate) vs Actual for the selected month const budgetMonthly = list.reduce((s,e)=>s+OPEX_MONTHLY(e),0); const opexActualTotal = list.reduce((s,e)=>s+opexActualFor(e,month),0); const staffBudget = data.adminStaff.reduce((s,st)=>s+(Number(st.salary)||0),0); const staffActual = data.adminStaff.reduce((s,st)=>s+staffPayable(st,month),0); const totalBudget = budgetMonthly + staffBudget; const totalActual = opexActualTotal + staffActual; const variance = totalActual - totalBudget; // Category breakdown (by actual spend for the month) + salaries const byCat = {}; list.forEach(e=>{ byCat[e.cat]=(byCat[e.cat]||0)+opexActualFor(e,month); }); if (staffActual>0) byCat['Salaries']=(byCat['Salaries']||0)+staffActual; const catCards = Object.entries(byCat).filter(([,v])=>v>0).sort((a,b)=>b[1]-a[1]).map(([c,v])=> `
${escHtml(c)}
${fmtINR(Math.round(v))}
`).join(''); if (window._opexSumCollapsed === undefined) { try { window._opexSumCollapsed = localStorage.getItem('mydas_opexSumCollapsed')==='1'; } catch(e){ window._opexSumCollapsed=false; } } const collapsed = window._opexSumCollapsed; // Month navigation label parts const prevD = new Date(month+'-01'); prevD.setMonth(prevD.getMonth()-1); const nextD = new Date(month+'-01'); nextD.setMonth(nextD.getMonth()+1); el.innerHTML = `
${monthLabel}
Actuals & salaries are for ${escHtml(monthLabel)}
📊 Summary — ${escHtml(monthLabel)}
${collapsed?`Actual ${fmtINR(Math.round(totalActual))} · Budget ${fmtINR(Math.round(totalBudget))}`:''}
Budgeted (run-rate)
${fmtINR(Math.round(totalBudget))}
Opex + salaries / month
Actual — ${escHtml(monthLabel)}
${fmtINR(Math.round(totalActual))}
Real expenditure
Variance vs Budget
${variance>0?'+':''}${fmtINR(Math.round(variance))}
${variance>0?'Over budget':'Under / on budget'}
Admin Salaries
${fmtINR(Math.round(staffActual))}
${data.adminStaff.length} staff · after LOP
${catCards?`
By Category (actual)
${catCards}
`:''}
+ Add Operating Expenditure
💡 Mark consumption-based bills (EB, internet, diesel) as Variable — then enter the real amount each month in the Actual column. Fixed items (rent) default to budget.
Operating Expense Register
Actual column = ${escHtml(monthLabel)}
${list.length ? list.map(e=>{ const kind = e.kind || 'fixed'; const budget = Math.round(OPEX_MONTHLY(e)); const actVal = opexHasActual(e,month) ? Math.round(Number(e.actuals[month])) : ''; const shown = opexActualFor(e,month); const over = shown > budget; return ``; }).join('') : ''} ${list.length?``:''}
DateExpenseCategoryTypeFreqBudget (mo)Actual (${escHtml(month)})Note
${e.date||'—'} ${escHtml(e.name)} ${escHtml(e.cat)} ${kind==='variable'?'Variable':'Fixed'} ${escHtml(e.freq||'monthly')} ${fmtINR(budget)} ${escHtml(e.note||'')}
No operating expenditure recorded yet.
TOTAL${fmtINR(Math.round(budgetMonthly))}${fmtINR(opexActualTotal)}
👔 Admin Staff Attendance & Salaries
Salary auto-prorates by LOP days for ${escHtml(monthLabel)}
${data.adminStaff.length ? data.adminStaff.map(s=>{ const wd = staffWD(s), ab = staffAbsent(s,month), present = Math.max(0, wd-ab); const pay = staffPayable(s,month), full = Math.round(Number(s.salary)||0); return ``; }).join('') : ''} ${data.adminStaff.length?``:''}
StaffRoleMonthly SalaryWorking DaysAbsent (LOP)PresentPayable (${escHtml(month)})
${escHtml(s.name)} ${escHtml(s.role||'—')} ${fmtINR(full)} ${wd} ${present} ${fmtINR(pay)}${pay(−${fmtINR(full-pay)})`:''}
No admin staff yet — add supervisors / office staff above to track salary by attendance.
TOTAL${fmtINR(staffBudget)}${fmtINR(staffActual)}
`; } // ═══════════════════ AUDIT REGISTER ═══════════════════ let _auditDragKey = null; function renderAuditTab() { const el = document.getElementById('auditContent'); if (!el) return; const statusFilter = window._auditStatusFilter || 'completed'; // default Completed const periodMode = window._auditPeriod || 'all'; // 'all' | 'month' | 'week' const periodVal = window._auditPeriodVal || ''; // 'YYYY-MM' or 'YYYY-Www' let projects = (data.projects||[]).filter(p => (Number(p.estimate)||0) > 0); projects = projects.filter(p => matchStatus(p, statusFilter)); // Period filter (by project's completion/work-order/expected date where available) const projDate = (p) => p.completedDate || p.workOrderDate || p.expectedDate || p.startDate || ''; if (periodMode === 'month' && periodVal) { projects = projects.filter(p => (projDate(p)||'').startsWith(periodVal)); } else if (periodMode === 'week' && periodVal) { projects = projects.filter(p => { const d = projDate(p); return d && isoWeekOf(d) === periodVal; }); } // Per-project tick-box selection (empty set = show all that passed status/period) const selectedSet = window._auditSelectedProjects instanceof Set ? window._auditSelectedProjects : new Set(); const candidateProjects = projects.slice(); // for the picker list (status/period filtered) if (selectedSet.size > 0) { projects = projects.filter(p => selectedSet.has(String(p.id))); } const rows = projects.map(p => ({ p, c: computeComplianceNet(Number(p.estimate)||0) })); const sum = (k) => rows.reduce((s,r) => s + (k==='est' ? (Number(r.p.estimate)||0) : r.c[k]), 0); const tdsRows = (data.accounts||[]).filter(e => ['vendor-payment','purchase'].includes(e.type) && Number(e.amount||0) > 30000); const tdsTotal = tdsRows.reduce((s,e)=>s+Math.round(Number(e.amount||0)*0.02),0); const M=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; // ── Metric registry (live values) ── const metrics = { gross: { label:'Gross Estimate Value', value: sum('est'), color:'var(--sky)', sub:`${rows.length} project${rows.length===1?'':'s'}` }, totalded: { label:'Total Deductions', value: sum('totalDed'), color:'var(--rose)', sub:'All statutory heads' }, emd: { label:'EMD & ASD (2%)', value: sum('emd'), color:'var(--amber)', sub:'Earnest + security' }, lwf: { label:'LWF (1%)', value: sum('lwf'), color:'var(--violet)', sub:'Labour Welfare Fund' }, wh: { label:'Withholding (5%)', value: sum('wh'), color:'var(--amber)', sub:'Security retention' }, it: { label:'IT (1%)', value: sum('it'), color:'var(--sky)', sub:'Income Tax on basic' }, cgst: { label:'CGST (1%)', value: sum('cgst'), color:'var(--violet)', sub:'Central GST on basic' }, sgst: { label:'SGST (1%)', value: sum('sgst'), color:'var(--violet)', sub:'State GST on basic' }, tds: { label:'TDS (Sec 194C)', value: tdsTotal, color:'var(--rose)', sub:'On vendor payments' }, net: { label:'Net to Contractor', value: sum('net'), color:'var(--accent)', sub:'After all deductions' }, gstwh: { label:'GST Withheld (8%+8%)', value: sum('gstWithheld'), color:'var(--rose)', sub:'CGST 8% + SGST 8% on basic' }, grosschq: { label:'Net − (CGST 8% + SGST 8%)', value: sum('grossCheque'), color:'var(--accent)', sub:'Gross cheque to contractor' }, }; window._auditMetrics = metrics; const DEFAULT_ORDER = ['gross','totalded','emd','lwf','wh','it','cgst','sgst','tds','net','gstwh','grosschq']; let order = Array.isArray(data.auditCardOrder) && data.auditCardOrder.length ? data.auditCardOrder.slice() : DEFAULT_ORDER.slice(); // Ensure any new default keys appear, drop stale DEFAULT_ORDER.forEach(k => { if (!order.includes(k)) order.push(k); }); const customCards = Array.isArray(data.auditCustomCards) ? data.auditCustomCards : []; customCards.forEach(c => { if (!order.includes('custom_'+c.id)) order.push('custom_'+c.id); }); order = order.filter(k => metrics[k] || (k.startsWith('custom_') && customCards.find(c=>'custom_'+c.id===k))); const cardHtml = (key) => { let m, isCustom = false, cid = null; if (key.startsWith('custom_')) { cid = key.slice(7); isCustom = true; const cc = customCards.find(c => String(c.id) === String(cid)); if (!cc) return ''; if (cc.mode === 'metric' && metrics[cc.metric]) { m = { label: cc.label, value: metrics[cc.metric].value, color: cc.color, sub: metrics[cc.metric].sub, raw:false }; } else { const num = Number(cc.value); m = { label: cc.label, value: isNaN(num)?cc.value:num, color: cc.color, sub: cc.sub||'', raw: isNaN(num) }; } } else { m = metrics[key]; } if (!m) return ''; const valStr = m.raw ? escHtml(String(m.value)) : fmtINR(m.value); return `
${isCustom?``:''}
${escHtml(m.label)}
${valStr}
${escHtml(m.sub||'')}
`; }; // Period filter controls const now = new Date(); const curMonth = dayKey(now).slice(0,7); const curWeek = isoWeekOf(dayKey(now)); const periodControls = `
${periodMode==='month'?``:''} ${periodMode==='week'?``:''}
`; el.innerHTML = `
🔍 Statutory Deductions Audit
Audit trail of all deductions — EMD & ASD, LWF, IT, CGST, SGST, Withholding, TDS & net payment. Drag cards to reorder.
${periodControls}
${rows.length} project${rows.length===1?'':'s'} in view${periodMode!=='all'?' · '+(periodVal||''):''}
${order.map(cardHtml).join('')}
Audit Register — Statutory Deductions
${statusFilterDropdown(statusFilter, "window._auditStatusFilter=this.value;renderAuditTab();")}
Tick projects to include (none ticked = all ${candidateProjects.length})
${candidateProjects.length ? candidateProjects.map(p => ` `).join('') : '
No projects match the current status/period filter.
'}
${rows.length ? rows.map(({p,c})=>``).join('') : ''} ${rows.length?``:''}
ProjectStatus EstimateBasic EMD&ASDLWFIT CGSTSGSTWH Total Ded. Net Payment GST WH (8+8%) Gross Cheque
${escHtml(projNick(p))} ${escHtml(p.status||'—')} ${fmtINR(Number(p.estimate)||0)} ${fmtINR(c.basic)} ${fmtINR(c.emd)} ${fmtINR(c.lwf)} ${fmtINR(c.it)} ${fmtINR(c.cgst)} ${fmtINR(c.sgst)} ${fmtINR(c.wh)} ${fmtINR(c.totalDed)} ${fmtINR(c.net)} ${fmtINR(c.gstWithheld)} ${fmtINR(c.grossCheque)}
No projects with an estimate to audit in this view.
TOTAL ${fmtINR(sum('est'))}${fmtINR(sum('basic'))} ${fmtINR(sum('emd'))}${fmtINR(sum('lwf'))}${fmtINR(sum('it'))} ${fmtINR(sum('cgst'))}${fmtINR(sum('sgst'))}${fmtINR(sum('wh'))} ${fmtINR(sum('totalDed'))} ${fmtINR(sum('net'))} ${fmtINR(sum('gstWithheld'))} ${fmtINR(sum('grossCheque'))}
TDS on Vendor Payments (Sec 194C — 2%)
${tdsRows.length ? tdsRows.map(e=>{const t=Math.round(Number(e.amount||0)*0.02);return ``;}).join('') : ''} ${tdsRows.length?``:''}
DateVendor / PartyGrossTDS 2%Net
${e.date||e.dueDate||'—'}${escHtml(e.party||'—')}${fmtINR(e.amount)}${fmtINR(t)}${fmtINR(Number(e.amount||0)-t)}
No vendor payments above ₹30,000.
TOTAL TDS${fmtINR(tdsTotal)}
`; } function auditSetPeriod(mode, val) { window._auditPeriod = mode; window._auditPeriodVal = val || ''; renderAuditTab(); } function auditToggleProjectPicker() { window._auditPickerOpen = !window._auditPickerOpen; renderAuditTab(); } function auditToggleProject(pid, checked) { if (!(window._auditSelectedProjects instanceof Set)) window._auditSelectedProjects = new Set(); if (checked) window._auditSelectedProjects.add(String(pid)); else window._auditSelectedProjects.delete(String(pid)); renderAuditTab(); } function auditSelectAllProjects(selectAll) { if (!(window._auditSelectedProjects instanceof Set)) window._auditSelectedProjects = new Set(); if (!selectAll) { window._auditSelectedProjects.clear(); renderAuditTab(); return; } // Select all currently-candidate projects (status/period filtered) const statusFilter = window._auditStatusFilter || 'completed'; const periodMode = window._auditPeriod || 'all'; const periodVal = window._auditPeriodVal || ''; const projDate = (p) => p.completedDate || p.workOrderDate || p.expectedDate || p.startDate || ''; let cand = (data.projects||[]).filter(p => (Number(p.estimate)||0) > 0).filter(p => matchStatus(p, statusFilter)); if (periodMode === 'month' && periodVal) cand = cand.filter(p => (projDate(p)||'').startsWith(periodVal)); else if (periodMode === 'week' && periodVal) cand = cand.filter(p => { const d=projDate(p); return d && isoWeekOf(d)===periodVal; }); cand.forEach(p => window._auditSelectedProjects.add(String(p.id))); renderAuditTab(); } // ISO week string YYYY-Www from a YYYY-MM-DD date function isoWeekOf(dateStr) { if (!dateStr) return ''; const d = new Date(dateStr + 'T00:00:00'); if (isNaN(d)) return ''; const tmp = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())); const dayNum = (tmp.getUTCDay() + 6) % 7; tmp.setUTCDate(tmp.getUTCDate() - dayNum + 3); const firstThu = new Date(Date.UTC(tmp.getUTCFullYear(), 0, 4)); const week = 1 + Math.round(((tmp - firstThu) / 86400000 - 3 + ((firstThu.getUTCDay() + 6) % 7)) / 7); return tmp.getUTCFullYear() + '-W' + String(week).padStart(2, '0'); } // ── Audit card drag-drop ── function auditCardDragStart(e, key) { _auditDragKey = key; e.dataTransfer.effectAllowed = 'move'; try { e.dataTransfer.setData('text/plain', key); } catch(_) {} e.currentTarget.style.opacity = '0.45'; } function auditCardDragOver(e) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; const card = e.currentTarget; if (card && card.dataset.cardkey !== _auditDragKey) card.style.outline = '2px dashed var(--sky)'; } function auditCardDragEnd(e) { if (e.currentTarget) e.currentTarget.style.opacity = ''; document.querySelectorAll('#auditKPIs .kpi-card').forEach(c => c.style.outline = ''); } function auditCardDrop(e, targetKey) { e.preventDefault(); document.querySelectorAll('#auditKPIs .kpi-card').forEach(c => c.style.outline = ''); const src = _auditDragKey; _auditDragKey = null; if (!src || src === targetKey) return; const DEFAULT_ORDER = ['gross','totalded','emd','lwf','wh','it','cgst','sgst','tds','net','gstwh','grosschq']; let order = Array.isArray(data.auditCardOrder) && data.auditCardOrder.length ? data.auditCardOrder.slice() : DEFAULT_ORDER.slice(); (data.auditCustomCards||[]).forEach(c => { if (!order.includes('custom_'+c.id)) order.push('custom_'+c.id); }); DEFAULT_ORDER.forEach(k => { if (!order.includes(k)) order.push(k); }); const from = order.indexOf(src), to = order.indexOf(targetKey); if (from < 0 || to < 0) return; order.splice(from, 1); order.splice(order.indexOf(targetKey) + (from < to ? 1 : 0), 0, src); data.auditCardOrder = order; saveData(); renderAuditTab(); } function auditDeleteCustomCard(cid) { if (!confirm('Delete this custom card?')) return; data.auditCustomCards = (data.auditCustomCards||[]).filter(c => String(c.id) !== String(cid)); data.auditCardOrder = (data.auditCardOrder||[]).filter(k => k !== 'custom_'+cid); saveData(); renderAuditTab(); } function auditAddCardModal() { const metrics = window._auditMetrics || {}; const metricOpts = Object.entries(metrics) .map(([k,m]) => ``).join(''); const COLORS = [['var(--violet)','Violet'],['var(--sky)','Blue'],['var(--accent)','Green'],['var(--amber)','Amber'],['var(--rose)','Red']]; openGenModal('+ Add Audit Card', `
`, () => { const mode = document.getElementById('acMode').value; const label = document.getElementById('acLabel').value.trim(); const color = document.getElementById('acColor').value; if (!label) { alert('Enter a card label.'); return; } const card = { id: Date.now(), mode, label, color }; if (mode === 'metric') { card.metric = document.getElementById('acMetric').value; } else { card.value = document.getElementById('acValue').value.trim(); card.sub = document.getElementById('acSub').value.trim(); if (card.value === '') { alert('Enter a value for the manual card.'); return; } } data.auditCustomCards = Array.isArray(data.auditCustomCards) ? data.auditCustomCards : []; data.auditCustomCards.push(card); data.auditCardOrder = Array.isArray(data.auditCardOrder) ? data.auditCardOrder : []; data.auditCardOrder.push('custom_'+card.id); saveData(); renderAuditTab(); closeGenModal(); }); setTimeout(auditAddCardModeChanged, 0); } function auditAddCardModeChanged() { const mode = document.getElementById('acMode')?.value; const mw = document.getElementById('acMetricWrap'); const vw = document.getElementById('acValueWrap'); const sw = document.getElementById('acSubWrap'); if (!mw || !vw) return; const manual = mode === 'manual'; mw.style.display = manual ? 'none' : ''; vw.style.display = manual ? '' : 'none'; if (sw) sw.style.display = manual ? '' : 'none'; } function exportAuditCSV() { const statusFilter = window._auditStatusFilter || 'completed'; let projects = (data.projects||[]).filter(p => (Number(p.estimate)||0) > 0); projects = projects.filter(p => matchStatus(p, statusFilter)); const sel = window._auditSelectedProjects instanceof Set ? window._auditSelectedProjects : new Set(); if (sel.size > 0) projects = projects.filter(p => sel.has(String(p.id))); const head = ['Project','Status','Estimate','Basic','EMD&ASD','LWF','IT','CGST','SGST','WH','TotalDeductions','NetPayment','GST_WH_8+8','GrossCheque']; const lines = [head.join(',')]; projects.forEach(p => { const c = computeComplianceNet(Number(p.estimate)||0); lines.push([`"${projNick(p)}"`, p.status||'', Number(p.estimate)||0, c.basic, c.emd, c.lwf, c.it, c.cgst, c.sgst, c.wh, c.totalDed, c.net, c.gstWithheld, c.grossCheque].join(',')); }); const blob = new Blob([lines.join('\n')], {type:'text/csv'}); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'audit_deductions.csv'; a.click(); URL.revokeObjectURL(a.href); } // ===== FUNDS NEEDED MODULE ===== let fundFilter = 'all'; const FUND_CATEGORIES = { Wages: { label: '👷 Wages', color: 'var(--sky)' }, Material: { label: '🧱 Material', color: 'var(--amber)' }, Transport: { label: '🚛 Transport', color: 'var(--violet)' }, Equipment: { label: '⚙️ Equipment', color: 'var(--rose)' }, Subcontractor: { label: '🏗 Subcontractor', color: 'var(--accent)' }, Utility: { label: '💡 Utility', color: '#06b6d4' }, Miscellaneous: { label: '📦 Misc', color: 'var(--text-muted)' }, // Kept for backward compat with old keys wages:{label:'👷 Wages',color:'var(--sky)'},material:{label:'🧱 Material',color:'var(--amber)'}, transport:{label:'🚛 Transport',color:'var(--violet)'},equipment:{label:'⚙️ Equipment',color:'var(--rose)'}, subcon:{label:'🏗 Subcontractor',color:'var(--accent)'},utility:{label:'💡 Utility',color:'#06b6d4'}, misc:{label:'📦 Misc',color:'var(--text-muted)'} }; // Dynamic fallback for custom/free-text categories function getFundCat(key) { return FUND_CATEGORIES[key] || { label: `🏷 ${key}`, color: 'var(--violet)' }; } function setFundFilter(f) { fundFilter = f; document.querySelectorAll('#fundStatusFilter .filter-btn').forEach(b => b.classList.toggle('active', b.dataset.fs === f)); renderFunds(); } function getFundFormValues() { const desc = document.getElementById('fundDesc')?.value.trim(); const amount = Number(document.getElementById('fundAmount')?.value || 0); const catEl = document.getElementById('fundCategory'); const cat = (catEl?.value || '').trim() || 'Miscellaneous'; const vendor = document.getElementById('fundVendor')?.value.trim() || ''; const projLinkEl = document.getElementById('fundProjectLink'); const projLinkId = projLinkEl?.value || ''; const projText = document.getElementById('fundProject')?.value.trim() || ''; const linkedProj = projLinkId ? (data.projects||[]).find(p => String(p.id) === projLinkId) : null; const projectId = linkedProj ? linkedProj.id : null; const projectLabel = linkedProj ? projNick(linkedProj) : (projText || null); const date = document.getElementById('fundDate')?.value || dayKey(new Date()); const priority = document.getElementById('fundPriority')?.value || 'medium'; return { desc, amount, cat, vendor, projectId, projectLabel, date, priority, valid: !!(desc && amount) }; } function clearFundForm() { ['fundDesc','fundAmount','fundCategory','fundVendor','fundProject'].forEach(id => { const el=document.getElementById(id); if(el) el.value=''; }); const projEl = document.getElementById('fundProjectLink'); if(projEl) projEl.value=''; } function addFund(alsoTask=false) { const v = getFundFormValues(); if (!v.valid) return; data.funds = Array.isArray(data.funds) ? data.funds : []; data.fundCategories = Array.isArray(data.fundCategories) ? data.fundCategories : []; if (v.cat && !data.fundCategories.includes(v.cat)) data.fundCategories.push(v.cat); const fund = { id: Date.now(), desc: v.desc, amount: v.amount, cat: v.cat, vendor: v.vendor, projectId: v.projectId, projectLabel: v.projectLabel, date: v.date, priority: v.priority, status: 'pending' }; data.funds.push(fund); if (alsoTask) { // Create a corresponding daily task data.tasks = Array.isArray(data.tasks) ? data.tasks : []; const taskText = `[${v.cat}] ${v.desc} — ₹${Number(v.amount).toLocaleString('en-IN')}`; data.tasks.push({ id: Date.now() + 1, text: taskText, completed: false, date: v.date, freq: 'once', reminderTime: '', remindedOn: '', timeSpent: 0, urgent: v.priority === 'high', important: v.priority === 'high' || v.priority === 'medium', category: v.cat, fundRef: fund.id }); showToast(`✅ Fund requirement added + Task created for ${v.date}`); } clearFundForm(); saveData(); renderAll(); } function addFundAsTask() { addFund(true); } function showToast(msg) { let t = document.getElementById('mydasToast'); if (!t) { t = document.createElement('div'); t.id='mydasToast'; t.style.cssText='position:fixed;bottom:80px;left:50%;transform:translateX(-50%);background:var(--bg-elevated);border:1px solid var(--accent);color:var(--accent);padding:10px 20px;border-radius:20px;font-size:13px;font-weight:600;z-index:99998;box-shadow:0 4px 20px rgba(0,0,0,0.2);transition:opacity 0.3s;'; document.body.appendChild(t); } t.textContent = msg; t.style.opacity='1'; t.style.display=''; clearTimeout(t._timer); t._timer = setTimeout(()=>{ t.style.opacity='0'; setTimeout(()=>t.style.display='none',300); }, 2500); } function editFund(id) { const f = (data.funds||[]).find(x=>x.id===id); if (!f) return; const allCats = Array.from(new Set(['Wages','Material','Transport','Equipment','Subcontractor','Utility','Miscellaneous',...(Array.isArray(data.fundCategories)?data.fundCategories:[]),...(data.funds||[]).map(x=>x.cat).filter(Boolean)])).sort(); const projOpts = '' + (data.projects||[]).map(p=>``).join(''); openGenModal('✎ Edit Fund Requirement', `
${allCats.map(c=>``).join('')}
`, () => { f.desc = document.getElementById('efDesc').value.trim() || f.desc; f.amount = Number(document.getElementById('efAmount').value || f.amount); f.cat = document.getElementById('efCat').value.trim() || f.cat; const pid = document.getElementById('efProject').value; const lp = pid ? (data.projects||[]).find(p=>String(p.id)===pid) : null; f.projectId = lp ? lp.id : null; f.projectLabel = lp ? projNick(lp) : null; f.date = document.getElementById('efDate').value || f.date; f.priority = document.getElementById('efPriority').value; saveData(); renderAll(); closeGenModal(); }); } function toggleFundStatus(id) { const f = (data.funds || []).find(x => x.id === id); if (!f) return; if (f.status === 'paid') { f.status = 'pending'; saveData(); renderAll(); return; } showMarkPaidModal(id); } function showMarkPaidModal(fundId) { document.getElementById('markPaidModalOverlay')?.remove(); const f = (data.funds||[]).find(x=>x.id===fundId); if (!f) return; const projects = (data.projects||[]).filter(p=>p.status!=='completed'); const vendors = data.vendors||[]; const labours = data.labours||[]; const totalPaidSoFar = (f.paidHistory||[]).reduce((s,p)=>s+p.amount,0); const remaining = Math.max(0, f.amount - totalPaidSoFar); const overlay = document.createElement('div'); overlay.id = 'markPaidModalOverlay'; overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.45);backdrop-filter:blur(8px);z-index:9999;display:flex;align-items:center;justify-content:center;padding:16px;'; overlay.addEventListener('click', e => { if(e.target===overlay) overlay.remove(); }); // Transparent overlay doesn't steal hovers - use pointer-events:none overlay.style.pointerEvents = 'none'; overlay.innerHTML = `
Mark Payment
${escHtml(f.desc)}  ·  ${fmtINR(f.amount)}${totalPaidSoFar>0?'  ·  Paid so far: '+fmtINR(totalPaidSoFar)+'':''}
Payment Date
Payment Mode
Payment Category
Note (optional)
${(f.paidHistory||[]).length > 0 ? `
Payment History
${(f.paidHistory||[]).map(p=>`
${p.date}  ·  ${p.mode||'cash'} ${fmtINR(p.amount)}
`).join('')}
Total paid${fmtINR(totalPaidSoFar)}
` : ''}
`; document.body.appendChild(overlay); window._mpType = 'full'; window._mpFundId = fundId; } // Render dynamic sub-select based on chosen category function mpRenderSubSelect() { const cat = document.getElementById('mpCategory')?.value; const area = document.getElementById('mpSubSelectArea'); if (!area) return; const projects = (data.projects||[]).filter(p=>p.status!=='completed'); const vendors = data.vendors||[]; const labours = data.labours||[]; if (cat === 'project') { area.innerHTML = `
Select Project
`; } else if (cat === 'vendor') { area.innerHTML = `
Select Vendor
`; } else if (cat === 'labour') { area.innerHTML = `
Select Worker
Purpose
`; // Show week/period selector setTimeout(mpRenderLabourPurpose, 50); } else { area.innerHTML = ''; if (document.getElementById('mpSubLink')) document.getElementById('mpSubLink').remove(); } } function mpRenderLabourPurpose() { const weekRow = document.getElementById('mpLabourWeekRow'); if (!weekRow) return; const purpose = document.getElementById('mpSubPurpose')?.value; if (purpose === 'salary') { weekRow.innerHTML = `
Period / Week
`; } else { weekRow.innerHTML = ''; } } function mpSetType(type) { window._mpType = type; const full = document.getElementById('mpFull'), part = document.getElementById('mpPartial'), row = document.getElementById('mpPartialRow'); if (type === 'full') { full.style.cssText += ';border-color:var(--accent);background:rgba(48,209,88,0.10);color:var(--accent);'; part.style.cssText += ';border-color:var(--border);background:transparent;color:var(--text-muted);'; if(row) row.style.display='none'; } else { part.style.cssText += ';border-color:var(--amber);background:rgba(255,159,10,0.10);color:var(--amber);'; full.style.cssText += ';border-color:var(--border);background:transparent;color:var(--text-muted);'; if(row) row.style.display='block'; } } function confirmMarkPaid(fundId) { const f = (data.funds||[]).find(x=>x.id===fundId); if (!f) return; const type = window._mpType || 'full'; const amtPaid = type==='full' ? f.amount : (Number(document.getElementById('mpAmount')?.value)||0); const date = document.getElementById('mpDate')?.value || dayKey(new Date()); const note = document.getElementById('mpNote')?.value || ''; const mode = document.getElementById('mpMode')?.value || 'cash'; // Build link from cascading dropdowns const cat = document.getElementById('mpCategory')?.value || ''; const sub = document.getElementById('mpSubLink')?.value || ''; const labId = document.getElementById('mpSubLabour')?.value || ''; const purp = document.getElementById('mpSubPurpose')?.value || ''; const period = document.getElementById('mpLabourPeriod')?.value || ''; let link = cat; if (cat==='project' && sub) link = sub; else if (cat==='vendor' && sub) link = sub; else if (cat==='labour') link = 'lab_'+(purp||'salary')+':'+(labId||'all')+(period?':'+period:''); if (type==='partial' && amtPaid<=0) { alert('Enter the amount paid'); return; } f.paidHistory = f.paidHistory || []; f.paidHistory.push({ date, amount:amtPaid, link, note, mode, type }); const totalPaid = f.paidHistory.reduce((s,p)=>s+p.amount, 0); if (type==='full' || totalPaid >= f.amount) { f.status='paid'; f.paidDate=date; f.paidLink=link; f.paidNote=note; } else { f.status='partial'; f.partialPaid=totalPaid; } // Record labour advance if linked if ((link.startsWith('lab_advance:') || link.startsWith('lab_adv:')) && !link.endsWith(':all')) { const labId=Number(link.split(':')[1]); const lab=(data.labours||[]).find(l=>l.id===labId); if (lab) { lab.advances=lab.advances||[]; lab.advances.push({id:Date.now(),date,amount:amtPaid,note,mode,fundId}); } } document.getElementById('markPaidModalOverlay')?.remove(); saveData(); renderAll(); } function delFund(id) { data.funds = (data.funds || []).filter(x => x.id !== id); saveData(); renderAll(); } // ===== FUNDS SUB-TAB SWITCHER ===== let fundsActiveTab = 'needs'; function setFundsTab(tab) { fundsActiveTab = tab; document.querySelectorAll('#fundsTabSwitch .filter-btn').forEach(b => b.classList.toggle('active', b.dataset.ftab === tab)); document.getElementById('fundsTabNeeds').style.display = tab === 'needs' ? '' : 'none'; document.getElementById('fundsTabBorrow').style.display = tab === 'borrow' ? '' : 'none'; document.getElementById('fundsTabPlanner').style.display = tab === 'planner' ? '' : 'none'; document.getElementById('fundsTabBalance').style.display = tab === 'balance' ? '' : 'none'; document.getElementById('fundsTabBanks').style.display = tab === 'banks' ? '' : 'none'; document.getElementById('fundsTabForecast').style.display = tab === 'forecast' ? '' : 'none'; document.getElementById('fundsTabProfitFirst').style.display = tab === 'profitfirst' ? '' : 'none'; document.getElementById('fundsTabBalSheet').style.display = tab === 'balsheet' ? '' : 'none'; if (tab === 'borrow') { renderLoans(); loanCategoryChanged(); } if (tab === 'planner') renderRepayPlanner(); if (tab === 'balance') renderBalanceViews(); if (tab === 'banks') renderBanksTab(); if (tab === 'forecast') renderForecastTab(); if (tab === 'profitfirst') renderProfitFirst(); if (tab === 'balsheet') renderBalanceSheet(); if (['aop','amj','jas','ndj','jfm'].includes(tab)) renderQuarterPlan(tab); if (tab === 'sop') renderSOP(); if (tab === 'personal') renderPersonalBudget(); if (tab === 'personalemi') renderPersonalEMI(); if (tab === 'personalspend') renderPersonalSpend(); if (tab === 'personalnet') renderPersonalNetWorth(); if (tab === 'personalnws') renderPersonalNWS(); if (tab === 'assets') renderAssetsPage(); if (tab === 'library') renderLibraryPage(); } // ===== PLANNING MODULE (AOP + Quarters + SOP) ===== function planData() { if (!data.planning) data.planning = { aop: { year: new Date().getFullYear(), vision: '', revenueTarget: 0, profitTarget: 0, debtReductionTarget: 0, newProjectsTarget: 0, teamTarget: 0, cashReserveTarget: 0, goals: [], // { id, text, category, quarter, done, priority } capex: [], // { id, item, amount, quarter, done } risks: [], // { id, risk, mitigation, severity } kpis: [], // { id, name, target, unit, current } notes: '' }, quarters: { amj: { revenue:0, expenses:0, projects:[], goals:[], milestones:[], risks:[], actions:[], notes:'', status:'planned', debtTarget:0, collectionTarget:0 }, jas: { revenue:0, expenses:0, projects:[], goals:[], milestones:[], risks:[], actions:[], notes:'', status:'planned', debtTarget:0, collectionTarget:0 }, ndj: { revenue:0, expenses:0, projects:[], goals:[], milestones:[], risks:[], actions:[], notes:'', status:'planned', debtTarget:0, collectionTarget:0 }, jfm: { revenue:0, expenses:0, projects:[], goals:[], milestones:[], risks:[], actions:[], notes:'', status:'planned', debtTarget:0, collectionTarget:0 } }, sop: { procedures: [ { id:'sop1', category:'Site', title:'Site Mobilisation', owner:'Supervisor', freq:'Project Start', steps:['Obtain project documents, drawings and BOQ','Set up site office, material store and safety board','Deploy supervisor with site register and attendance book','Confirm material schedule and delivery dates with vendors','Photograph site in "before" state, upload to Library','Erect name board with client/contractor/project details'], tags:['project-start'] }, { id:'sop2', category:'Finance', title:'Weekly Cost Review', owner:'Newton', freq:'Every Saturday', steps:['Pull labour attendance and payment sheet from supervisor','Match material delivery notes to purchase orders','Update project spend vs budget in MYDAS Projects tab','Flag any budget variance >5% to Newton immediately','Record week-end account balances in DBS, IOB, KBL, HDFC','Note any unresolved vendor invoices'], tags:['weekly'] }, { id:'sop3', category:'Finance', title:'Monthly Bank Reconciliation', owner:'Newton', freq:'1st of each month', steps:['Download statements from all banks (DBS, IOB, KBL, HDFC, Cash)','Import CSV/Excel into MYDAS → Funds → Banks tab','Match every debit to a project, vendor or EMI','Match every credit to a client invoice or receipt','Flag unmatched transactions as "Needs Review"','Compare closing balances to MYDAS account ledger','Resolve all flags before the 10th'], tags:['monthly'] }, { id:'sop4', category:'Billing', title:'Client Invoice Submission', owner:'Newton', freq:'Per Milestone', steps:['Get supervisor sign-off on measurement book','Attach work completion photos (before/after)','Prepare GST invoice: correct HSN, tax rate, PAN, GSTIN','Submit invoice to client and log in MYDAS Invoices tab','Follow up in 7 days if no acknowledgement','Record invoice in accounts receivable tracker'], tags:['milestone'] }, { id:'sop5', category:'Tax', title:'GST Monthly Filing', owner:'Newton', freq:'Monthly (by 20th)', steps:['Export all sales invoices for the month from MYDAS','Verify ITC credit on all material purchase invoices','Reconcile GSTR-2A with purchase register','File GSTR-1 by 11th of following month','File GSTR-3B by 20th of following month','Transfer net GST liability to Tax account in Profit First'], tags:['monthly'] }, { id:'sop6', category:'HR', title:'Labour Payment Process', owner:'Supervisor', freq:'Every Saturday', steps:['Collect signed attendance from site supervisor','Verify days worked vs site register — no register, no pay','Calculate wages at agreed daily rate per worker','Pay wages via cash/UPI on Saturday before 6PM','Record each payment in MYDAS Labour tab with day count and mode','Obtain signed payment receipt or WhatsApp confirmation'], tags:['weekly'] }, { id:'sop7', category:'Vendor', title:'Material Order & Receipt', owner:'Supervisor', freq:'As needed', steps:['Get quotation from minimum 3 vendors for any order >₹5,000','Select vendor based on price, quality, and reliability history','Raise Purchase Order in MYDAS with agreed quantity and rate','Receive material at site — count and inspect before accepting','Reject and return any substandard or short material immediately','Attach delivery note to vendor bill before filing'], tags:['ad-hoc'] }, { id:'sop8', category:'Finance', title:'Profit First Rhythm Check-in', owner:'Newton', freq:'1st, 10th, 25th', steps:['Log into MYDAS → Funds → Profit First tab','Check all 4 account balances (Income, Operating, Owner Pay, Tax)','Allocate any unallocated income to the 4 accounts','Verify Operating balance covers all pending bills','Transfer Owner Pay portion to personal account','Apply Owner Pay % to debt repayment (use interlink)','Log check-in observations and one action item'], tags:['fortnightly'] }, { id:'sop9', category:'Project', title:'Project Close-Out', owner:'Newton', freq:'Project End', steps:['Complete final joint measurement with client engineer','Obtain signed completion certificate from client','Raise final invoice including any retention release','Collect all outstanding payments before demobilising site','Return all hired equipment and clear site','Archive all project documents, photos, and correspondence in MYDAS Library','Conduct brief internal review: what went well, what to improve'], tags:['project-end'] }, { id:'sop10', category:'Tax', title:'TDS Deduction & Filing', owner:'Newton', freq:'Monthly (by 7th)', steps:['Identify all sub-contractor payments exceeding ₹30,000 in the month','Deduct TDS @ 1% under Section 194C at source','Remit TDS to government by 7th of next month via Challan 281','Issue Form 16A to sub-contractor within 15 days of filing','File TDS return quarterly (Form 26Q) before due date','Reconcile TDS records with MYDAS Finance tab'], tags:['monthly'] }, { id:'sop11', category:'Procurement','title':'Vendor Empanelment', owner:'Newton', freq:'Annually', steps:['List all material categories required for year ahead','Invite quotations from at least 3 vendors per category','Evaluate: price, quality record, credit terms, delivery reliability','Negotiate rate contracts valid for 6 months','Sign rate agreement with selected vendors','Update preferred vendor list in MYDAS Vendor tab'], tags:['annual'] }, { id:'sop12', category:'Safety', title:'Weekly Site Safety Check', owner:'Supervisor', freq:'Every Monday', steps:['Inspect scaffolding, ladders, and working platforms','Check that all workers are using PPE (helmets, shoes, gloves)','Verify first aid kit is stocked and accessible','Ensure power tools are in working condition with guards fitted','Check material storage areas for fire/trip hazards','Log safety observations in site register'], tags:['weekly'] }, ] }, ownerPayDebtPlan: { enabled: true, allocationPct: 50, targetLoanId: null, strategy: 'avalanche', // avalanche=highest-rate-first, snowball=smallest-balance-first schedule: [], monthlyLog: [] } }; return data.planning; } function planSave() { saveData(); } // ══════════════════════════════════════════════════════════════════════ // OWNER PAY → DEBT REPAYMENT INTERLINK // ══════════════════════════════════════════════════════════════════════ function renderOwnerPayDebtPanel() { const pf = pfData(); const plan = planData(); const opd = plan.ownerPayDebtPlan; const loans = (data.loans||[]).filter(l => l.status !== 'closed'); const ownerBal = pf.balances.ownerPay || 0; const debtAmt = Math.round(ownerBal * (opd.allocationPct||0) / 100); const targetLoan = loans.find(l => String(l.id) === String(opd.targetLoanId)); // Strategy: sort loans for recommendation const avalanche = [...loans].sort((a,b) => b.rate - a.rate); const snowball = [...loans].sort((a,b) => getLoanBalance(a) - getLoanBalance(b)); const recommended = opd.strategy === 'avalanche' ? avalanche[0] : snowball[0]; // Projection: how long to clear target loan at this rate const monthlyDebt = debtAmt * 3; // 3 check-ins per month const monthsToFree = targetLoan && monthlyDebt > 0 ? Math.ceil(getLoanBalance(targetLoan) / monthlyDebt) : null; // Total monthly EMI relief if target is cleared const emiRelief = targetLoan?.emi || 0; const IC = { link: ``, money: ``, arrow: ``, fire: ``, chart: ``, }; return `
${IC.link} Owner Pay ${IC.arrow} Debt Repayment — Interlink
Every time you receive Owner Pay, a set % is earmarked for debt repayment. Applied 3×/month (on the 1st, 10th, 25th check-in). Remaining balance stays as your salary.
Owner Pay Balance
${fmtINR(ownerBal)}
Earmarked for Debt
${fmtINR(debtAmt)}
${opd.allocationPct||0}% of balance
Kept as Salary
${fmtINR(ownerBal - debtAmt)}
${100-(opd.allocationPct||0)}% of balance
${recommended ? `
${IC.fire} ${opd.strategy==='avalanche'?'Avalanche':'Snowball'} Recommendation: ${escHtml(recommended.lender)} — Balance ${fmtINR(getLoanBalance(recommended))} @ ${recommended.rate}% ${opd.strategy==='avalanche'?' (highest interest rate — tackle this first)':' (smallest balance — quick win)'}
` : ''} ${targetLoan ? `
${IC.chart} Projection: ${escHtml(targetLoan.lender)}
Current Balance
${fmtINR(getLoanBalance(targetLoan))}
Rate
${targetLoan.rate}% p.a.
Monthly Debt Payment
${fmtINR(monthlyDebt)}
Months to Clear
${monthsToFree ?? '?'} mo
Est. Free Date
${monthsToFree ? (() => { const d=new Date(); d.setMonth(d.getMonth()+monthsToFree); return d.toLocaleDateString('en-IN',{month:'short',year:'numeric'}); })() : '—'}
EMI Relief After
${fmtINR(emiRelief)}/mo
${Math.round((1 - getLoanBalance(targetLoan)/targetLoan.principal)*100)}% of principal cleared
` : `
Select a target loan above to see repayment projection and apply payment.
`} ${(opd.schedule||[]).length > 0 ? `
Transfer History
${opd.schedule.slice(-5).reverse().map(s=>`
${s.date} ${fmtINR(s.amount)} ${escHtml(s.lender)}
`).join('')}
` : ''}
`; } function applyOwnerPayDebt() { const pf = pfData(); const plan = planData(); const opd = plan.ownerPayDebtPlan; const loan = (data.loans||[]).find(l => String(l.id) === String(opd.targetLoanId)); if (!loan) { alert('Select a target loan first'); return; } const debtAmt = Math.round((pf.balances.ownerPay||0) * (opd.allocationPct||0) / 100); if (debtAmt <= 0) { alert('Owner Pay balance or allocation % is zero'); return; } loan.repayments = loan.repayments || []; loan.repayments.push({ date: dayKey(new Date()), amount: debtAmt, interest: 0, total: debtAmt, mode: 'principal-only', note: 'Profit First owner pay allocation' }); pf.balances.ownerPay = Math.max((pf.balances.ownerPay||0) - debtAmt, 0); opd.schedule.push({ date: dayKey(new Date()), lender: loan.lender, amount: debtAmt }); saveData(); renderProfitFirst(); renderLoans(); alert(`Applied ${fmtINR(debtAmt)} from Owner Pay to ${loan.lender}.`); } // ══════════════════════════════════════════════════════════════════════ // QUARTER META // ══════════════════════════════════════════════════════════════════════ const QUARTER_META = { amj: { label:'AMJ', months:'April · May · June', icon:'🍸', color:'var(--sky)', q:'Q1', fin:'Apr–Jun' }, jas: { label:'JAS', months:'July · August · September', icon:'🌝', color:'var(--accent)', q:'Q2', fin:'Jul–Sep' }, ndj: { label:'NDJ', months:'October · November · December', icon:'🍂', color:'var(--amber)', q:'Q3', fin:'Oct–Dec' }, jfm: { label:'JFM', months:'January · February · March', icon:'❄', color:'var(--violet)', q:'Q4', fin:'Jan–Mar' } }; // Quarter selection helpers window._qSel = window._qSel || {}; function qSelInit(qKey, allIds) { if (!window._qSel[qKey]) window._qSel[qKey] = new Set(allIds); document.querySelectorAll('.qchk_' + qKey).forEach(cb => { const sel = window._qSel[qKey].has(cb.dataset.pid); cb.checked = sel; const row = document.getElementById('qrow_' + cb.dataset.pid); if (row) row.style.background = sel ? 'rgba(48,209,88,0.06)' : ''; }); const allChk = document.getElementById('qSelAll_' + qKey); if (allChk) { const sz = window._qSel[qKey].size, tot = allIds.length; allChk.checked = sz === tot && tot > 0; allChk.indeterminate = sz > 0 && sz < tot; } updateQSelTotal(qKey); } function qSelChange(qKey, pid, checked, total) { window._qSel[qKey] = window._qSel[qKey] || new Set(); const s = window._qSel[qKey]; if (checked) s.add(pid); else s.delete(pid); const row = document.getElementById('qrow_' + pid); if (row) row.style.background = checked ? 'rgba(48,209,88,0.06)' : ''; const allChk = document.getElementById('qSelAll_' + qKey); if (allChk) { allChk.checked = s.size === total && total > 0; allChk.indeterminate = s.size > 0 && s.size < total; } updateQSelTotal(qKey); } function qSelToggleAll(qKey, checked, total) { window._qSel[qKey] = window._qSel[qKey] || new Set(); document.querySelectorAll('.qchk_' + qKey).forEach(cb => { cb.checked = checked; if (checked) window._qSel[qKey].add(cb.dataset.pid); else window._qSel[qKey].delete(cb.dataset.pid); const row = document.getElementById('qrow_' + cb.dataset.pid); if (row) row.style.background = checked ? 'rgba(48,209,88,0.06)' : ''; }); updateQSelTotal(qKey); } function qSelApply(qKey) { const s = window._qSel?.[qKey]; if (!s) return; let total = 0; document.querySelectorAll('.qchk_' + qKey).forEach(cb => { if (s.has(cb.dataset.pid)) total += Number(cb.dataset.val) || 0; }); planData().quarters[qKey].collectionTarget = total; planSave(); renderQuarterPlan(qKey); } function updateQSelTotal(qKey) { const s = window._qSel?.[qKey]; if (!s) return; const chks = document.querySelectorAll('.qchk_' + qKey); let total = 0, count = 0; chks.forEach(cb => { if (s.has(cb.dataset.pid)) { total += Number(cb.dataset.val)||0; count++; } }); const fmt = v => '\u20b9' + Math.round(v).toLocaleString('en-IN'); const tc = document.getElementById('qSelCount_' + qKey); const tt = document.getElementById('qSelTotal_' + qKey); const tc2= document.getElementById('qSelCount2_' + qKey); const tf = document.getElementById('qSelFooter_' + qKey); if (tc) tc.textContent = count; if (tt) tt.textContent = fmt(total); if (tc2) tc2.textContent = count + ' selected'; if (tf) tf.textContent = 'Sel: ' + fmt(total); } function renderQuarterPlan(qKey) { if (qKey === 'aop') { renderAOP(); return; } const el = document.getElementById(qKey + 'Content'); if (!el) return; const plan = planData(); const qd = plan.quarters[qKey] || {}; const qm = QUARTER_META[qKey]; const aop = plan.aop; const allProj = (data.projects||[]); const activeProj = allProj.filter(p => p.status !== 'completed'); const loans = (data.loans||[]).filter(l => l.status !== 'closed'); const totalDebt = loans.reduce((s,l) => s+getLoanBalance(l), 0); // ── Quarter date range ──────────────────────────────────────────── const year = aop.year || new Date().getFullYear(); const QRANGE = { amj: { start: `${year}-04-01`, end: `${year}-06-30`, label:'Apr–Jun' }, jas: { start: `${year}-07-01`, end: `${year}-09-30`, label:'Jul–Sep' }, ndj: { start: `${year}-10-01`, end: `${year}-12-31`, label:'Oct–Dec' }, jfm: { start: `${year+1}-01-01`, end: `${year+1}-03-31`, label:'Jan–Mar' }, }; const qRange = QRANGE[qKey] || {}; // ── Projects ending this quarter (auto-detected by endDate) ─────── // Filter state per quarter (status, category, search) window._qFilters = window._qFilters || {}; if (!window._qFilters[qKey]) window._qFilters[qKey] = { status: 'all', category: 'all', search: '' }; const qf = window._qFilters[qKey]; // All projects ending this quarter by endDate const projDueThisQtrAll = allProj.filter(p => { if (!p.endDate) return false; return p.endDate >= qRange.start && p.endDate <= qRange.end; }); // Unique filter options const qStatuses = [...new Set(projDueThisQtrAll.map(p=>p.status).filter(Boolean))].sort(); const qCats = [...new Set(projDueThisQtrAll.map(p=>p.category||'government').filter(Boolean))].sort(); // Apply filters let projDueThisQtr = projDueThisQtrAll; if (qf.status !== 'all') projDueThisQtr = projDueThisQtr.filter(p => p.status === qf.status); if (qf.category !== 'all') projDueThisQtr = projDueThisQtr.filter(p => (p.category||'government') === qf.category); if (qf.search) { const sq = qf.search.toLowerCase(); projDueThisQtr = projDueThisQtr.filter(p => projNick(p).toLowerCase().includes(sq) || (p.zone||'').toLowerCase().includes(sq) || (p.client||'').toLowerCase().includes(sq) || (p.company||'').toLowerCase().includes(sq)); } // Manually linked projects const linkedProjIds = new Set((qd.projects||[]).map(String)); const allQProjs = allProj.filter(p => linkedProjIds.has(String(p.id)) || (p.endDate >= qRange.start && p.endDate <= qRange.end) ); // ── Auto-calculate collection target from due projects ──────────── // Expected collection from client = full contract value (estimate) // p.amountNeeded = capital Newton still needs to SPEND (not what client pays) const autoCollectionTarget = projDueThisQtr.reduce((s,p) => { const already = Number(p.collected) || 0; return s + Math.max((Number(p.estimate)||0) - already, 0); }, 0); // Auto-fill collection target if not manually set if (!qd.collectionTarget && autoCollectionTarget > 0) { plan.quarters[qKey].collectionTarget = autoCollectionTarget; planSave(); } const qRevPct = aop.revenueTarget > 0 ? Math.round((qd.revenue||0)/aop.revenueTarget*100) : 0; const qProfit = (qd.revenue||0) - (qd.expenses||0); const qProjVal = allQProjs.reduce((s,p) => s+(Number(p.estimate)||0), 0); const qCollect = qd.collectionTarget || autoCollectionTarget; const qDebtTgt = qd.debtTarget || 0; const CAT_PILL = { government:'pill-violet', private:'pill-sky', residential:'pill-green', commercial:'pill-sky', maintenance:'pill-amber', other:'pill-sky' }; const STATUS_COLOR = { 'in-progress':'var(--sky)', planning:'var(--amber)', 'on-hold':'var(--rose)', completed:'var(--accent)' }; el.innerHTML = `
${qm.icon}
${qm.label} — ${qm.months}
${qm.q} Operating Plan  ·  Linked to AOP ${aop.year}  ·  ${projDueThisQtrAll.length} project${projDueThisQtrAll.length!==1?'s':''} due ${qRange.label}
Revenue Target
${qm.months}
Budgeted Expenses
operating costs
Expected Collections ${autoCollectionTarget > 0 ? `Auto` : ''}
full contract values  ··· ${projDueThisQtrAll.length} project${projDueThisQtrAll.length!==1?'s':''} due
Debt Reduction Target
loans to repay this quarter
Est. Quarter Profit
${fmtINR(Math.abs(qProfit))}
${qProfit>=0?'surplus':'deficit'}
AOP Contribution
${qRevPct}%
of annual target
Projects Due This Quarter — ${qRange.label} ${aop.year}
Auto-detected by completion date  ·  Expected collection auto-calculated
${fmtINR(autoCollectionTarget)}
total expected
Status
${['all',...qStatuses].map(s => ``).join('')}
Category
${['all',...qCats].map(c => ``).join('')}
🔍
${projDueThisQtr.length} / ${projDueThisQtrAll.length} ${(qf.status!=='all'||qf.category!=='all'||qf.search)?``:''}
${projDueThisQtr.length > 0 ? `
Selected: 0 projects
Expected Collection: ₹0
${projDueThisQtr.map(p => { const fc = computeProjectForecast(p); const alreadyCollected = Number(p.collected) || 0; const contractValue = Number(p.estimate) || 0; const expected = Math.max(contractValue - alreadyCollected, 0); const daysLeft = p.endDate ? Math.ceil((new Date(p.endDate) - new Date()) / 86400000) : null; const overdue = daysLeft != null && daysLeft < 0; const urgent = daysLeft != null && daysLeft >= 0 && daysLeft <= 14; const pid = String(p.id); return ``; }).join('')}
Project Zone / Client Category Due Date Budget Spent Progress Expected Collection Status
${escHtml(projNick(p))}
${escHtml(p.company||'')}
${escHtml(p.zone||'')}${p.client?'
'+escHtml(p.client)+'
':''}
${projectCategoryLabel[p.category]||p.category||''} ${p.endDate}
${overdue?'⚠ '+Math.abs(daysLeft)+'d overdue':daysLeft===0?'Due today':daysLeft!=null?daysLeft+'d left':''}
${fmtINR(contractValue)} ${fmtINR(fc.spent)}
${fc.progress}%
${fmtINR(expected)}
${alreadyCollected>0?`
Rcvd: ${fmtINR(alreadyCollected)}
`:''}
${p.status||''}
${projDueThisQtr.length} projects   full contract values ${fmtINR(projDueThisQtr.reduce((s,p)=>s+Math.max((Number(p.estimate)||0)-(Number(p.collected)||0),0),0))}
${qDebtTgt > 0 ? `
Collection vs Debt Repayment Plan
Expected Collection
${fmtINR(autoCollectionTarget)}
Debt Target
${fmtINR(qDebtTgt)}
Free Cash After Debt
${fmtINR(Math.max(autoCollectionTarget-qDebtTgt,0))}
` : ''} ` : `
No projects found with completion date in ${qRange.label} ${aop.year}.
Set project end dates in Construction Projects to auto-populate this quarter. Or manually link projects using the checklist below.
`}
Link Projects
${allQProjs.length} linked
${allProj.map(p => { const checked = linkedProjIds.has(String(p.id)); const autoDue = p.endDate >= qRange.start && p.endDate <= qRange.end; const fc = computeProjectForecast(p); return ``; }).join('')} ${!allProj.length ? '
No active projects
' : ''}
Quarter Goals
${(qd.goals||[]).map((g,i) => `
${escHtml(g.text)}
`).join('')} ${!(qd.goals||[]).length?'
No goals yet
':''}
Key Actions — ${qm.label}
${(qd.actions||[]).map((a,i) => `
${escHtml(a.text)} ${a.priority||'normal'}
`).join('')}
Notes & Decisions
`; setTimeout(() => qSelInit(qKey, projDueThisQtr.map(p => String(p.id))), 40); } function renderAOP() { const el = document.getElementById('aopContent'); if (!el) return; const plan = planData(); const aop = plan.aop; const qKeys = ['amj','jas','ndj','jfm']; const allQ = qKeys.map(k => plan.quarters[k]); const totalQRev = allQ.reduce((s,q) => s+(q.revenue||0), 0); const totalQExp = allQ.reduce((s,q) => s+(q.expenses||0), 0); const totalQColl = allQ.reduce((s,q) => s+(q.collectionTarget||0), 0); const totalQDebt = allQ.reduce((s,q) => s+(q.debtTarget||0), 0); const loans = (data.loans||[]).filter(l => l.status !== 'closed'); const totalDebt = loans.reduce((s,l) => s+getLoanBalance(l), 0); const allProjs = (data.projects||[]).filter(p => p.status !== 'completed'); const pipeline = allProjs.reduce((s,p) => s+(Number(p.estimate)||0), 0); const revPct = aop.revenueTarget > 0 ? Math.round(totalQRev/aop.revenueTarget*100) : 0; el.innerHTML = `
📅 Annual Operating Plan — ${aop.year}
Your business blueprint for the full financial year · 4 quarters · All targets in one view
🌟 Vision & Annual Targets
${[ { label:'Revenue Target', key:'revenueTarget', color:'var(--sky)', placeholder:'Annual revenue ₹' }, { label:'Profit Target', key:'profitTarget', color:'var(--accent)', placeholder:'Net profit target ₹' }, { label:'Debt Reduction', key:'debtReductionTarget', color:'var(--rose)', placeholder:'Loans to clear ₹' }, { label:'New Projects', key:'newProjectsTarget', color:'var(--violet)', placeholder:'# of new projects', isNum:true }, { label:'Team Size', key:'teamTarget', color:'var(--amber)', placeholder:'# of staff/labourers', isNum:true }, { label:'Cash Reserve', key:'cashReserveTarget', color:'var(--sky)', placeholder:'Target cash buffer ₹' }, ].map(t => `
${t.label}
`).join('')}
📈 AOP vs Quarter Breakdown
Annual Target
${fmtINR(aop.revenueTarget)}
Planned (Qtrs)
${fmtINR(totalQRev)}
${revPct}% of target
Total Expenses
${fmtINR(totalQExp)}
Debt Reduction
${fmtINR(totalQDebt)}
Collections
${fmtINR(totalQColl)}
Project Pipeline
${fmtINR(pipeline)}
${allProjs.length} active
Revenue Plan by Quarter
Enter targets in each quarter tab. Click a quarter label to open it.
${qKeys.map(k => { const q = plan.quarters[k]; const qm = QUARTER_META[k]; const pct = aop.revenueTarget > 0 ? Math.min((q.revenue||0)/aop.revenueTarget*100, 100) : 0; const hasRev = (q.revenue||0) > 0; return `
${hasRev ? fmtINR(q.revenue||0) : 'Not set'} ${Math.round(pct)}% ${q.status||'planned'}
`; }).join('')}
🎯 Annual Goals
${(aop.goals||[]).filter(g=>g.done).length}/${(aop.goals||[]).length} done  ·  by quarter
${['amj','jas','ndj','jfm',''].map(qk => { const qm = QUARTER_META[qk]; const colClr = qk ? (QUARTER_META[qk]?.color || 'var(--sky)') : 'var(--text-muted)'; const colLbl = qk ? qk.toUpperCase() : 'All Year'; const colSub = qk ? (QUARTER_META[qk]?.months || '') : 'Not quarter-specific'; const CAT_COLOR = {revenue:'var(--sky)',project:'var(--violet)',finance:'var(--accent)',team:'var(--amber)',general:'var(--text-muted)'}; const CAT_ICON = {revenue:'₹',project:'🛠',finance:'📈',team:'👥',general:'●'}; const colGoals = (aop.goals||[]).filter(g => (g.quarter||'') === qk); return `
${colLbl}
${colSub}
${colGoals.length === 0 ? `
No goals
` : colGoals.map((g) => { const gi = (aop.goals||[]).indexOf(g); const cc = CAT_COLOR[g.category]||"var(--text-muted)"; const ci = CAT_ICON[g.category]||""; return `
${escHtml(g.text||"(no text)")}
${ci} ${g.category||"general"} ${g.priority==="high"?'High':""}
`; }).join("") }
`; }).join("")}
🔢 Capital Expenditure Plan
${fmtINR((aop.capex||[]).reduce((s,c)=>s+(c.amount||0),0))} planned
${(aop.capex||[]).length ? `${(aop.capex||[]).map((c,i)=>``).join('')}
ItemQuarterAmountDone
${escHtml(c.item)} ${(c.quarter||'').toUpperCase()} ${fmtINR(c.amount||0)}
` : '
No CapEx items planned
'}
📋 AOP Notes & Strategic Decisions
`; } // ══════════════════════════════════════════════════════════════════════ // SOP — SYSTEMATIC OPERATING PROCEDURES // ══════════════════════════════════════════════════════════════════════ function renderSOP() { const el = document.getElementById('sopContent'); if (!el) return; const plan = planData(); const procs = plan.sop.procedures; const cats = [...new Set(procs.map(p => p.category))]; const freq = ['weekly','fortnightly','monthly','milestone','project-start','project-end','ad-hoc','annual']; const CAT_COLOR = { Site:'var(--amber)',Finance:'var(--sky)',Billing:'var(--accent)',Tax:'var(--rose)', HR:'var(--violet)',Vendor:'var(--amber)',Project:'var(--sky)',Procurement:'var(--accent)', Safety:'var(--rose)',Compliance:'var(--violet)' }; const FREQ_COLOR = { weekly:'var(--sky)',fortnightly:'var(--violet)',monthly:'var(--amber)', milestone:'var(--accent)',annual:'var(--rose)','project-start':'var(--accent)','project-end':'var(--rose)','ad-hoc':'var(--text-muted)' }; el.innerHTML = `
📋 Systematic Operating Procedures
${procs.length} standard procedures  ·  ${cats.length} categories  ·  Your construction business playbook
Filter: ${['all',...freq].map(f => ``).join('')}
${cats.map(cat => { const catProcs = procs.filter(p => p.category === cat); return `
 
${cat} ${catProcs.length} procedure${catProcs.length>1?'s':''}
${catProcs.map((proc, pi) => { const pi2 = procs.indexOf(proc); return `
${escHtml(proc.title)}
${proc.freq||proc.tags?.[0]||''} Owner: ${escHtml(proc.owner||'—')}
${(proc.steps||[]).map((step, si) => `
${si+1}
${escHtml(step)}
`).join('')}
`; }).join('')}
`; }).join('')}
📝 Tip: Use browser print (Ctrl+P) to export these procedures as a laminated reference sheet for your site supervisor.
`; } function sopFilter(f) { document.querySelectorAll('[id^="sopF_"]').forEach(b => { b.style.background=''; b.style.color=''; }); const btn = document.getElementById('sopF_'+f); if (btn) { btn.style.background='var(--accent)'; btn.style.color='#fff'; } document.querySelectorAll('.sop-card').forEach(c => { const proc = planData().sop.procedures.find(p => 'sopCard_'+p.id === c.id); c.style.display = (!proc || f==='all' || (proc.freq||'').toLowerCase().includes(f) || (proc.tags||[]).includes(f)) ? '' : 'none'; }); } function printSOP() { const plan = planData(); const procs = plan.sop.procedures; const cats = [...new Set(procs.map(p => p.category))]; const CAT_COLORS = {Site:"#f59e0b",Finance:"#0ea5e9",Billing:"#22c55e",Tax:"#ef4444",HR:"#7c3aed",Vendor:"#f59e0b",Project:"#0ea5e9",Procurement:"#22c55e",Safety:"#ef4444",Compliance:"#7c3aed"}; const now = new Date().toLocaleDateString('en-IN', {day:'2-digit',month:'short',year:'numeric'}); const html = ` MYDAS - Standard Operating Procedures
MYDAS SOP Print View

Standard Operating Procedures

Your Construction Business Playbook  |  ${procs.length} procedures  |  Generated: ${now}
${cats.map(cat => { const cc = CAT_COLORS[cat] || "#6b7280"; const catProcs = procs.filter(p => p.category === cat); return `
${cat} (${catProcs.length})
${catProcs.map(proc => `
${proc.title||""}
Freq: ${proc.freq||""} Owner: ${proc.owner||""}
${(proc.steps||[]).map((step,si) => `
${si+1}
${step}
`).join("")}
`).join("")}
`; }).join("")} `; const w = window.open("", "_blank", "width=900,height=700"); if (w) { w.document.write(html); w.document.close(); } } function addSOP() { openGenModal('📋 Add New Procedure', `
`, () => { const t = document.getElementById('sopT').value.trim(); if (!t) return; const steps = (document.getElementById('sopSteps').value||'').split('\n').map(s=>s.trim()).filter(Boolean); planData().sop.procedures.push({ id: 'sop_' + Date.now(), category: document.getElementById('sopCat').value, title: t, owner: document.getElementById('sopOwn').value.trim() || 'Newton', freq: document.getElementById('sopFreq').value, steps, tags: [] }); planSave(); renderSOP(); closeGenModal(); }); } function deleteSOP(idx) { if (!confirm('Delete this procedure?')) return; planData().sop.procedures.splice(idx, 1); planSave(); renderSOP(); } function editSOP(idx) { const proc = planData().sop.procedures[idx]; if (!proc) return; openGenModal('✎ Edit Procedure', `
`, () => { proc.title = document.getElementById('sopT').value.trim(); proc.category = document.getElementById('sopCat').value; proc.owner = document.getElementById('sopOwn').value.trim(); proc.freq = document.getElementById('sopFreq').value.trim(); proc.steps = (document.getElementById('sopSteps').value||'').split('\n').map(s=>s.trim()).filter(Boolean); planSave(); renderSOP(); closeGenModal(); }); } // ===== PROFIT FIRST MODULE ===== // Based on Mike Michalowicz's Profit First system function pfData() { if (!data.profitFirst) data.profitFirst = { allocations: { profit: 1, ownerPay: 30, tax: 15, operating: 54 }, rhythmLog: [], incomeEntries: [], transfers: [], balances: { income: 0, operating: 0, ownerPay: 0, tax: 0, profit: 0 } }; return data.profitFirst; } function pfSave() { saveData(); renderProfitFirst(); } function pfDistributeBalance() { const pf = pfData(); const bal = pf.balances.income || 0; if (bal <= 0) { alert("Enter an Income A/c balance first"); return; } const al = pf.allocations; const tot = al.profit + al.ownerPay + al.tax + al.operating || 100; const split = { operating: Math.floor(bal * al.operating / tot), ownerPay: Math.floor(bal * al.ownerPay / tot), tax: Math.floor(bal * al.tax / tot), profit: 0 }; split.profit = bal - split.operating - split.ownerPay - split.tax; const msg = "Distribute " + fmtINR(bal) + " from Income A/c:\n\n" + " Operating: " + fmtINR(split.operating) + "\n" + " Owner Pay: " + fmtINR(split.ownerPay) + "\n" + " Tax: " + fmtINR(split.tax) + "\n" + " Profit: " + fmtINR(split.profit) + "\n\nProceed?"; if (!confirm(msg)) return; pf.balances.income = 0; pf.balances.operating = Math.round((pf.balances.operating||0) + split.operating); pf.balances.ownerPay = Math.round((pf.balances.ownerPay||0) + split.ownerPay); pf.balances.tax = Math.round((pf.balances.tax||0) + split.tax); pf.balances.profit = Math.round((pf.balances.profit||0) + split.profit); pf.transfers = pf.transfers || []; // Record a transfer PER unallocated income entry so the per-project / per-date // breakdown is preserved on each account card. Remainder (manual balance with no // matching entry) is logged as a single "Income A/c" transfer. const pending = (pf.incomeEntries||[]).filter(e => !e.allocated); const pendingTotal = pending.reduce((s,e)=>s+(e.amt||0),0); pending.forEach(e => { const amt = e.amt || 0; if (amt <= 0) return; const es = { operating: Math.round(amt * al.operating / tot * 100)/100, ownerPay: Math.round(amt * al.ownerPay / tot * 100)/100, tax: Math.round(amt * al.tax / tot * 100)/100, profit: Math.round(amt * al.profit / tot * 100)/100 }; pf.transfers.push({ id: Date.now()+Math.floor(Math.random()*100000), date: dayKey(new Date()), entryId: e.id, amt, split: es, src: e.src || e.source || '', projId: e.projId||null, srcDate: e.date || dayKey(new Date()), note: 'From: ' + (e.src||e.source||'') }); e.allocated = true; }); const remainder = bal - pendingTotal; if (remainder > 0.5) { const rs = { operating: Math.round(remainder * al.operating / tot * 100)/100, ownerPay: Math.round(remainder * al.ownerPay / tot * 100)/100, tax: Math.round(remainder * al.tax / tot * 100)/100, profit: Math.round(remainder * al.profit / tot * 100)/100 }; pf.transfers.push({ id: Date.now()+Math.floor(Math.random()*100000), date: dayKey(new Date()), amt: remainder, split: rs, src: 'Income A/c (manual)', projId: null, srcDate: dayKey(new Date()), note: 'Distribute from Income A/c' }); } pfSave(); renderProfitFirst(); } // Feed an Expected-IN (Finance) entry into the Profit First Income A/c function pfFeedExpected(acctId, overrideAmt) { const e = (data.accounts||[]).find(x => x.id === acctId); if (!e || e.type !== 'payment-in') return; if (e.status !== 'pending') { alert('This expected income has already been fed in.'); return; } const amt = Math.round(Number(overrideAmt || e.amount || 0)); if (!amt) return; const proj = e.projectId ? (data.projects||[]).find(p=>p.id===e.projectId) : null; const pf = pfData(); pf.incomeEntries = pf.incomeEntries || []; // Duplicate guard: this exact account was already fed in if (pf.incomeEntries.some(x => x.fromAcctId === acctId)) { alert('This expected income has already been fed into the Income A/c.'); return; } const srcName = proj ? projNick(proj) : (e.party||'Expected'); pf.balances.income = Math.round((pf.balances.income||0) + amt); pf.incomeEntries.push({ id: Date.now(), date: e.dueDate || dayKey(new Date()), source: srcName, src: srcName, projId: e.projectId||null, fromAcctId: acctId, amt, allocated: false, note: 'From expected income' + (e.party?(' · '+e.party):'') }); // Mark the expected entry as received so it leaves the pending list / calendar e.status = 'completed'; if (!e.date) e.date = dayKey(new Date()); pfSave(); saveData(); renderProfitFirst(); alert('Fed ' + fmtINR(amt) + ' from ' + srcName + ' into Income A/c'); } function pfFeedAllExpected() { const list = (data.accounts||[]).filter(e => e.type==='payment-in' && e.status==='pending'); if (!list.length) return; if (!confirm('Feed all ' + list.length + ' expected income entries (' + fmtINR(list.reduce((s,e)=>s+Number(e.amount||0),0)) + ') into Income A/c?')) return; const pf = pfData(); pf.incomeEntries = pf.incomeEntries || []; list.forEach(e => { const amt = Math.round(Number(e.amount||0)); if (!amt) return; if (pf.incomeEntries.some(x => x.fromAcctId === e.id)) return; // skip already-fed const proj = e.projectId ? (data.projects||[]).find(p=>p.id===e.projectId) : null; const srcName = proj ? projNick(proj) : (e.party||'Expected'); pf.balances.income = Math.round((pf.balances.income||0) + amt); pf.incomeEntries.push({ id: Date.now()+Math.floor(Math.random()*100000), date: e.dueDate || dayKey(new Date()), source: srcName, src: srcName, projId: e.projectId||null, fromAcctId: e.id, amt, allocated: false, note: 'From expected income' + (e.party?(' · '+e.party):'') }); e.status = 'completed'; if (!e.date) e.date = dayKey(new Date()); }); pfSave(); saveData(); renderProfitFirst(); } // Income A/c: selecting a project auto-fills the net-after-compliance amount function pfProjSelChanged() { const sel = document.getElementById('pfProjSel'); const amtEl = document.getElementById('pfProjAmt'); if (!sel || !amtEl) return; const opt = sel.options[sel.selectedIndex]; const net = opt ? Number(opt.getAttribute('data-net')||0) : 0; if (net) { amtEl.value = net; const p = (data.projects||[]).find(x=>String(x.id)===String(sel.value)); const est = p ? (Number(p.estimate)||0) : 0; if (est) { const c = computeComplianceNet(est); amtEl.title = `Estimate ₹${est.toLocaleString('en-IN')} − deductions ₹${c.totalDed.toLocaleString('en-IN')} = Net ₹${c.net.toLocaleString('en-IN')}`; } } else { amtEl.value = ''; } } function pfAddProjectIncome() { const sel = document.getElementById('pfProjSel'); const amtEl = document.getElementById('pfProjAmt'); const selVal = sel ? sel.value : ""; if (!selVal) { alert("Select an entry first"); return; } // Expected-IN entry → feed via the Finance-linked path (with its own dedupe) if (selVal.startsWith('acct_')) { const acctId = Number(selVal.slice(5)); const editedAmt = Math.round(Number((amtEl ? amtEl.value : 0))||0); pfFeedExpected(acctId, editedAmt); if (amtEl) amtEl.value = ''; return; } const projId = selVal; const amt = Math.round(Number((amtEl ? amtEl.value : 0))||0); if (!amt) { alert("Enter the amount received"); return; } const pf = pfData(); const proj = (data.projects||[]).find(p=>String(p.id)===String(projId)); const srcName = proj ? projNick(proj) : String(projId); pf.incomeEntries = pf.incomeEntries||[]; // Duplicate guard: same project + same amount already pending (unallocated) const dupe = pf.incomeEntries.find(e => !e.allocated && String(e.projId||'') === String(projId) && Math.round(e.amt||0) === amt); if (dupe) { if (!confirm('An unallocated entry for ' + srcName + ' of ' + fmtINR(amt) + ' already exists.\nAdd it again anyway?')) return; } pf.balances.income = Math.round((pf.balances.income||0) + amt); pf.incomeEntries.push({ id:Date.now(), date:dayKey(new Date()), src: srcName, source: srcName, projId:projId, amt:amt, allocated:false }); pfSave(); if (amtEl) amtEl.value = ''; renderProfitFirst(); alert('Added ' + fmtINR(amt) + ' from ' + srcName + ' to Income A/c'); } function pfClearAll() { if (!confirm("Clear ALL Profit First data?\n\nThis will reset:\n - All account balances to zero\n - All income entries\n - All transfer history\n - All allocation settings back to default\n\nThis cannot be undone. Proceed?")) return; const pf = pfData(); ['income','operating','ownerPay','tax','profit'].forEach(k => pf.balances[k] = 0); pf.incomeEntries = []; pf.transfers = []; pf.monthlyLog = []; pf.checkIns = []; pf.allocations = { profit: 5, ownerPay: 20, tax: 15, operating: 60 }; pfSave(); renderProfitFirst(); alert('All Profit First data cleared.'); } function pfRecalcBalances() { const pf = pfData(); ['income','operating','ownerPay','tax','profit'].forEach(k => { pf.balances[k] = Math.round(pf.balances[k] || 0); }); pfSave(); renderProfitFirst(); } function pfResetBalances() { if (!confirm("Reset ALL account balances to zero? This cannot be undone.")) return; const pf = pfData(); ['income','operating','ownerPay','tax','profit'].forEach(k => pf.balances[k] = 0); pfSave(); renderProfitFirst(); } function pfSetPct(key, val) { const pf = pfData(); const map = {operating:'operating',ownerPay:'ownerPay',tax:'tax',profit:'profit'}; if (!map[key]) return; pf.allocations[map[key]] = Number(val)||0; const sum = pf.allocations.profit+pf.allocations.ownerPay+pf.allocations.tax+pf.allocations.operating; const w = document.getElementById('pfAllocWarn'); if (w) w.textContent = sum!==100 ? '⚠ Sum = '+sum+'% (normalised automatically)' : ''; pfSave(); } function pfAddIncome() { const amt = Number(document.getElementById('pfIncomeAmt')?.value || 0); const src = document.getElementById('pfIncomeSrc')?.value.trim() || ''; const date = document.getElementById('pfIncomeDate')?.value || dayKey(new Date()); const note = document.getElementById('pfIncomeNote')?.value.trim() || ''; if (!amt || !src) { alert('Enter amount and source'); return; } const pf = pfData(); pf.incomeEntries.push({ id: Date.now(), amt, src, date, note, allocated: false }); ['pfIncomeAmt','pfIncomeSrc','pfIncomeNote'].forEach(id => { const e = document.getElementById(id); if (e) e.value = ''; }); pfSave(); } function pfAllocate(entryId) { const pf = pfData(); const al = pf.allocations; const entry = pf.incomeEntries.find(e => e.id === entryId); if (!entry) return; const total = al.profit + al.ownerPay + al.tax + al.operating; const split = { profit: Math.round(entry.amt * al.profit / total * 100) / 100, ownerPay: Math.round(entry.amt * al.ownerPay / total * 100) / 100, tax: Math.round(entry.amt * al.tax / total * 100) / 100, operating: Math.round(entry.amt * al.operating / total * 100) / 100 }; pf.balances.income -= entry.amt; pf.balances.profit += split.profit; pf.balances.ownerPay += split.ownerPay; pf.balances.tax += split.tax; pf.balances.operating += split.operating; pf.transfers.push({ id: Date.now(), date: dayKey(new Date()), entryId, amt: entry.amt, split, src: entry.src || entry.source || '', projId: entry.projId || null, srcDate: entry.date || dayKey(new Date()), note: 'From: ' + (entry.src || entry.source || '') }); entry.allocated = true; pfSave(); } function pfAddRhythm() { const pf = pfData(); const notes = document.getElementById('pfRhythmNote')?.value.trim() || ''; const action = document.getElementById('pfRhythmAction')?.value.trim() || ''; pf.rhythmLog.unshift({ id: Date.now(), date: dayKey(new Date()), notes, action, balSnapshot: { ...pf.balances } }); ['pfRhythmNote','pfRhythmAction'].forEach(id => { const e = document.getElementById(id); if (e) e.value = ''; }); pfSave(); } function pfUpdateAlloc() { const pf = pfData(); const p = Number(document.getElementById('pfAlProfit')?.value || 0); const o = Number(document.getElementById('pfAlOwner')?.value || 0); const t = Number(document.getElementById('pfAlTax')?.value || 0); const op = Number(document.getElementById('pfAlOps')?.value || 0); if (p + o + t + op === 0) return; pf.allocations = { profit: p, ownerPay: o, tax: t, operating: op }; const w = document.getElementById('pfAllocWarn'); const sum = p + o + t + op; if (w) w.textContent = sum !== 100 ? `⚠ Allocations sum to ${sum}% — will be normalised automatically` : ''; pfSave(); } function pfUpdateBalance(key) { const pf = pfData(); pf.balances[key] = Math.round(Number(document.getElementById('pfBal_' + key)?.value || 0)); pfSave(); } function renderProfitFirst() { const el = document.getElementById('profitFirstContent'); if (!el) return; const pf = pfData(); // Auto-fix: round all stored balances to integer on every render let _dirty = false; ['income','operating','ownerPay','tax','profit'].forEach(k => { const v = pf.balances[k]||0; const r = Math.round(v); if (v !== r) { pf.balances[k] = r; _dirty = true; } }); if (_dirty) pfSave(); const al = pf.allocations; const total = al.profit + al.ownerPay + al.tax + al.operating || 100; const pct = { profit: +(al.profit / total * 100).toFixed(1), ownerPay: +(al.ownerPay / total * 100).toFixed(1), tax: +(al.tax / total * 100).toFixed(1), operating: +(al.operating / total * 100).toFixed(1) }; const now = new Date(); const thisMonth = dayKey(now).slice(0, 7); const monthIncome = pf.incomeEntries.filter(e => e.date.startsWith(thisMonth)).reduce((s,e) => s+e.amt, 0); const unallocated = pf.incomeEntries.filter(e => !e.allocated).reduce((s,e) => s+e.amt, 0); const totalIncome = pf.incomeEntries.reduce((s,e) => s+e.amt, 0); const allocatedInc = pf.incomeEntries.filter(e => e.allocated).reduce((s,e) => s+e.amt, 0); const loans = (data.loans||[]).filter(l => l.status !== 'closed'); const totalDebt = loans.reduce((s,l) => s+getLoanBalance(l), 0); const monthlyEMI = loans.reduce((s,l) => s+(l.emi||0), 0); const allProjects = (data.projects||[]); const activeProj = allProjects.filter(p => p.status !== 'completed'); const govProj = activeProj.filter(p => (p.category||'') === 'government'); const pvtProj = activeProj.filter(p => (p.category||'') === 'private'); const totalPipeline = activeProj.reduce((s,p) => s + (Number(p.estimate)||0), 0); const totalSpent = activeProj.reduce((s,p) => s + computeProjectSpent(p), 0); const totalNeeded = activeProj.reduce((s,p) => s + computeProjectForecast(p).moneyNeeded, 0); const balTotal = Object.values(pf.balances).reduce((s,v) => s+v, 0); // ── Key Metrics calculations ────────────────────────────────────── const debtToRevenue = monthIncome > 0 ? (monthlyEMI / monthIncome * 100).toFixed(1) : 'N/A'; const operatingRatio = totalPipeline > 0 ? (totalSpent / totalPipeline * 100).toFixed(1) : 'N/A'; const profitMarginEst = totalPipeline > 0 ? ((totalPipeline - totalSpent - totalNeeded) / totalPipeline * 100).toFixed(1) : 'N/A'; const taxCoverage = pf.balances.tax > 0 ? (pf.balances.tax / Math.max(monthIncome * 0.18, 1) * 100).toFixed(0) : '0'; const ownerPayRatio = totalIncome > 0 ? (pf.balances.ownerPay / totalIncome * 100).toFixed(1) : '0'; const cashRunway = monthlyEMI > 0 ? +(pf.balances.operating / monthlyEMI).toFixed(1) : 99; const debtToPipeline = totalPipeline > 0 ? (totalDebt / totalPipeline * 100).toFixed(1) : 'N/A'; // rhythm countdown const rhythmDays = [1, 10, 25].map(d => { const dt = new Date(now.getFullYear(), now.getMonth(), d); if (dt <= now) dt.setMonth(dt.getMonth() + 1); return { d, diff: Math.ceil((dt - now) / 86400000), date: dayKey(dt) }; }).sort((a,b) => a.diff - b.diff); const nextCheck = rhythmDays[0]; // SVG icon helpers — all pure SVG, no emoji const IC = { building: ``, gear: ``, user: ``, tax: ``, trophy: ``, income: ``, chart: ``, alert: ``, check: ``, calendar: ``, arrow: ``, bank: ``, trend_up: ``, risk: ``, target: ``, book: ``, flow: ``, plate: ``, }; const ACCOUNTS = [ { key:'income', icon:IC.income, label:'Income A/c', color:'var(--sky)', bg:'rgba(10,132,255,0.07)', desc:'All revenue lands here first' }, { key:'operating', icon:IC.gear, label:'Operating A/c', color:'var(--amber)', bg:'rgba(255,159,10,0.07)', desc:'Day-to-day business expenses — 1/3 rule' }, { key:'ownerPay', icon:IC.user, label:'Owner Pay', color:'var(--violet)', bg:'rgba(88,86,214,0.07)', desc:'Your salary — pay yourself consistently' }, { key:'tax', icon:IC.tax, label:'Tax Reserve', color:'var(--rose)', bg:'rgba(255,69,58,0.07)', desc:'GST · TDS · Advance tax — never touch' }, { key:'profit', icon:IC.trophy, label:'Profit A/c', color:'var(--accent)', bg:'rgba(48,209,88,0.07)', desc:'Accumulates — distribute 50% quarterly' }, ]; // Metric status helper const ms = (val, good, warn, label, unit='%') => { const n = parseFloat(val); if (isNaN(n)) return { color:'var(--text-muted)', status:'N/A', icon: IC.risk }; const c = n <= good ? 'var(--accent)' : n <= warn ? 'var(--amber)' : 'var(--rose)'; const ic = n <= good ? IC.check : n <= warn ? IC.alert : IC.alert; return { color: c, status: label || val+unit, icon: ic }; }; // Per-account project allocation breakdown (from allocation transfers) const accountAllocations = (acctKey) => { const txs = (pf.transfers||[]).filter(t => t.split && typeof t.split[acctKey] === 'number'); const list = txs.map(t => ({ date: t.srcDate || t.date || '', projName: t.src || t.source || (t.projId ? (()=>{const p=(data.projects||[]).find(x=>String(x.id)===String(t.projId));return p?projNick(p):'—';})() : '—'), amt: t.split[acctKey] || 0 })).filter(x => x.amt > 0); const total = list.reduce((s,x)=>s+x.amt,0); const byProjMap = {}; list.forEach(x => { byProjMap[x.projName] = (byProjMap[x.projName]||0) + x.amt; }); const byProject = Object.entries(byProjMap).map(([projName,amt])=>({projName,amt})).sort((a,b)=>b.amt-a.amt); const byDate = list.slice().sort((a,b)=>(b.date||'').localeCompare(a.date||'')); return { total, byDate, byProject, count:list.length }; }; // Renders the "total + project split + dates" block for a card const allocListHtml = (acctKey, color) => { const a = accountAllocations(acctKey); if (!a.count) return `
No project allocations yet
`; const projRows = a.byProject.map(p => `
${escHtml(p.projName)} ${fmtINR(p.amt)}
`).join(''); const dateRows = a.byDate.slice(0,6).map(d => `
${d.date||'—'} ${escHtml(d.projName)} ${fmtINR(d.amt)}
`).join(''); return `
${a.byProject.length} project${a.byProject.length===1?'':'s'} Σ ${fmtINR(a.total)}
${projRows}
By date
${dateRows} ${a.byDate.length>6?`
+${a.byDate.length-6} more
`:''}
`; }; el.innerHTML = `
${IC.trophy}
Profit First System
Every rupee of income is split immediately across 4 accounts.
Operating costs are limited to what the operating account holds — you cannot overspend.
Next Check-in
${nextCheck.diff===0?'TODAY':nextCheck.diff+'d'}
${nextCheck.date}
${IC.plate} Use a Smaller Plate — Your 4 Accounts
Each account is a plate — you can only spend what fits. Update balances as you transfer.
${IC.income}Income A/c
All revenue lands here first
Balance
Add Income From ${window._pfSrcExpected ? 'Expected IN' : 'Project'}
${fmtINR(unallocated)} unallocated
Splits balance across 4 accounts per target %
${[{key:'operating',icon:IC.gear, label:'Operating A/c',color:'var(--amber)', bg:'rgba(255,159,10,0.07)', desc:'Day-to-day expenses'}, {key:'ownerPay', icon:IC.user, label:'Owner Pay', color:'var(--violet)',bg:'rgba(88,86,214,0.07)', desc:'Your salary'}, {key:'tax', icon:IC.tax, label:'Tax Reserve', color:'var(--rose)', bg:'rgba(255,69,58,0.07)', desc:'GST / TDS - never touch'}, {key:'profit', icon:IC.trophy,label:'Profit A/c', color:'var(--accent)',bg:'rgba(48,209,88,0.07)', desc:'Quarterly distribution'}, ].map(ac => `
${ac.icon}${ac.label}
${ac.desc}
Balance
Target %
%
${monthIncome>0?fmtINR(Math.round(monthIncome*pct[ac.key]/100))+" this mo":""}
${allocListHtml(ac.key, ac.color)}
`).join('')}
${renderOwnerPayDebtPanel()}
${IC.flow} Income Split Flow
INCOME
${fmtINR(monthIncome)}
this month
${[ { key:'profit', color:'var(--accent)', icon:IC.trophy }, { key:'ownerPay', color:'var(--violet)', icon:IC.user }, { key:'tax', color:'var(--rose)', icon:IC.tax }, { key:'operating',color:'var(--amber)', icon:IC.gear }, ].map((a,i) => `${i>0?'
+
':''}
${a.icon}
${pct[a.key]}%
${fmtINR(monthIncome * pct[a.key] / 100)}
`).join('')}
${[{k:'operating',c:'var(--amber)'},{k:'ownerPay',c:'var(--violet)'},{k:'tax',c:'var(--rose)'},{k:'profit',c:'var(--accent)'}] .map(a=>`
`).join('')}
■ Operating ${pct.operating}% ■ Owner Pay ${pct.ownerPay}% ■ Tax ${pct.tax}% ■ Profit ${pct.profit}%
${IC.chart} Key Financial Metrics — What to Monitor
Based on your construction projects, loans, and cash position. Track these every check-in.
${[ { label:'Debt Service Ratio', value: debtToRevenue === 'N/A' ? 'N/A' : debtToRevenue + '%', sub: `${fmtINR(monthlyEMI)}/mo EMI vs ${fmtINR(monthIncome)} income`, color: debtToRevenue === 'N/A' ? 'var(--text-muted)' : parseFloat(debtToRevenue) <= 30 ? 'var(--accent)' : parseFloat(debtToRevenue) <= 50 ? 'var(--amber)' : 'var(--rose)', icon: IC.bank, insight: debtToRevenue === 'N/A' ? 'Log monthly income to track' : parseFloat(debtToRevenue) <= 30 ? 'Healthy — EMI load is manageable' : parseFloat(debtToRevenue) <= 50 ? 'Caution — reduce discretionary spend' : 'Critical — EMI is consuming over half your income', target: 'Target: below 30%' }, { label:'Operating Cost Ratio', value: operatingRatio === 'N/A' ? 'N/A' : operatingRatio + '%', sub: `${fmtINR(totalSpent)} spent of ${fmtINR(totalPipeline)} pipeline`, color: operatingRatio === 'N/A' ? 'var(--text-muted)' : parseFloat(operatingRatio) <= 60 ? 'var(--accent)' : parseFloat(operatingRatio) <= 80 ? 'var(--amber)' : 'var(--rose)', icon: IC.gear, insight: operatingRatio === 'N/A' ? 'Add project estimates to track' : parseFloat(operatingRatio) <= 60 ? 'Good cost control across projects' : parseFloat(operatingRatio) <= 80 ? 'Watch spend — margins are thinning' : 'Over-spend risk — review project budgets', target: 'Target: below 65%' }, { label:'Est. Profit Margin', value: profitMarginEst === 'N/A' ? 'N/A' : profitMarginEst + '%', sub: 'After completing all active projects', color: profitMarginEst === 'N/A' ? 'var(--text-muted)' : parseFloat(profitMarginEst) >= 15 ? 'var(--accent)' : parseFloat(profitMarginEst) >= 5 ? 'var(--amber)' : 'var(--rose)', icon: IC.trend_up, insight: profitMarginEst === 'N/A' ? 'Log project spend to calculate' : parseFloat(profitMarginEst) >= 15 ? 'Strong margin — reinvest in growth' : parseFloat(profitMarginEst) >= 5 ? 'Thin margin — find cost reductions' : 'Margin at risk — renegotiate or cut scope', target: 'Target: 15%+' }, { label:'Cash Runway', value: cashRunway >= 99 ? 'Safe' : cashRunway + ' mo', sub: `${fmtINR(pf.balances.operating)} operating balance`, color: cashRunway >= 3 ? 'var(--accent)' : cashRunway >= 1 ? 'var(--amber)' : 'var(--rose)', icon: IC.calendar, insight: cashRunway >= 3 ? 'Comfortable — 3+ months operating buffer' : cashRunway >= 1 ? 'Limited — 1-3 months, collect receivables now' : 'Critical — operating account needs immediate top-up', target: 'Target: 3+ months' }, { label:'Tax Reserve Coverage', value: taxCoverage + '%', sub: `${fmtINR(pf.balances.tax)} vs est. ${fmtINR(Math.round(monthIncome*0.18))} GST liability`, color: parseFloat(taxCoverage) >= 100 ? 'var(--accent)' : parseFloat(taxCoverage) >= 60 ? 'var(--amber)' : 'var(--rose)', icon: IC.tax, insight: parseFloat(taxCoverage) >= 100 ? 'GST fully covered — no surprise liability' : parseFloat(taxCoverage) >= 60 ? 'Partial cover — keep allocating to tax account' : 'Under-reserved — risk of GST/TDS penalty', target: 'Target: 100% coverage' }, { label:'Debt / Pipeline Ratio', value: debtToPipeline === 'N/A' ? 'N/A' : debtToPipeline + '%', sub: `${fmtINR(totalDebt)} debt vs ${fmtINR(totalPipeline)} pipeline`, color: debtToPipeline === 'N/A' ? 'var(--text-muted)' : parseFloat(debtToPipeline) <= 40 ? 'var(--accent)' : parseFloat(debtToPipeline) <= 70 ? 'var(--amber)' : 'var(--rose)', icon: IC.risk, insight: debtToPipeline === 'N/A' ? 'Add project data to calculate' : parseFloat(debtToPipeline) <= 40 ? 'Leverage is healthy vs project value' : parseFloat(debtToPipeline) <= 70 ? 'Moderate leverage — prioritise collections' : 'High leverage — complete projects fast, reduce debt', target: 'Target: below 40%' }, { label:'Govt vs Private Mix', value: activeProj.length ? Math.round(govProj.length/activeProj.length*100)+'% Govt' : 'N/A', sub: `${govProj.length} govt · ${pvtProj.length} private of ${activeProj.length} active`, color: 'var(--sky)', icon: IC.building, insight: govProj.length > pvtProj.length ? 'Govt-heavy: guaranteed payment, but slow collection cycles — maintain strong documentation' : 'Private-heavy: faster payment possible, but higher collection risk — enforce milestones', target: 'Balanced mix reduces risk' }, { label:'Owner Pay Discipline', value: ownerPayRatio + '%', sub: `${fmtINR(pf.balances.ownerPay)} in owner account`, color: parseFloat(ownerPayRatio) >= 20 ? 'var(--accent)' : parseFloat(ownerPayRatio) >= 10 ? 'var(--amber)' : 'var(--rose)', icon: IC.user, insight: parseFloat(ownerPayRatio) >= 20 ? 'Consistent owner pay — business is working for you' : parseFloat(ownerPayRatio) >= 10 ? 'Growing — keep allocating every cycle' : 'Low — you are not paying yourself. This is a risk signal', target: 'Target: 25-30% of revenue' }, ].map(m => `
${m.icon}${m.label}
${m.value}
${m.sub}
${m.insight}
${m.target}
`).join('')}
${IC.target} Personalised Improvement Recommendations
${(()=>{ const recs = []; // Finance if (parseFloat(debtToRevenue) > 40) recs.push({ area:'Financing', urgency:'high', color:'var(--rose)', icon:IC.bank, title:'Reduce EMI burden immediately', actions:['Prioritise closing 1-2 high-rate loans (NBFC/private finance) using project collections','Avoid taking new loans until Debt Service Ratio drops below 30%','Negotiate lower interest rates with banks — your government project record is collateral strength'] }); if (parseFloat(debtToPipeline) > 60) recs.push({ area:'Financing', urgency:'medium', color:'var(--amber)', icon:IC.bank, title:'Leverage is high relative to project pipeline', actions:['Every project collection — use 30% directly for loan principal prepayment','Prioritise completing near-done projects to unlock billing and reduce debt ratio','Do not start new projects until at least one major loan is cleared'] }); // Budgeting if (activeProj.filter(p => computeProjectForecast(p).overBudget).length > 0) recs.push({ area:'Budgeting', urgency:'high', color:'var(--rose)', icon:IC.chart, title:'Over-budget projects detected', actions:['Review material and labour costs on over-budget projects this week','Raise variation orders for any client-initiated scope changes','Set weekly budget check meetings — compare actual spend vs budget per project'] }); recs.push({ area:'Budgeting', urgency:'info', color:'var(--sky)', icon:IC.target, title:'Construction-specific budgeting improvements', actions:['Build a 10% contingency into every new project estimate','Separate material cost from labour cost tracking — material usually overruns first','Use rate contracts with preferred vendors to lock prices for 6 months'] }); // Accounting recs.push({ area:'Accounting', urgency:'info', color:'var(--violet)', icon:IC.book, title:'Key accounting habits for your business type', actions:['Bill clients at each milestone — do not wait for project completion (especially government projects)','Reconcile bank accounts against project ledger every 10 days (use the Banks tab)','Maintain a separate register for retention money held by clients — track release dates'] }); if (parseFloat(taxCoverage) < 80) recs.push({ area:'Tax / Compliance', urgency:'medium', color:'var(--amber)', icon:IC.tax, title:'GST / TDS reserve is under-funded', actions:['Allocate at least 18% of every government project collection to Tax account immediately','File GST returns on time — late fees compound; set calendar reminders 5 days before due date','For jewel/gold loans: interest is not tax-deductible — prioritise repaying them first'] }); // Spending recs.push({ area:'Spending Control', urgency:'info', color:'var(--amber)', icon:IC.gear, title:'Apply the 1/3 Operating Rule', actions:['Operating expenses should not exceed 1/3 of your income — review this every check-in','Distinguish fixed costs (rent, salaries, EMI) from variable (materials, casual labour) — cut variable first','Before any major spend, check: does the Operating Account balance cover this? If no — stop'] }); if (pf.balances.profit < 5000) recs.push({ area:'Profit Building', urgency:'medium', color:'var(--accent)', icon:IC.trophy, title:'Profit account needs attention', actions:['Start at 1% profit allocation — it feels small but builds discipline','Never use the Profit Account for operations — it is your reward and emergency fund','Each quarter, transfer 50% of the Profit Account to yourself as a bonus; leave 50% to grow'] }); return recs.map(r => `
${r.icon}
${r.area}
${r.title}
${r.urgency==='high'?`Urgent`:r.urgency==='medium'?`Action needed`:''}
${r.actions.map(a => `
${IC.arrow}${a}
`).join('')}
`).join(''); })()}
${IC.gear} Allocation Settings
Start: Profit 1% · Owner 30% · Tax 15% · Operating 54%. Raise Profit 1% each quarter.
${[ { label:'Profit', id:'pfAlProfit', val:al.profit, color:'var(--accent)', rec:'1–5%' }, { label:'Owner Pay', id:'pfAlOwner', val:al.ownerPay, color:'var(--violet)', rec:'25–35%' }, { label:'Tax', id:'pfAlTax', val:al.tax, color:'var(--rose)', rec:'15–20%' }, { label:'Operating', id:'pfAlOps', val:al.operating, color:'var(--amber)', rec:'40–54%' }, ].map(r => `
${r.label} %  rec: ${r.rec}
`).join('')}
${IC.income} Log Income
Log every collection. Allocate immediately to the 4 accounts.
${(data.projects||[]).map(p=>``).join('')}
${(() => { const expIn = (data.accounts||[]).filter(e => e.type==='payment-in' && e.status==='pending'); if (!expIn.length) return ''; expIn.sort((a,b)=>(a.dueDate||'').localeCompare(b.dueDate||'')); const total = expIn.reduce((s,e)=>s+Number(e.amount||0),0); const M=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; const fmtD = d => { if(!d) return '—'; const [y,m,dd]=d.split('-'); return `${parseInt(dd)} ${M[parseInt(m)-1]} ${y}`; }; return `
${IC.income} Expected Income
${expIn.length} expected · ${fmtINR(total)} total · from Finance → Expected IN
${expIn.map(e => { const proj = e.projectId ? (data.projects||[]).find(p=>p.id===e.projectId) : null; return ``; }).join('')}
Expected DateProjectClientNet AmountAction
${fmtD(e.dueDate)} ${proj?escHtml(projNick(proj)):'—'} ${escHtml(e.party||'—')} ${fmtINR(e.amount)}
TOTAL EXPECTED ${fmtINR(total)}
`; })()} ${pf.incomeEntries.length ? `
${IC.book} Income Register
${pf.incomeEntries.filter(e=>!e.allocated).length} pending allocation
${[...pf.incomeEntries].reverse().map(e => { const t2 = al.profit+al.ownerPay+al.tax+al.operating||100; const sp = { profit: Math.round(e.amt*al.profit /t2*100)/100, ownerPay: Math.round(e.amt*al.ownerPay /t2*100)/100, tax: Math.round(e.amt*al.tax /t2*100)/100, operating: Math.round(e.amt*al.operating/t2*100)/100, }; return ``; }).join('')}
DateSourceAmountNoteStatusAction
${e.date} ${escHtml(e.src || e.source || (e.projId ? (()=>{const p=(data.projects||[]).find(x=>String(x.id)===String(e.projId)); return p?projNick(p):'—';})() : '—'))} ${fmtINR(e.amt)} ${escHtml(e.note||'')} ${e.allocated ? `Allocated` : `Pending`} ${!e.allocated?`
P:${fmtINR(sp.profit)} O:${fmtINR(sp.ownerPay)} T:${fmtINR(sp.tax)} Ops:${fmtINR(sp.operating)}
`:''}
` : ''}
${IC.calendar} Check-in 3× Per Month
1st · 10th · 25th — review statements, take action
${rhythmDays.map(c=>`
${c.diff===0?'TODAY':c.diff+'d'}
${c.date}
`).join('')}
Rhythm Checklist
${[ 'Check Income Account — how much arrived since last check?', 'Allocate all unallocated income to the 4 accounts now', 'Can Operating Account pay all pending bills? If not — cut costs, do NOT borrow', 'Transfer Owner Pay to personal account — pay yourself without fail', 'Verify Tax Reserve is growing — never touch until filing deadline', 'Profit Account — let it accumulate; distribute 50% only on quarter-end', 'Compare account balances to previous check-in — which direction are they moving?', 'Identify ONE concrete action to take before next check-in', ].map(s=>`
${IC.check}${s}
`).join('')}
${pf.rhythmLog.length?`
Check-in History
${pf.rhythmLog.slice(0,5).map(log=>`
${log.date}
Profit ${fmtINR(log.balSnapshot?.profit||0)} Owner ${fmtINR(log.balSnapshot?.ownerPay||0)} Ops ${fmtINR(log.balSnapshot?.operating||0)}
${log.notes?`
${escHtml(log.notes)}
`:''} ${log.action?`
${IC.arrow}${escHtml(log.action)}
`:''}
`).join('')}
`:''}
${IC.book} Profit First — The 5 Core Rules
${[ { n:'1', icon:IC.plate, rule:'Use a smaller plate', detail:'Limit operating costs to only what is in the Operating Account. If the balance does not cover it, you cannot spend it.' }, { n:'2', icon:IC.income, rule:'Serve sequentially', detail:'Transfer: Profit first (1%) → Owner Pay → Tax → Operating. In that order, every single time.' }, { n:'3', icon:IC.bank, rule:'Remove temptation', detail:'Keep Profit and Tax accounts at a different bank. Physical separation prevents impulse spending.' }, { n:'4', icon:IC.calendar,rule:'Enforce a rhythm', detail:'Check accounts on the 1st, 10th, and 25th of every month. No exceptions — consistency is the system.' }, { n:'5', icon:IC.trend_up,rule:'Raise allocations quarterly', detail:'Increase Profit % by 1 point every 3 months. Small increments compound into a fundamentally different business.' }, ].map(r=>`
${r.icon}
${r.n}. ${r.rule}
${r.detail}
`).join('')}
`; } // ===== PROJECT FORECAST & DECISION ENGINE ===== function renderForecastTab() { const el = document.getElementById('forecastContent'); if (!el) return; const allProjects = (data.projects || []).filter(p => p.status !== 'completed'); const loans = (data.loans || []).filter(l => l.status !== 'closed'); const totalDebt = loans.reduce((s, l) => s + getLoanBalance(l), 0); const monthlyEMI = loans.reduce((s, l) => s + (l.emi || 0), 0); if (!allProjects.length) { el.innerHTML = `
🔮
No active projects to analyse
Add projects in the Construction Projects tab first.
`; return; } // ── Score every project across 6 dimensions ──────────────────────── const scored = allProjects.map(p => { const fc = computeProjectForecast(p); const budget = fc.budget || 1; const spent = fc.spent; const progress = fc.progress; const remaining = Math.max(budget - spent, 0); const moneyNeeded = fc.moneyNeeded; const gstRate = Number(p.gstRate ?? 18); const budgetExGST = Math.round(budget / (1 + gstRate / 100) * 100) / 100; const profit = budgetExGST - fc.forecastTotal; const profitPct = budget > 0 ? (profit / budgetExGST) * 100 : 0; const cpi = fc.cpi; const daysLeft = fc.daysLeft; const overdue = daysLeft != null && daysLeft < 0; const urgency = daysLeft != null ? Math.max(0, 100 - daysLeft) : 30; const tasksDone = (() => { const s = projectStats(p); return s.tasks > 0 ? (s.doneTasks / s.tasks) * 100 : progress; })(); // Cash-return: how much net cash returns after completing? (budget received - money still needed) const cashReturn = Math.max(budgetExGST - moneyNeeded, 0); // 80/20 value score: projects that give most return for least remaining effort const effortLeft = moneyNeeded > 0 ? moneyNeeded : remaining; const valueScore = effortLeft > 0 ? (cashReturn / effortLeft) : (cashReturn > 0 ? 99 : 0); // Risk score (0-100, higher = riskier) const riskScore = Math.min(100, Math.round( (fc.overBudget ? 40 : 0) + (fc.forecastOverrun > 0 ? Math.min(30, (fc.forecastOverrun / budget) * 100) : 0) + (overdue ? 20 : daysLeft != null && daysLeft < 30 ? 10 : 0) + (cpi < 0.8 ? 15 : cpi < 0.95 ? 7 : 0) )); // Multi-scenario: Optimistic (+15% progress speed), Realistic (current), Pessimistic (+20% cost overrun) const scenOptimistic = { completion: Math.min(progress * 1.25, 100), cost: spent + remaining * 0.85, margin: (budgetExGST - (spent + remaining * 0.85)) / budgetExGST * 100 }; const scenRealistic = { completion: progress, cost: fc.forecastTotal, margin: profitPct }; const scenPessimistic = { completion: progress * 0.8, cost: fc.forecastTotal * 1.20, margin: (budgetExGST - fc.forecastTotal * 1.20) / budgetExGST * 100 }; // Priority score for ordering (weighted composite) const priorityScore = Math.round( valueScore * 35 + // 35% weight: return per rupee spent (urgency / 100) * 25 + // 25% weight: deadline urgency (progress / 100) * 20 + // 20% weight: how close to done (sunk cost momentum) ((100 - riskScore) / 100) * 10 + // 10% weight: low risk preferred (p.category === 'government' ? 10 : 5) // 10% weight: government projects (guaranteed payment) ); // Decision label let decision, decisionColor, decisionIcon; if (valueScore > 3 && riskScore < 30 && urgency > 50) { decision = 'Complete First'; decisionColor = 'var(--accent)'; decisionIcon = '🥇'; } else if (valueScore > 1.5 && riskScore < 50) { decision = 'High Priority'; decisionColor = 'var(--sky)'; decisionIcon = '🔵'; } else if (riskScore > 60 || fc.overBudget) { decision = 'Review & Fix'; decisionColor = 'var(--rose)'; decisionIcon = '⚠️'; } else if (progress > 80) { decision = 'Nearly Done'; decisionColor = 'var(--accent)'; decisionIcon = '🏁'; } else if (moneyNeeded > totalDebt * 0.5) { decision = 'Capital Risk'; decisionColor = 'var(--amber)'; decisionIcon = '💸'; } else { decision = 'Continue'; decisionColor = 'var(--text-secondary)'; decisionIcon = '▶️'; } return { p, fc, budget, spent, progress, remaining, moneyNeeded, budgetExGST, profit, profitPct, cpi, daysLeft, overdue, urgency, cashReturn, effortLeft, valueScore, riskScore, scenOptimistic, scenRealistic, scenPessimistic, priorityScore, decision, decisionColor, decisionIcon, tasksDone }; }).sort((a, b) => b.priorityScore - a.priorityScore); // ── Global summary ───────────────────────────────────────────────── const totalBudget = scored.reduce((s, x) => s + x.budget, 0); const totalSpent = scored.reduce((s, x) => s + x.spent, 0); const totalNeeded = scored.reduce((s, x) => s + x.moneyNeeded, 0); const totalCashBack = scored.reduce((s, x) => s + x.cashReturn, 0); const avgRisk = scored.length ? Math.round(scored.reduce((s, x) => s + x.riskScore, 0) / scored.length) : 0; // 80/20 rule: top 20% projects by value const top20count = Math.max(1, Math.ceil(scored.length * 0.2)); const top20Value = scored.slice(0, top20count).reduce((s, x) => s + x.cashReturn, 0); const top20pct = totalCashBack > 0 ? Math.round(top20Value / totalCashBack * 100) : 0; // ── Cash flow pressure (months till EMI crisis) ──────────────────── const avgMonthlyRecovery = totalCashBack / Math.max(scored.length * 3, 1); const cashPressureMonths = monthlyEMI > 0 ? (totalCashBack / monthlyEMI).toFixed(1) : '∞'; // ── Operating Expenditure (OpEx) — feeds financial planning ──────── const opexToMonthly = (e) => e.freq==='yearly' ? e.amount/12 : e.freq==='weekly' ? e.amount*4.33 : e.freq==='daily' ? e.amount*30 : e.freq==='one-time' ? 0 : e.amount; const monthlyOpex = Math.round((data.opexEntries||[]).reduce((s,e)=>s+opexToMonthly(e),0)); // Total fixed monthly obligations = EMI + recurring OpEx const monthlyObligations = monthlyEMI + monthlyOpex; // Runway: how many months the expected cash return covers ALL fixed costs const runwayMonths = monthlyObligations > 0 ? (totalCashBack / monthlyObligations) : Infinity; const runwayLabel = isFinite(runwayMonths) ? runwayMonths.toFixed(1) + ' mo' : '∞'; // Net cash after one month of obligations const netAfterObligations = totalCashBack - monthlyObligations; const opexCat = {}; (data.opexEntries||[]).forEach(e => { opexCat[e.cat] = (opexCat[e.cat]||0) + opexToMonthly(e); }); const topOpexCats = Object.entries(opexCat).sort((a,b)=>b[1]-a[1]).slice(0,4); // ── Build HTML ──────────────────────────────────────────────────── el.innerHTML = `
🔮
Project Forecast & Decision Engine
Multi-scenario analysis · 80/20 Pareto ranking · Priority decision matrix
${scored.length} active project${scored.length===1?'':'s'} analysed
Last updated: ${dayKey(new Date())}
Total Pipeline Value
${fmtINR(totalBudget)}
${scored.length} active projects
Capital Still Needed
${fmtINR(totalNeeded)}
To complete all projects
Expected Cash Return
${fmtINR(totalCashBack)}
After all completions
Monthly EMI Burden
${fmtINR(monthlyEMI)}
${cashPressureMonths} months of EMI in pipeline
Monthly OpEx Run-rate
${fmtINR(monthlyOpex)}
${(data.opexEntries||[]).length} recurring expense${(data.opexEntries||[]).length===1?'':'s'}
Total Monthly Obligations
${fmtINR(monthlyObligations)}
EMI + OpEx combined
Cash Runway
${runwayLabel}
Cash return ÷ monthly obligations
Avg Portfolio Risk
${avgRisk}/100
${avgRisk>60?'High':'avgRisk>35?Moderate:Low'} risk overall
📊
80/20 Pareto Insight
Your top ${top20count} project${top20count===1?'':'s'} (${Math.round(top20count/scored.length*100)}% of portfolio) will return ${top20pct}% of your total expected cash (${fmtINR(top20Value)}).
→ Focus capital and labour on the top-ranked projects first to maximise liquidity.
${top20pct}%
value from top ${Math.round(top20count/scored.length*100)}%
⚙️
Operating Expenditure in the Plan
from CapEx/OpEx → Operating Expenditure
${(data.opexEntries||[]).length ? `
Monthly fixed cost stack
EMI / Debt${fmtINR(monthlyEMI)}
${topOpexCats.map(([c,v])=>`
${escHtml(c)}${fmtINR(Math.round(v))}
`).join('')} ${Object.keys(opexCat).length>4?`
Other OpEx${fmtINR(Math.round(monthlyOpex - topOpexCats.reduce((s,[,v])=>s+v,0)))}
`:''}
Total / month${fmtINR(monthlyObligations)}
Planning impact
Cash runway (return ÷ obligations)
${runwayLabel}
Net after 1 month of fixed costs
${fmtINR(netAfterObligations)}
${runwayMonths<3 ? `⚠️ Tight runway. Expected cash return covers only ${runwayLabel} of combined EMI + OpEx. Prioritise the top 80/20 projects and trim discretionary OpEx (${topOpexCats[0]?escHtml(topOpexCats[0][0]):'largest category'} is your biggest line).` : `✓ Combined monthly obligations of ${fmtINR(monthlyObligations)} (EMI ${fmtINR(monthlyEMI)} + OpEx ${fmtINR(monthlyOpex)}) are covered ${runwayLabel} over by expected cash return. Healthy operating cushion.`}
` : `
No operating expenses logged yet. Add recurring costs in CapEx/OpEx → Operating Expenditure to factor them into this forecast.
`}
${monthlyEMI > 0 ? `
Cash Flow Pressure Check
You owe ${fmtINR(monthlyEMI)}/mo in EMIs against total outstanding debt of ${fmtINR(totalDebt)}.
Your project pipeline can cover ${cashPressureMonths} months of EMI obligations — ${Number(cashPressureMonths) > 6 ? '✅ comfortable buffer' : Number(cashPressureMonths) > 3 ? '⚠️ moderate pressure — prioritise collection' : '🚨 critical — fast-track high-return projects immediately'}.
EMI coverage from pipeline ${Math.min(Math.round(Number(cashPressureMonths)/12*100),100)}%
` : ''}
🏆 Priority Decision Matrix
Ranked by composite score — complete from top to bottom for maximum financial health
${scored.map((x, i) => { const riskColor = x.riskScore > 60 ? 'var(--rose)' : x.riskScore > 35 ? 'var(--amber)' : 'var(--accent)'; const vsColor = x.valueScore > 3 ? 'var(--accent)' : x.valueScore > 1 ? 'var(--sky)' : 'var(--text-muted)'; const dlLabel = x.daysLeft == null ? '—' : x.overdue ? `⚠ ${Math.abs(x.daysLeft)}d overdue` : `${x.daysLeft}d left`; const dlColor = x.overdue ? 'var(--rose)' : x.daysLeft != null && x.daysLeft < 30 ? 'var(--amber)' : 'var(--text-muted)'; const rankBg = i === 0 ? 'rgba(255,214,10,0.12)' : i < 3 ? 'rgba(48,209,88,0.05)' : ''; return ``; }).join('')}
# Project Decision Budget Spent Cash Return Progress Risk Value/₹ Deadline Score
${i===0?'🥇':i===1?'🥈':i===2?'🥉':i+1}
${escHtml(projNick(x.p))}
${x.p.company||''} ${x.p.zone?'· '+x.p.zone:''}
${x.decisionIcon} ${x.decision} ${fmtINR(x.budget)} ${fmtINR(x.spent)} ${fmtINR(x.cashReturn)}
${x.progress}%
${x.riskScore} ${x.valueScore > 50 ? '∞' : x.valueScore.toFixed(1)}x ${dlLabel} ${x.priorityScore}
📐 Multi-Scenario Analysis
Three probability scenarios per project — Optimistic (best case), Realistic (current trajectory), Pessimistic (20% cost overrun risk)
${scored.map((x, i) => { const sO = x.scenOptimistic, sR = x.scenRealistic, sP = x.scenPessimistic; const scenarios = [ { label:'🟢 Optimistic', sub:'Best case: faster execution, no overruns', color:'var(--accent)', prob:'25%', completion: sO.completion, cost: sO.cost, margin: sO.margin }, { label:'🔵 Realistic', sub:'Current trajectory maintained', color:'var(--sky)', prob:'55%', completion: sR.completion, cost: sR.cost, margin: sR.margin }, { label:'🔴 Pessimistic',sub:'20% cost overrun, delays expected', color:'var(--rose)', prob:'20%', completion: sP.completion, cost: sP.cost, margin: sP.margin }, ]; return `
${x.decisionIcon} ${escHtml(projNick(x.p))} #${i+1} Priority ${x.decision}
${x.p.company||''}${x.p.zone?' · '+x.p.zone:''}${x.p.category?' · '+(projectCategoryLabel[x.p.category]||x.p.category):''}
Budget: ${fmtINR(x.budget)} · Spent: ${fmtINR(x.spent)} · Needed: ${fmtINR(x.moneyNeeded)}
${scenarios.map(sc => `
${sc.label} ${sc.prob}
${sc.sub}
Completion
${Math.round(sc.completion)}%
Est. Cost
${fmtINR(sc.cost)}
Margin
${sc.margin.toFixed(1)}%
`).join('')}
💡 Recommendation
${(()=>{ const msgs = []; if (x.riskScore > 60) msgs.push('⚠️ High risk detected — review cost overruns and resource allocation before proceeding.'); if (x.fc.overBudget) msgs.push('🚨 Already over budget by ' + fmtINR(x.spent - x.budget) + ' — stop additional commitments, focus on billing and collection.'); if (x.overdue) msgs.push('📅 Overdue by ' + Math.abs(x.daysLeft) + ' days — negotiate deadline extension or accelerate labour deployment.'); if (x.valueScore > 3) msgs.push('💰 High-value project — every ₹1 invested returns ' + x.valueScore.toFixed(1) + 'x in cash. Prioritise resources here.'); if (x.progress > 75 && x.moneyNeeded < x.budget * 0.25) msgs.push('🏁 Nearly complete — minor push required. Fast-track to free up team and unlock final payment.'); if (x.moneyNeeded > totalDebt * 0.4) msgs.push('💸 Capital-heavy — requires ' + fmtINR(x.moneyNeeded) + ' to complete. Ensure funding secured before ramp-up.'); if (sP.margin < 0) msgs.push('📉 Pessimistic scenario shows negative margin — negotiate scope reduction or rate revision as contingency.'); if (sO.margin > 20) msgs.push('📈 Optimistic case shows ' + sO.margin.toFixed(1) + '% margin — achievable if material and labour costs controlled.'); if (x.p.category === 'government') msgs.push('🏛️ Government project — payment is guaranteed post-completion but billing documentation must be kept current.'); if (!msgs.length) msgs.push('✅ Project is on track. Continue current pace and monitor burn rate weekly.'); return msgs.map(m => '
• ' + m + '
').join(''); })()}
`; }).join('')}
🎯 Final Verdict — What To Do Next
${[ { icon:'1️⃣', color:'var(--amber)', label:'Complete First', projects: scored.filter(x=>x.decision==='Complete First'||x.decision==='Nearly Done') }, { icon:'2️⃣', color:'var(--sky)', label:'High Priority', projects: scored.filter(x=>x.decision==='High Priority') }, { icon:'3️⃣', color:'var(--rose)', label:'Needs Attention',projects: scored.filter(x=>x.decision==='Review & Fix'||x.decision==='Capital Risk') }, { icon:'4️⃣', color:'var(--text-secondary)', label:'Continue Normally', projects: scored.filter(x=>x.decision==='Continue') }, ].map(g => g.projects.length ? `
${g.icon} ${g.label}
${g.projects.map(x => `
${escHtml(projNick(x.p))} ${fmtINR(x.cashReturn)}
`).join('')}
` : '').filter(Boolean).join('')}
`; } // ===== PERSONAL FINANCE MODULE ===== // Based on My_budget_2026.numbers — Newton & Smitha household function pbData() { if (!data.personalBudget) data.personalBudget = { // ===== PRE-LOADED FROM My_budget_2026.numbers (Feb-Jul 2026) ===== income: { newtonSalary: 200000, smithaSalary: 0, additionalIncome: 0 }, emis: [ { id:'emi_01', label:'SBI - MUTHOOT (Gold Loan)', lender:'SBI', amount:9152, dueDay:2, category:'jewel', status:'active', note:'02-SBI-MUTHOOT' }, { id:'emi_02', label:'PERSONAL - SBI', lender:'SBI', amount:9064, dueDay:2, category:'personal', status:'active', note:'02-PERSONAL-SBI (from Jun 2026)' }, { id:'emi_03', label:'INDIAN S24 (Phone)', lender:'Indian', amount:3333, dueDay:5, category:'device', status:'active', note:'05-INDIAN-S24' }, { id:'emi_04', label:'AXIS - NOVTR', lender:'Axis Bank', amount:7700, dueDay:5, category:'personal', status:'active', note:'05-AXIS-NOVTR' }, { id:'emi_05', label:'IND - FIBE', lender:'FIBE', amount:9554, dueDay:6, category:'credit', status:'active', note:'06-IND-FIBE' }, { id:'emi_06', label:'ARAVIND - iPhone', lender:'Aravind', amount:13637, dueDay:9, category:'device', status:'active', note:'09-ARAVIND-IPHONE' }, { id:'emi_07', label:'ARAVIND - Google', lender:'Aravind', amount:5000, dueDay:9, category:'device', status:'active', note:'09-ARAVIND-GOOGLE' }, { id:'emi_08', label:'SMITHA - AXIS', lender:'Axis Bank', amount:6500, dueDay:10, category:'personal', status:'active', note:'10-SMITHA-AXIS (6479)' }, { id:'emi_09', label:'SBI - AKK Laptop', lender:'SBI', amount:39000, dueDay:24, category:'device', status:'active', note:'24-SBI-AKK LAPTOP' }, { id:'emi_10', label:'KEON Loan', lender:'KEON', amount:5000, dueDay:1, category:'personal', status:'active', note:'KEON - paid extra Feb/Mar' }, { id:'emi_11', label:'GOLD Loan Interest', lender:'Muthoot/SBI',amount:12000, dueDay:1, category:'gold', status:'active', note:'GOLD loan monthly interest' }, { id:'emi_12', label:'Shriram - Car Loan', lender:'Shriram', amount:23900, dueDay:20, category:'vehicle', status:'active', note:'CAR-20-SHRIRAM-IND' }, { id:'emi_13', label:'HDFC Credit Card', lender:'HDFC', amount:37000, dueDay:15, category:'credit', status:'active', note:'CC - varies (was 20k Feb, 37k Apr+)' }, { id:'emi_14', label:'Smitha Monthly Expenses', lender:'Personal', amount:3500, dueDay:1, category:'personal', status:'active', note:'SMITHA MONTHLY' }, ], expenses: { housing: { budget:16000, actual:16000, label:'Housing / Rent' }, tieth1: { budget:15000, actual:15000, label:'Fortnight 1 (1st-10th)' }, tieth2: { budget:5000, actual:0, label:'Fortnight 2 (10th-25th)' }, savings: { budget:5000, actual:5000, label:'Savings (SIB)' }, misc: { budget:3000, actual:3000, label:'Miscellaneous' }, food: { budget:0, actual:0, label:'Food & Groceries' }, transport: { budget:0, actual:0, label:'Fuel & Transport' }, mobile: { budget:0, actual:0, label:'Mobile & Internet' }, school: { budget:0, actual:0, label:'School & Education' }, health: { budget:0, actual:0, label:'Health & Medical' }, utilities: { budget:0, actual:0, label:'Utilities (EB, Water)' }, personal: { budget:0, actual:0, label:'Personal Care' }, }, // 6-month history from Numbers file (Feb-Jul 2026) monthlyLog: [ { month:'2026-02', income:200000, totalBudget:197276, totalPaid:194276, net:2724 }, { month:'2026-03', income:200000, totalBudget:197276, totalPaid:179276, net:2724 }, { month:'2026-04', income:200000, totalBudget:214276, totalPaid:111139, net:-14276 }, { month:'2026-05', income:200000, totalBudget:214276, totalPaid:155776, net:-14276 }, { month:'2026-06', income:200000, totalBudget:175703, totalPaid:0, net:24297 }, { month:'2026-07', income:200000, totalBudget:175703, totalPaid:0, net:24297 }, ], // Month-wise expense detail (from Numbers file) monthlyExpenseDetail: { '2026-02': [ {label:'TIETH 1', budget:15000, paid:15000}, {label:'Housing', budget:16000, paid:16000}, {label:'SBI-MUTHOOT', budget:9152, paid:9152}, {label:'INDIAN-S24', budget:3333, paid:3333}, {label:'AXIS-NOVTR', budget:7700, paid:7700}, {label:'IND-FIBE', budget:9554, paid:9554}, {label:'ARAVIND-IPHONE', budget:13637, paid:13637}, {label:'SMITHA-AXIS', budget:6500, paid:6500}, {label:'SBI-AKK LAPTOP', budget:39000, paid:39000}, {label:'KEON', budget:5000, paid:10000}, {label:'GOLD', budget:12000, paid:12000}, {label:'SMITHA MONTHLY', budget:3500, paid:3500}, {label:'Saving SIB', budget:5000, paid:5000}, {label:'CREDIT CARD', budget:20000, paid:20000}, {label:'CAR SHRIRAM', budget:23900, paid:23900}, {label:'TIETH 2', budget:5000, paid:0}, {label:'MISC', budget:3000, paid:0}, ], '2026-06': [ {label:'TIETH 1', budget:15000, paid:0}, {label:'Housing', budget:16000, paid:0}, {label:'PERSONAL-SBI', budget:9064, paid:0}, {label:'SBI-MUTHOOT', budget:9152, paid:0}, {label:'INDIAN-S24', budget:3333, paid:0}, {label:'AXIS-NOVTR', budget:7700, paid:0}, {label:'IND-FIBE', budget:9554, paid:0}, {label:'ARAVIND-GOOGLE', budget:5000, paid:0}, {label:'SMITHA-AXIS', budget:6500, paid:0}, {label:'KEON', budget:5000, paid:0}, {label:'GOLD', budget:12000, paid:0}, {label:'SMITHA MONTHLY', budget:3500, paid:0}, {label:'Saving SIB', budget:5000, paid:0}, {label:'CREDIT CARD', budget:37000, paid:0}, {label:'CAR SHRIRAM', budget:23900, paid:0}, {label:'TIETH 2', budget:5000, paid:0}, {label:'MISC', budget:3000, paid:0}, ] }, // Personal account balances (from ACCOUNTS/PERSONAL sheet, Feb 2026) accountBalances: { inhand: 1000, inb: 1100, sib: 5400, axis: 380, sbi: 1500, hdfc: 1900, hdfcCC: 4000, dbs: 18000, total: 33280 }, assets: [ { id:'a1', label:'Gold / Jewellery', category:'asset', amount:0, note:'Auto-valued from jewel/gold loan records' }, { id:'a2', label:'Vehicle (Car)', category:'asset', amount:135000, note:'Car market value (PERSONAL sheet Feb 2026)' }, { id:'a3', label:'In-hand Cash', category:'asset', amount:1000, note:'Cash in hand (Feb 2026)' }, { id:'a4', label:'INB Account', category:'asset', amount:1100, note:'INB bank balance (Feb 2026)' }, { id:'a5', label:'SIB Account', category:'asset', amount:5400, note:'SIB savings balance (Feb 2026)' }, { id:'a6', label:'HDFC Account', category:'asset', amount:1900, note:'HDFC savings (Feb 2026)' }, { id:'a7', label:'DBS Account', category:'asset', amount:18000, note:'DBS balance (Feb 2026)' }, { id:'a8', label:'Fixed Deposits', category:'asset', amount:0, note:'FDs if any' }, { id:'a9', label:'Investments (MF/Stocks)', category:'asset', amount:0, note:'Mutual funds, stocks etc.' }, ], liabilities: [ { id:'l1', label:'AKK Loan', category:'liability', amount:39000, note:'AKK loan (from PERSONAL sheet Feb 2026)' }, { id:'l2', label:'Car Loan (Shriram)', category:'liability', amount:0, note:'Shriram car loan outstanding - enter balance' }, { id:'l3', label:'Gold Loan (SBI-Muthoot)', category:'liability', amount:0, note:'Auto-synced from jewel loan records' }, { id:'l4', label:'SBI Laptop Loan (AKK)', category:'liability', amount:0, note:'EMI Rs.39,000/mo - enter outstanding balance' }, { id:'l5', label:'AXIS-NOVTR Loan', category:'liability', amount:0, note:'EMI Rs.7,700/mo - enter outstanding' }, { id:'l6', label:'FIBE Credit Line', category:'liability', amount:0, note:'EMI Rs.9,554/mo - enter outstanding' }, { id:'l7', label:'HDFC Credit Card', category:'liability', amount:0, note:'Outstanding CC balance (was 20k Feb, 37k Apr+)' }, { id:'l8', label:'KEON Loan', category:'liability', amount:0, note:'EMI Rs.5,000/mo - enter outstanding' }, { id:'l9', label:'ARAVIND iPhone Loan', category:'liability', amount:0, note:'EMI Rs.13,637/mo - enter outstanding' }, { id:'l10', label:'SBI Personal Loan', category:'liability', amount:0, note:'EMI Rs.9,064/mo from Jun 2026' }, { id:'l11', label:'Total Debt Pool', category:'liability', amount:500000, note:'Debt repayment target (PERSONAL sheet)' }, ], goldRatePerGram: 0, extraGoldGrams: 0 }; return data.personalBudget; } function pbSave() { saveData(); } // Personal Budget month state window._pbMonth = window._pbMonth || (() => { const n = new Date(); return `${n.getFullYear()}-${String(n.getMonth()+1).padStart(2,'0')}`; })(); function pbMonthData(ym) { const pb = pbData(); pb.months = pb.months || {}; if (!pb.months[ym]) { // Pre-fill from monthlyLog if available const log = (pb.monthlyLog||[]).find(l => l.month === ym); const det = (pb.monthlyExpenseDetail||{})[ym] || []; pb.months[ym] = { income: { newtonSalary: pb.income.newtonSalary||0, smithaSalary: pb.income.smithaSalary||0, additionalIncome: 0 }, emis: pb.emis.map(e => ({ id: e.id, paid: false, amount: e.amount||0, note:'' })), expenses: JSON.parse(JSON.stringify(pb.expenses)), bankTxns: {}, // { bankName: [{date,desc,debit,credit,balance,matched}] } notes: '', closed: false }; // Apply detail if available if (det.length) { det.forEach(d => { const expKey = Object.keys(pb.months[ym].expenses).find(k => pb.months[ym].expenses[k].label.toLowerCase().includes(d.label.toLowerCase().substring(0,8))); if (expKey) { pb.months[ym].expenses[expKey].budget = d.budget || pb.months[ym].expenses[expKey].budget; pb.months[ym].expenses[expKey].actual = d.paid || 0; } }); } } return pb.months[ym]; } // Personal bank statement parsers — per bank format const PB_BANK_PARSERS = { 'SBI': (rows) => { // SBI: Date | Description | Ref | Debit | Credit | Balance return rows.filter(r => r.length >= 5).map((r,i) => ({ date: parseDateFlex(r[0]), desc: (r[1]||'').trim(), ref: (r[2]||'').trim(), debit: parseAmount(r[3]), credit: parseAmount(r[4]), balance: parseAmount(r[5]||''), matched: false })).filter(t => t.date && (t.debit||t.credit)); }, 'HDFC': (rows) => { // HDFC: Date | Narration | Chq/Ref | Value Date | Withdrawal | Deposit | Balance return rows.filter(r => r.length >= 6).map((r,i) => ({ date: parseDateFlex(r[0]), desc: (r[1]||'').trim(), ref: (r[2]||'').trim(), debit: parseAmount(r[4]), credit: parseAmount(r[5]), balance: parseAmount(r[6]||''), matched: false })).filter(t => t.date && (t.debit||t.credit)); }, 'AXIS': (rows) => { // Axis Bank CSV: 19 metadata lines, then header: // Tran Date | CHQNO | PARTICULARS | DR | CR | BAL | SOL // Date format: DD-MM-YYYY, amounts space-padded, empty when zero // First: find actual data header row (contains 'Tran Date' or 'PARTICULARS') let dataStart = 0; for (let i = 0; i < rows.length; i++) { const joined = (rows[i]||[]).join(',').toLowerCase(); if (joined.includes('tran date') || joined.includes('particulars')) { dataStart = i + 1; // skip the header row itself break; } } return rows.slice(dataStart).filter(r => r.length >= 5).map(r => { // Col: 0=Date 1=CHQNO 2=PARTICULARS 3=DR 4=CR 5=BAL 6=SOL const rawDate = (r[0]||'').trim(); const desc = (r[2]||'').trim(); const ref = (r[1]||'').trim(); // Date is DD-MM-YYYY -> convert to YYYY-MM-DD const dm = rawDate.match(/(\d{2})-(\d{2})-(\d{4})/); const date = dm ? `${dm[3]}-${dm[2]}-${dm[1]}` : parseDateFlex(rawDate); const debit = parseAmount(r[3]||''); const credit = parseAmount(r[4]||''); const bal = parseAmount(r[5]||''); return { date, desc, ref, debit, credit, balance: bal, matched: false }; }).filter(t => t.date && (t.debit || t.credit)); }, 'ICICI': (rows) => { // ICICI: Date | Transaction Remarks | Withdrawal Amount | Deposit Amount | Balance return rows.filter(r => r.length >= 4).map(r => ({ date: parseDateFlex(r[0]), desc: (r[1]||'').trim(), ref: '', debit: parseAmount(r[2]), credit: parseAmount(r[3]), balance: parseAmount(r[4]||''), matched: false })).filter(t => t.date && (t.debit||t.credit)); }, 'DBS': (rows) => { // DBS: Date | Description | Debit | Credit | Available Balance return rows.filter(r => r.length >= 4).map(r => ({ date: parseDateFlex(r[0]), desc: (r[1]||'').trim(), ref: '', debit: parseAmount(r[2]), credit: parseAmount(r[3]), balance: parseAmount(r[4]||''), matched: false })).filter(t => t.date && (t.debit||t.credit)); }, 'IOB': (rows) => { // IOB: Sl No | Txn Date | Value Date | Description | Ref No | Debit | Credit | Balance return rows.filter(r => r.length >= 6).map(r => ({ date: parseDateFlex(r[1]||r[2]), desc: (r[3]||'').trim(), ref: (r[4]||'').trim(), debit: parseAmount(r[5]), credit: parseAmount(r[6]), balance: parseAmount(r[7]||''), matched: false })).filter(t => t.date && (t.debit||t.credit)); }, 'KBL': (rows) => { // KBL (Karnataka Bank): Date | Particulars | Withdrawals | Deposits | Balance return rows.filter(r => r.length >= 4).map(r => ({ date: parseDateFlex(r[0]), desc: (r[1]||'').trim(), ref: '', debit: parseAmount(r[2]), credit: parseAmount(r[3]), balance: parseAmount(r[4]||''), matched: false })).filter(t => t.date && (t.debit||t.credit)); }, 'Indian': (rows) => { // Indian Bank Excel format: // Rows 0-13: metadata (A/C Holder, Address, IFSC, etc.) // Row 14 (0-indexed): Column headers: // Txn Date | Description | Cheque No | Debit Amount | Credit Amount | Balance // Date: '2026-04-02 00:00:00' -> extract 'YYYY-MM-DD' // Amounts: '299.0' or '' for zero // Balance: '3165.870 CR' or '500.000 DR' -> strip CR/DR suffix // Last row: 'Total' summary row -> skip // Self-header detection: find row containing 'Txn Date' or 'Description' let dataStart = 0; for (let i = 0; i < Math.min(20, rows.length); i++) { const joined = (rows[i]||[]).join('|').toLowerCase(); if (joined.includes('txn date') || joined.includes('debit amount')) { dataStart = i + 1; break; } } return rows.slice(dataStart).filter(r => { const first = String(r[0]||'').trim(); // Skip total/summary rows and empty rows if (!first || first.toLowerCase() === 'total') return false; // Must have a date-like value return /\d{4}-\d{2}-\d{2}/.test(first); }).map(r => { // Date: '2026-04-02 00:00:00' -> '2026-04-02' const rawDate = String(r[0]||'').trim(); const date = rawDate.includes(' ') ? rawDate.split(' ')[0] : rawDate; const desc = String(r[1]||'').trim(); const ref = String(r[2]||'').trim(); // Debit/Credit: plain number string or empty const debit = parseAmount(String(r[3]||'')); const credit = parseAmount(String(r[4]||'')); // Balance: '3165.870 CR' -> strip suffix const balRaw = String(r[5]||'').replace(/(\s*(CR|DR)\s*)$/i,'').trim(); const balance = parseFloat(balRaw.replace(/,/g,'')) || 0; return { date, desc, ref, debit, credit, balance, matched: false }; }).filter(t => t.date && (t.debit || t.credit)); }, 'Generic': (rows) => { const cols = autoDetectColumns(rows); return rowsToTransactions(rows, cols, 'personal'); } }; // INB card uses bank code 'INB' — alias it to the Indian Bank Excel parser PB_BANK_PARSERS['INB'] = PB_BANK_PARSERS['Indian']; // ── PDF text parsers (raw text from pdfjs extractPdfText) ──────────── // These handle PDF statements whose layout is text, not tabular rows. // Each takes the full extracted text and returns {header, txns}. const PB_PDF_PARSERS = { // Indian Bank (INB) PDF — layout: // Date | Transaction Details | Debits | Credits | Balance // First line of each txn: "01 Apr 2026 INR 20,000.00 - INR 22,184.61" // (debit|"-") (credit|"-") balance — desc wraps onto following lines 'INB_PDF': (text) => { const lines = text.split('\n').map(l => l.trim()).filter(Boolean); const DATE = /^(\d{2}\s[A-Za-z]{3}\s\d{4})\b/; // three trailing money columns anchored to end of line const TAIL = /(INR\s[\d,]+\.\d{2}|-)\s+(INR\s[\d,]+\.\d{2}|-)\s+(INR\s[\d,]+\.\d{2})\s*$/; const money = (s) => { s = (s||'').trim(); if (s === '-' || !s) return 0; return parseFloat(s.replace(/INR/gi,'').replace(/,/g,'').trim()) || 0; }; // Auto-fetch header fields const grab = (re) => { const m = text.match(re); return m ? m[1].trim() : ''; }; const header = { holder: grab(/Account Holder Name\s+([A-Z][A-Za-z.\s]+?)(?:\s+Opening|\n|Account Type)/i), accNo: grab(/Account Number\s+(\d+)/i), ifsc: grab(/IFSC\s+([A-Z]{4}0[A-Z0-9]{6})/i), branch: grab(/Branch Name\s+([A-Za-z.\s]+?)(?:\s+IFSC|\n)/i), period: grab(/For period:\s*([0-9A-Za-z\s\-]+?)(?:\n|ACCOUNT)/i), openBal: money(grab(/Opening Balance\s+(INR\s[\d,]+\.\d{2})/i)), closeBal: money(grab(/Ending Balance\s+(INR\s[\d,]+\.\d{2})/i)), totCredit:money(grab(/Total Credits\s*\+?\s*(INR\s[\d,]+\.\d{2})/i)), totDebit: money(grab(/Total Debits\s*-?\s*(INR\s[\d,]+\.\d{2})/i)) }; const txns = []; let cur = null; const isNoise = (ln) => (ln.startsWith('Date Transaction')) || (ln.startsWith('Date ') && ln.includes('Balance')) || ln.startsWith('Ending Balance') || ln.startsWith('Total INR') || /Indian Bank\s*\|/.test(ln); for (const ln of lines) { const m = ln.match(DATE), t = ln.match(TAIL); if (m && t) { if (cur) txns.push(cur); cur = { date: parseDateFlex(m[1]), desc: ln.slice(m[0].length, ln.length - t[0].length).replace(/\s+/g,' ').trim(), ref: '', debit: money(t[1]), credit: money(t[2]), balance: money(t[3]), matched: false }; } else if (cur && !isNoise(ln)) { cur.desc = (cur.desc + ' ' + ln).replace(/\s+/g,' ').trim(); } } if (cur) txns.push(cur); const clean = txns.filter(x => x.date && (x.debit || x.credit)); return { header, txns: clean }; } }; // Map a bank code to its PDF parser (only banks with PDF support) const PB_PDF_PARSER_FOR = { 'INB':'INB_PDF', 'Indian':'INB_PDF' }; // Spending category rules for auto-categorization const SPEND_CATEGORIES = [ { key:'food', label:'Food & Groceries', color:'var(--amber)', icon:'🍽', keywords:['zomato','swiggy','grocery','supermarket','bigbasket','grofer','blinkit','zepto','kirana','sweets','bakery','restaurant','hotel','cafe','fruits','vegetables','milk'] }, { key:'transport', label:'Fuel & Transport', color:'var(--sky)', icon:'🚗', keywords:['fuel','petrol','diesel','uber','ola','rapido','metro','bus','auto','cab','parking','toll','bpcl','hpcl','iocl'] }, { key:'mobile', label:'Mobile & Internet', color:'var(--violet)', icon:'📱', keywords:['airtel','jio','bsnl','vodafone','recharge','broadband','internet','dth','tatasky','dishtv'] }, { key:'health', label:'Health & Medical', color:'var(--rose)', icon:'🏥', keywords:['hospital','clinic','pharmacy','medical','medicine','doctor','apollo','health','lab','diagnostic','chemist'] }, { key:'utilities', label:'Utilities (EB, Water)', color:'var(--sky)', icon:'⚡', keywords:['electricity','tneb','bescom','water board','gas','lpg','municipal','panchayat','metro water','utility'] }, { key:'school', label:'School & Education', color:'var(--violet)', icon:'📚', keywords:['school','college','tuition','fees','education','book','stationery','exam','coaching','institute'] }, { key:'shopping', label:'Shopping & Retail', color:'var(--amber)', icon:'🛍', keywords:['amazon','flipkart','myntra','meesho','ajio','nykaa','reliance','dmart','mall','footlocker','aruppukottai'] }, { key:'emi', label:'EMI / Loan Payment', color:'var(--rose)', icon:'🏦', keywords:['nach','ach-dr','ecs','muthoot','shriram','fibe','keon','instalment','emi'] }, { key:'entertainment', label:'Entertainment', color:'var(--violet)', icon:'🎬', keywords:['netflix','amazon prime','hotstar','disney','spotify','youtube','cinema','pvr','inox','bookmyshow','apple media','apple_media'] }, { key:'housing', label:'Housing / Rent', color:'var(--sky)', icon:'🏠', keywords:['rent','maintenance','society','apartment','flat'] }, { key:'transfer', label:'Transfer / IMPS', color:'var(--text-muted)', icon:'↔', keywords:['imps commission','transfer to 88907','transfer to 97158','transfer from 97157'] }, { key:'personal', label:'Personal Care', color:'var(--accent)', icon:'💆', keywords:['salon','parlour','saloon','beauty','spa'] }, { key:'misc', label:'Miscellaneous', color:'var(--text-muted)', icon:'📦', keywords:[] } ]; function autoCategorizeTxn(desc) { const d = (desc||'').toLowerCase(); for (const cat of SPEND_CATEGORIES) { if (cat.keywords.length && cat.keywords.some(k => d.includes(k))) return cat.key; } return 'misc'; } function txnCatColor(key) { const c = SPEND_CATEGORIES.find(x => x.key === key); return c ? c.color : 'var(--text-muted)'; } function txnCatIcon(key) { const c = SPEND_CATEGORIES.find(x => x.key === key); return c ? c.icon : ''; } function setTxnMatch(bankName, txnIdx, matchVal, ym2) { const targetYm = ym2 || window._pbMonth || ""; const md = pbMonthData(targetYm); if (!md.bankTxns || !md.bankTxns[bankName]) return; const t = md.bankTxns[bankName][txnIdx]; if (!t) return; if (!matchVal) { t.matched = false; t.matchedTo = null; t.matchedLabel = null; } else if (matchVal === "ignore") { t.matched = true; t.matchedTo = "ignore"; t.matchedLabel = "Ignored"; } else if (matchVal === "personal") { t.matched = true; t.matchedTo = "personal"; t.matchedLabel = "Personal"; } else if (matchVal.startsWith("emi:")) { const emiId = matchVal.slice(4); const emi = pbData().emis.find(e => String(e.id) === emiId); t.matched = true; t.matchedTo = emiId; t.matchedLabel = emi ? emi.label : emiId; md.emis = md.emis || []; const mi = md.emis.findIndex(x => x.id === emiId); const entry = { id:emiId, paid:true, amount:emi?emi.amount:0, actualAmt:t.debit, paidDate:t.date, bankRef:(t.desc||"").slice(0,60) }; if (mi >= 0) Object.assign(md.emis[mi], entry); else md.emis.push(entry); } else if (matchVal.startsWith("proj:")) { const projId = matchVal.slice(5); const proj = (data.projects||[]).find(p => String(p.id) === projId); t.matched = true; t.matchedTo = matchVal; t.matchedLabel = proj ? projNick(proj) : projId; } else if (matchVal.startsWith("loan:")) { const loanId = matchVal.slice(5); const loan = (data.loans||[]).find(l => String(l.id) === loanId); t.matched = true; t.matchedTo = matchVal; t.matchedLabel = loan ? loan.lender : loanId; } pbSave(); syncSpendingFromBank(targetYm); } function setTxnCategory(bankName, txnIdx, catKey, ym) { const md = pbMonthData(ym || window._pbMonth || ''); if (md.bankTxns && md.bankTxns[bankName] && md.bankTxns[bankName][txnIdx]) { md.bankTxns[bankName][txnIdx].category = catKey; pbSave(); syncSpendingFromBank(ym || window._pbMonth || ''); } } function syncSpendingFromBank(ym) { // Aggregate debit txns by category -> update expenses.actual const md = pbMonthData(ym); const allTxns = Object.values(md.bankTxns || {}).flat(); const catTotals = {}; allTxns.filter(t => t.debit > 0).forEach(t => { const cat = t.category || 'misc'; if (cat === 'emi' || cat === 'transfer') return; // skip EMIs/transfers catTotals[cat] = (catTotals[cat] || 0) + t.debit; }); // Map to expense keys const MAP = { food:'food', transport:'transport', mobile:'mobile', health:'health', utilities:'utilities', school:'school', misc:'misc', housing:'housing', shopping:'misc', entertainment:'misc', personal:'personal' }; // Reset all bank-synced actuals to 0 first md.bankSourced = true; Object.entries(MAP).forEach(([from, to]) => { if (catTotals[from] && md.expenses && md.expenses[to]) { md.expenses[to].actual = (md.expenses[to].actual||0); } }); // Set actuals from bank data const expTotals = {}; Object.entries(catTotals).forEach(([cat, total]) => { const key = MAP[cat] || 'misc'; expTotals[key] = (expTotals[key] || 0) + total; }); Object.entries(expTotals).forEach(([key, total]) => { if (md.expenses && md.expenses[key]) { md.expenses[key].actual = Math.round(total); } }); pbSave(); } async function pbUploadStatement(bankName, file, ym) { const ext = file.name.split('.').pop().toLowerCase(); let rows = []; let allParsedTxns = null; // set directly by PDF path; else built from rows below let pdfHeader = null; // auto-fetched header fields from PDF statements // ── PDF statement path ─────────────────────────────────────────── if (ext === 'pdf') { const pdfKey = PB_PDF_PARSER_FOR[bankName]; if (!pdfKey) { alert('PDF upload is not supported for ' + bankName + ' yet.\nSupported: INB (Indian Bank). Use CSV/Excel for other banks.'); return; } let text = ''; try { const ab = await file.arrayBuffer(); text = await extractPdfText(ab); } catch (e) { alert('Could not read PDF: ' + (e.message || e)); return; } const result = PB_PDF_PARSERS[pdfKey](text); allParsedTxns = (result.txns || []).filter(t => t.date); pdfHeader = result.header || null; if (allParsedTxns.length === 0) { alert('No transactions found in this PDF. Check that it is an Indian Bank statement.'); return; } // Reconcile against the statement footer totals (auto-fetched) if (pdfHeader) { const sumD = allParsedTxns.reduce((s,t)=>s+t.debit,0); const sumC = allParsedTxns.reduce((s,t)=>s+t.credit,0); const dOk = !pdfHeader.totDebit || Math.abs(sumD - pdfHeader.totDebit) < 1; const cOk = !pdfHeader.totCredit || Math.abs(sumC - pdfHeader.totCredit) < 1; if (!dOk || !cOk) { const proceed = confirm( 'PDF parsed, but totals do not tie out to the statement footer:\n\n' + 'Debits : parsed ' + fmtINR(sumD) + ' vs stated ' + fmtINR(pdfHeader.totDebit) + '\n' + 'Credits : parsed ' + fmtINR(sumC) + ' vs stated ' + fmtINR(pdfHeader.totCredit) + '\n\n' + 'OK = import anyway · Cancel = abort' ); if (!proceed) return; } } } else if (ext === 'csv') { const text = await file.text(); rows = parseCSVtoRows(text); } else if (ext === 'xlsx' || ext === 'xls') { const ab = await file.arrayBuffer(); const wb = window.XLSX?.read(ab, {type:'array', cellDates:true}); if (!wb) { alert('SheetJS not available. Use CSV.'); return; } // Use cellDates:true so date cells become JS Date objects const ws0 = wb.Sheets[wb.SheetNames[0]]; rows = window.XLSX.utils.sheet_to_json(ws0, {header:1, raw:true, cellDates:true, defval:''}); // Normalise every cell to a string rows = rows.map(r => (r||[]).map(v => { if (v === null || v === undefined || v === '') return ''; // JS Date object from cellDates:true if (v instanceof Date) { return v.getFullYear()+'-'+String(v.getMonth()+1).padStart(2,'0')+'-'+String(v.getDate()).padStart(2,'0'); } // Excel date serial number (number < 100000 and no decimal > 0.99) if (typeof v === 'number' && v > 40000 && v < 50000 && (v%1) < 0.01) { const d = new Date(Math.round((v - 25569) * 86400 * 1000)); return d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0'); } return String(v); })); } else { alert('Use CSV, Excel, or PDF format.'); return; } // Row-based parsing (CSV/Excel only — PDF path already set allParsedTxns) if (allParsedTxns === null) { if (rows.length < 2) { alert('File appears empty'); return; } // Skip metadata header rows - scan up to 30 rows for first date row // (Axis Bank has 19 header rows; other banks have fewer) // For AXIS we pass ALL rows and let the parser find its own header const _selfHeaderBanks = ['AXIS', 'IOB', 'Indian', 'INB']; let dataRows = rows; if (!_selfHeaderBanks.includes(bankName)) { const dateRe = /\d{1,2}[\/\-\.]\d{1,2}[\/\-\.]\d{2,4}|\d{4}-\d{2}-\d{2}/; let headerIdx = 0; for (let i=0; i dateRe.test(String(c||'')))) { headerIdx = i; break; } } dataRows = rows.slice(headerIdx); } const parser = PB_BANK_PARSERS[bankName] || PB_BANK_PARSERS['Generic']; allParsedTxns = parser(dataRows).filter(t => t.date); } if (allParsedTxns.length === 0) { alert('No transactions found in this file. Check the format or bank selection.'); return; } // Detect statement month from data const _stmtMonths = {}; allParsedTxns.forEach(t => { const mo=t.date.slice(0,7); _stmtMonths[mo]=(_stmtMonths[mo]||0)+1; }); const detectedMonth = Object.entries(_stmtMonths).sort((a,b)=>b[1]-a[1])[0]?.[0]||ym; // Duplicate detection: check if this bank+month already has data const existingMd = pbMonthData(detectedMonth); const existingTxns = existingMd.bankTxns?.[bankName]||[]; if (existingTxns.length > 0) { const months=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; const [ey,emn]=detectedMonth.split('-'); const mLabel = months[parseInt(emn)-1]+' '+ey; const choice = confirm( 'DUPLICATE DETECTED!\n\n' + bankName + ' statement for ' + mLabel + ' already has ' + existingTxns.length + ' transactions.\n\n' + 'Options:\n' + 'OK = REPLACE existing data with new upload\n' + 'Cancel = KEEP existing data (abort upload)' ); if (!choice) { alert('Upload cancelled. Existing data kept.'); return; } } // Suppress the already-exists alert above if (allParsedTxns.length === 0) { alert("No transactions found in this file. Check the format."); return; } // Detect the actual statement month from the transactions const stmtMonths = {}; allParsedTxns.forEach(t => { const mo = t.date.slice(0,7); // YYYY-MM stmtMonths[mo] = (stmtMonths[mo]||0) + 1; }); // Use the month with most transactions as the statement month const stmtMonth = Object.entries(stmtMonths).sort((a,b)=>b[1]-a[1])[0]?.[0] || ym; const [_y,_mn] = ym.split('-'); const txnsThisMonth = allParsedTxns.filter(t => t.date.startsWith(`${_y}-${_mn}`)); // If the statement month differs from the selected month, auto-switch let targetYm = ym; let monthSwitched = false; if (txnsThisMonth.length === 0 && stmtMonth !== ym) { targetYm = stmtMonth; window._pbMonth = stmtMonth; monthSwitched = true; } // Filter to the target month (or all if same month) const txns = txnsThisMonth.length > 0 ? txnsThisMonth : allParsedTxns.filter(t => t.date.startsWith(targetYm)); const md = pbMonthData(targetYm); md.bankTxns = md.bankTxns || {}; md.bankTxns[bankName] = txns; // Auto-categorize all transactions allParsedTxns.forEach(t => { if (!t.category) t.category = autoCategorizeTxn(t.desc); }); txns.forEach(t => { if (!t.category) t.category = autoCategorizeTxn(t.desc); }); // Auto-match EMIs to transactions const pb = pbData(); pb.emis.forEach(emi => { const emiLabel = emi.label.toLowerCase(); const emiLender = (emi.lender||'').toLowerCase(); const emiNote = (emi.note||'').toLowerCase(); // Build keyword list: lender words, label words, note fragments const keywords = [ ...emiLender.split(/[\s\/\-]+/).filter(k=>k.length>3), ...emiLabel.split(/[\s\/\-]+/).filter(k=>k.length>3), ...emiNote.split(/[\s\/\-]+/).filter(k=>k.length>3), ]; txns.forEach(t => { if (t.matched) return; // already matched const desc = (t.desc||'').toLowerCase(); // ACH/NACH debits likely = EMI const isACH = /^ach|^nach|^ecs/.test(desc); // Match by amount + any keyword const amtMatch = emi.amount > 0 && Math.abs(t.debit - emi.amount) < 50; const kwMatch = keywords.some(k => k && desc.includes(k)); if (t.debit > 0 && (kwMatch || (isACH && amtMatch))) { t.matched = true; t.matchedTo = emi.id; t.matchedLabel = emi.label; // Auto-mark paid + store actual bank amount md.emis = md.emis || []; const mi = md.emis.findIndex(x => x.id === emi.id); const entry = { id: emi.id, paid: true, amount: emi.amount, actualAmt: t.debit, paidDate: t.date, bankRef: (t.desc||''). slice(0,60) }; if (mi >= 0) Object.assign(md.emis[mi], entry); else md.emis.push(entry); } }); }); pbData().months = pbData().months || {}; pbData().months[targetYm] = md; pbSave(); syncSpendingFromBank(targetYm); const matched2 = txns.filter(t=>t.matched).length; const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; const fmtM = m => { const [y,mn]=m.split('-'); return months[parseInt(mn)-1]+' '+y; }; let msg = 'Loaded ' + txns.length + ' transactions for ' + bankName + '\n'; msg += 'Statement period: ' + fmtM(targetYm) + '\n'; if (monthSwitched) msg += '(Auto-switched view to ' + fmtM(targetYm) + ')\n'; msg += matched2 + ' EMIs auto-matched & marked paid'; alert(msg); renderPersonalBudget(); } function pbAddCustomBank() { const name = prompt('Enter bank name (e.g. CANARA, UPI, CASH):'); if (!name || !name.trim()) return; const cleaned = name.trim().toUpperCase(); const pb = pbData(); pb.customBanks = pb.customBanks || []; const all = ['SBI','HDFC','AXIS','ICICI','DBS','IOB','KBL','Indian',...pb.customBanks]; if (all.includes(cleaned)) { alert(cleaned + ' already exists'); return; } pb.customBanks.push(cleaned); pbSave(); renderPersonalBudget(); } function pbRemoveBank(bankName) { const defaultBanks = ['SBI','HDFC','AXIS','ICICI','DBS','IOB','KBL','Indian']; const pb = pbData(); const allMonths = Object.keys(pb.months||{}); const hasData = allMonths.some(m => (pb.months[m]?.bankTxns?.[bankName]||[]).length > 0); const isDefault = defaultBanks.includes(bankName); const msg = (hasData ? 'WARNING: ' + bankName + ' has uploaded statement data that will be cleared.\n\n' : '') + (isDefault ? bankName + ' is a built-in bank. It will be hidden but data is preserved.\n\n' : '') + 'Remove ' + bankName + ' card from the dashboard?'; if (!confirm(msg)) return; // For default banks: add to hidden list; for custom: remove from customBanks if (isDefault) { pb.hiddenBanks = pb.hiddenBanks || []; if (!pb.hiddenBanks.includes(bankName)) pb.hiddenBanks.push(bankName); } else { pb.customBanks = (pb.customBanks||[]).filter(b => b !== bankName); } pbSave(); renderPersonalBudget(); } function pbRefreshBank(bankName, ym) { // Re-run EMI matching + categorization on existing txns const md = pbMonthData(ym); if (!md.bankTxns || !md.bankTxns[bankName]) return; const txns = md.bankTxns[bankName]; txns.forEach(t => { if (!t.category) t.category = autoCategorizeTxn(t.desc); }); const pb = pbData(); pb.emis.forEach(emi => { const keywords = [ ...(emi.lender||'').toLowerCase().split(/[\s\/\-]+/).filter(k=>k.length>3), ...(emi.label||'').toLowerCase().split(/[\s\/\-]+/).filter(k=>k.length>3), ]; txns.forEach(t => { const desc = (t.desc||'').toLowerCase(); const isACH = /^ach|^nach|^ecs/.test(desc); const amtMatch = emi.amount > 0 && Math.abs(t.debit - emi.amount) < 100; const kwMatch = keywords.some(k => k && desc.includes(k)); if (t.debit > 0 && (kwMatch || (isACH && amtMatch))) { t.matched = true; t.matchedTo = emi.id; const mi = md.emis.findIndex(x => x.id === emi.id); const entry = { id:emi.id, paid:true, amount:emi.amount, actualAmt:t.debit, paidDate:t.date }; if (mi >= 0) Object.assign(md.emis[mi], entry); else md.emis.push(entry); } }); }); pbSave(); syncSpendingFromBank(ym); renderPersonalBudget(); } function pbRefreshAllStatements(ym) { const md = pbMonthData(ym); Object.keys(md.bankTxns||{}).forEach(bname => pbRefreshBank(bname, ym)); alert('All statements re-processed for ' + ym); } function pbDeleteMonthStatements(ym) { const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; const [y,mn] = ym.split('-'); const label = months[parseInt(mn)-1] + ' ' + y; if (!confirm('Delete ALL bank statements for ' + label + '?\nThis will clear all transactions and matched EMIs for this month.\nThis cannot be undone.')) return; const md = pbMonthData(ym); md.bankTxns = {}; md.emis = (md.emis||[]).map(e => ({...e, paid:false, actualAmt:undefined, paidDate:undefined, bankRef:undefined})); pbSave(); syncSpendingFromBank(ym); renderPersonalBudget(); } function renderPersonalBudget() { const el = document.getElementById('personalBudgetContent'); if (!el) return; const pb = pbData(); const ym = window._pbMonth; const md = pbMonthData(ym); const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; const fmtMonth = m => { const [y,mn] = m.split('-'); return months[parseInt(mn)-1]+' '+y; }; const income = md.income || pb.income; const totalIncome = (income.newtonSalary||0) + (income.smithaSalary||0) + (income.additionalIncome||0); const activeEMIs = pb.emis.filter(e => e.status === 'active'); const totalEMI = activeEMIs.reduce((s,e) => s+(e.amount||0), 0); const expenses = md.expenses || pb.expenses; const totalExpense = Object.values(expenses).reduce((s,e) => s+(e.actual||0), 0); const totalBudget = Object.values(expenses).reduce((s,e) => s+(e.budget||0), 0); const leftOver = totalIncome - totalEMI - totalExpense; const emiRatio = totalIncome > 0 ? (totalEMI/totalIncome*100).toFixed(0) : 0; const savingsRate = totalIncome > 0 ? (leftOver/totalIncome*100).toFixed(1) : '0'; // Generate month options (Jan 2026 to Dec 2027) const monthOpts = []; for (let y=2026; y<=2027; y++) { for (let m=1; m<=12; m++) { const k = `${y}-${String(m).padStart(2,'0')}`; monthOpts.push(``); } } // Bank txn summary const bankTxns = md.bankTxns || {}; const defaultBanks = ['SBI','HDFC','AXIS','ICICI','DBS','IOB','KBL','Indian']; pb.customBanks = pb.customBanks || []; pb.hiddenBanks = pb.hiddenBanks || []; const allBanks = [...defaultBanks, ...pb.customBanks.filter(b => !defaultBanks.includes(b))]; const bankNames = allBanks.filter(b => !pb.hiddenBanks.includes(b)); const allTxns = Object.values(bankTxns).flat(); const totalDebits = allTxns.filter(t=>t.debit>0).reduce((s,t)=>s+t.debit,0); const totalCredits = allTxns.filter(t=>t.credit>0).reduce((s,t)=>s+t.credit,0); const matched = allTxns.filter(t=>t.matched).length; el.innerHTML = `
Personal Budget — Newton & Smitha
Monthly tracker  ·  Select month to enter or view data
EMI/Income
${emiRatio}%
${allTxns.length > 0 ? `
${Object.keys(bankTxns).join(" + ")} Statement Loaded  ·  ${allTxns.length} transactions
Debit: ${fmtINR(totalDebits)}  ·  Credit: ${fmtINR(totalCredits)}  ·  ${matched} EMIs matched
` : ""} ${parseFloat(emiRatio) > 80 ? `
⚠ CRITICAL: EMIs consume ${emiRatio}% of income. Leaves only ${fmtINR(Math.abs(leftOver))} ${leftOver<0?'shortfall':'surplus'} after all obligations.
` : ''}
Total Income — ${fmtMonth(ym)}
${fmtINR(totalIncome)}
Newton + Smitha
Total EMI ${allTxns.length>0?'(Bank Verified)':''}
${fmtINR(totalEMI)}
${activeEMIs.length} EMIs ${allTxns.length>0?' ·  '+md.emis.filter(e=>e.paid).length+' paid via bank':''}
Other Expenses
${fmtINR(totalExpense)}
vs budget ${fmtINR(totalBudget)}
Left Over
${fmtINR(Math.abs(leftOver))}
${leftOver>=0?'▲ Surplus':'▼ Shortfall'}  ·  ${savingsRate}%
💰 Income — ${fmtMonth(ym)}
${[ {label:"Newton's Salary", key:'newtonSalary', val:income.newtonSalary||0}, {label:"Smitha's Income", key:'smithaSalary', val:income.smithaSalary||0}, {label:'Additional Income', key:'additionalIncome', val:income.additionalIncome||0}, ].map(r=>`
${r.label}
`).join('')}
Total${fmtINR(totalIncome)}
📈 Monthly Flow
${totalIncome > 0 ? ` ${[{label:'EMI Payments',val:totalEMI,color:'var(--rose)'},{label:'Expenses',val:totalExpense,color:'var(--amber)'},{label:'Left Over',val:Math.abs(leftOver),color:leftOver>=0?'var(--accent)':'var(--rose)'}].map(r=>`
${r.label} ${fmtINR(r.val)} (${(r.val/totalIncome*100).toFixed(0)}%)
`).join('')}` : '
Enter income above
'}
${allTxns.length > 0 ? `
✓ Bank Reconciliation
${md.emis.filter(e=>e.paid).length} EMIs found in bank  ·  ${fmtMonth(ym)}
${pb.emis.filter(e=>e.status==='active'&&e.amount>0).sort((a,b)=>a.dueDay-b.dueDay).map(emi => { const me = md.emis.find(x=>x.id===emi.id); const isPaid = me && me.paid; const actual = me && me.actualAmt ? me.actualAmt : null; const diff = actual !== null ? (actual - emi.amount) : null; return ``; }).join('')}
EMILender Budgeted Actual (Bank) Diff Date PaidStatus
${escHtml(emi.label)} ${escHtml(emi.lender)} ${fmtINR(emi.amount)} ${actual?fmtINR(actual):'-'} ${diff===null?'Not found':Math.abs(diff)<5?'Exact':diff>0?'+'+fmtINR(diff):'-'+fmtINR(Math.abs(diff))} ${me&&me.paidDate?me.paidDate:'-'} ${isPaid ?'✓ Paid via Bank' :'Not Found in Stmt'}
Total ${fmtINR(pb.emis.filter(e=>e.status==='active'&&e.amount>0).reduce((s,e)=>s+e.amount,0))} ${fmtINR(md.emis.filter(e=>e.paid&&e.actualAmt).reduce((s,e)=>s+(e.actualAmt||0),0))} ${md.emis.filter(e=>e.paid).length} of ${pb.emis.filter(e=>e.status==='active'&&e.amount>0).length} verified
` : ""}
💳 EMI Status — ${fmtMonth(ym)}
${md.emis.filter(e=>e.paid).length}/${md.emis.length} paid
${activeEMIs.map((emi,i) => { const me = md.emis.find(e=>e.id===emi.id) || {paid:false, amount:emi.amount}; const matched = Object.values(bankTxns).flat().find(t=>t.matchedTo===emi.id); return `
${escHtml(emi.label)}
${emi.lender}  ·  Due ${emi.dueDay}th${matched?'✓ Bank matched':''}
${fmtINR(emi.amount)}
`; }).join('')}
🏠 Expenses Detail — ${fmtMonth(ym)}
${fmtINR(totalExpense)} / ${fmtINR(totalBudget)}
${window._expTableOpen===false ? '' : `
${Object.entries(expenses).map(([key,exp]) => { const diff = (exp.budget||0)-(exp.actual||0); const over = exp.budget>0 && exp.actual>exp.budget; const pct = exp.budget>0 ? Math.min(exp.actual/exp.budget*100,150) : 0; return ``; }).join('')}
CategoryBudgetActualBarDiff
${exp.label} ${exp.budget>0?`
`:''}
${exp.budget>0?(over?'−':'+')+ fmtINR(Math.abs(diff)):'—'}
TOTAL ${fmtINR(totalBudget)} ${fmtINR(totalExpense)} ${totalBudget>0?fmtINR(Math.abs(totalBudget-totalExpense)):'—'}
`}
🏭 Bank Statements
Select a month to upload that month's statement  ·  Duplicates detected automatically
${allTxns.length > 0 ? `
Transactions
${allTxns.length}
Total Debit
${fmtINR(totalDebits)}
Total Credit
${fmtINR(totalCredits)}
Matched
${matched}
Unmatched Debits
${allTxns.filter(t=>t.debit>0&&!t.matched).length}
${Object.keys(bankTxns).join(' + ')}
` : ''}
${bankNames.map(bname => { const btxns = bankTxns[bname] || []; const bDeb = btxns.filter(t=>t.debit>0).reduce((s,t)=>s+t.debit,0); const bCred = btxns.filter(t=>t.credit>0).reduce((s,t)=>s+t.credit,0); const bMatch = btxns.filter(t=>t.matched).length; const hasData= btxns.length > 0; // Check all months for this bank across all stored months const allMonthsWithBank = Object.keys(pb.months||{}).filter(m => pbData().months[m]?.bankTxns?.[bname]?.length > 0); return `
🏢 ${bname}${PB_PDF_PARSER_FOR[bname] ? ' PDF' : ''}
${allMonthsWithBank.length > 0 ? `${allMonthsWithBank.length} mo` : ''} ${hasData ? `${btxns.length} txns` : ''}
${hasData ? `
Dr: ${fmtINR(bDeb)}  ·  Cr: ${fmtINR(bCred)}
${bMatch} EMIs matched  ·  ${fmtMonth(ym)}
` : `
No statement for ${fmtMonth(ym)}
`} ${allMonthsWithBank.length > 0 && !hasData ? `
Data in: ${allMonthsWithBank.slice(0,3).map(m=>fmtMonth(m)).join(', ')}${allMonthsWithBank.length>3?'...':''}
` : ''}
Upload to month:
${hasData ? ` ` : ''}
`; }).join('')}
Add Bank
Click to add a custom bank account
${allTxns.length > 0 ? `
All Transactions — ${fmtMonth(ym)}
${(()=>{ const search = document.getElementById('txnSearch')?.value?.toLowerCase()||''; const catFilt = document.getElementById('txnCatFilter')?.value||''; return Object.entries(bankTxns).flatMap(([bname,txns]) => txns.filter(t => { if (search && !(t.desc||'').toLowerCase().includes(search) && !String(t.debit||'').includes(search)) return false; if (catFilt && (t.category||'misc') !== catFilt) return false; const matchFilt = document.getElementById('txnMatchFilter')?.value||''; if (matchFilt === 'unmatched' && t.matched) return false; if (matchFilt === 'matched' && !t.matched) return false; return true; }).map((t,ti)=>``) ).join(''); })()}
BankDateDescriptionDebitCreditCategory🔗 Match / Link
${bname} ${t.date} ${escHtml((t.desc||'').slice(0,55))} ${t.debit>0?fmtINR(t.debit):''} ${t.credit>0?fmtINR(t.credit):''} ${t.matchedLabel && t.matchedTo !== 'ignore' ? `
✓ ${escHtml(t.matchedLabel)}
` : ''}
` : ''}
📋 Notes — ${fmtMonth(ym)}
`; } // Month picker: renders 2 selects (month + year) in place of function monthPickerHTML(id, val, onchangeExpr) { const MN = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; const [cy, cm] = val ? val.split("-") : ["",""]; const years = []; for (let y=2020; y<=2032; y++) years.push(y); const mSel = MN.map((m,i)=>{ const v = String(i+1).padStart(2,"0"); return ``; }).join(""); const ySel = years.map(y=>``).join(""); return `
`; } // Read value from month picker function monthPickerVal(id) { const m = document.getElementById('mpM_'+id)?.value; const y = document.getElementById('mpY_'+id)?.value; return (m && y) ? y+"-"+m : ""; } function openEMIDetail(emiIdx) { const pb = pbData(); const e = pb.emis[emiIdx]; if (!e) return; const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; const fmtM = m => { if (!m) return '—'; const [y,mn]=m.split('-'); return months[parseInt(mn)-1]+' '+y; }; const principal = e.principal || 0; const tenure = e.tenure || 0; const totalPayable = e.amount && tenure ? Math.round(e.amount * tenure) : 0; const totalInterest= principal && totalPayable ? Math.round(totalPayable - principal) : 0; const paidMonths = e.startMonth ? (()=>{ const s = new Date(e.startMonth+'-01'), n = new Date(); return Math.max(0, Math.round((n - s)/(1000*60*60*24*30.44))); })() : 0; const moLeft = e.endMonth ? (()=>{ const n=new Date(), en=new Date(e.endMonth+'-01'); return Math.max(0, Math.round((en-n)/(1000*60*60*24*30.44))); })() : Math.max(0, tenure - paidMonths); const pctDone = tenure > 0 ? Math.min(100, Math.round(paidMonths/tenure*100)) : 0; const emiRatio = pb.income.newtonSalary > 0 ? ((e.amount||0)/pb.income.newtonSalary*100).toFixed(1) : null; const isSaving = isSavingsKind(e.kind); const savedSoFar = isSaving ? emiSavedSoFar(e) : 0; const savePct = isSaving && e.target>0 ? Math.min(100, Math.round(savedSoFar/e.target*100)) : 0; const totGrams = emiTotalGrams(e); const showGrams = isSaving && emiTracksGrams(e); const CAT_ICON = { personal:'👤', jewel:'💎', gold:'🥇', device:'📱', vehicle:'🚗', credit:'💳', home:'🏠', business:'🏢', other:'📦' }; document.getElementById('emiDetailModal')?.remove(); const modal = document.createElement('div'); modal.id = 'emiDetailModal'; modal.style.cssText = [ 'position:fixed;inset:0;z-index:9999', 'background:rgba(0,0,0,0.35)', 'backdrop-filter:blur(8px)', '-webkit-backdrop-filter:blur(8px)', 'display:flex;align-items:center;justify-content:center;padding:20px' ].join(';'); modal.innerHTML = `
${escHtml(e.label)}
${escHtml(e.lender||'—')} ${e.kind==='emergency' ? `🚨 Emergency Fund` : isSaving ? `🐷 Saving Scheme` : ''} ${e.debitBank ? `${e.debitBank}` : ''} ${e.category ? `${CAT_ICON[e.category]||'📦'} ${e.category}` : ''} ${e.status||'active'}
${(isSaving ? [ {label: e.kind==='emergency'?'Monthly Contribution':'Monthly Saving', value: fmtINR(e.amount||0), sub: `Saves ${e.dueDay||'—'}th`, color:'var(--accent)'}, {label:'Saved So Far', value: savedSoFar ? fmtINR(savedSoFar) : '—', sub: emiPaidMonths(e)?emiPaidMonths(e)+' month(s) paid':'tick months below', color:'var(--accent)'}, showGrams ? {label:'Total Gold', value: totGrams? (Math.round(totGrams*1000)/1000)+' g' : '—', sub:'cumulative grams', color:'var(--amber)'} : {label:'Target / Maturity', value: e.target ? fmtINR(e.target) : 'Not set', sub: e.target ? savePct+'% reached' : 'Enter below', color:'var(--violet)'}, {label:'Months to Maturity', value: moLeft || '—', sub: e.endMonth ? 'Matures: '+fmtM(e.endMonth) : 'Set maturity month', color: moLeft < 6 ? 'var(--amber)' : 'var(--accent)'}, {label:'Progress', value: e.target ? savePct+'%' : '—', sub: e.target ? 'toward target' : 'set a target', color:'var(--sky)'}, {label: e.kind==='emergency'?'Contribution / Income':'Saving / Income', value: emiRatio ? emiRatio+'%' : '—', sub: 'of monthly salary', color:'var(--accent)'}, ] : [ {label:'EMI / Month', value: fmtINR(e.amount||0), sub: `Due ${e.dueDay||'—'}th`, color:'var(--rose)'}, {label:'Loan Principal', value: principal ? fmtINR(principal) : 'Not set', sub: e.amtReceived ? 'Received: '+fmtINR(e.amtReceived) : 'Enter below', color:'var(--violet)'}, {label:'Total Interest', value: totalInterest ? fmtINR(totalInterest) : '—', sub: totalPayable ? 'Total payable: '+fmtINR(totalPayable) : 'Set principal + tenure', color:'var(--amber)'}, {label:'Months Left', value: moLeft || '—', sub: e.endMonth ? 'Ends: '+fmtM(e.endMonth) : 'Set end month', color: moLeft < 6 ? 'var(--rose)' : moLeft < 12 ? 'var(--amber)' : 'var(--accent)'}, {label:'Amount Paid', value: paidMonths > 0 ? fmtINR(e.amount * Math.min(paidMonths,tenure||999)) : '—', sub: paidMonths+' months elapsed', color:'var(--sky)'}, {label:'EMI / Income', value: emiRatio ? emiRatio+'%' : '—', sub: 'of monthly salary', color: emiRatio > 30 ? 'var(--rose)' : emiRatio > 15 ? 'var(--amber)' : 'var(--accent)'}, ]).map(k=>`
${k.label}
${k.value}
${k.sub}
`).join('')}
${tenure > 0 ? `
${isSaving?'Saving Progress':'Repayment Progress'} ${Math.min(paidMonths,tenure)}/${tenure} months  ·  ${pctDone}%
Started: ${fmtM(e.startMonth)} Ends: ${fmtM(e.endMonth)}
` : ''}
✎ Edit Details
Type
Saving schemes & emergency funds are contributions that build savings — not debt — so they're tracked separately and don't count toward your EMI/debt load.
${[ {label:'Label', id:'label', type:'text', val: e.label||'', span:2}, {label:'Lender', lblId:'lblLender', id:'lender', type:'text', val: e.lender||''}, {label:'EMI / Month (₹)', lblId:'lblAmount', id:'amount', type:'number', val: e.amount||''}, {label:'Loan Principal (₹)', id:'principal', type:'number', val: e.principal||'', only:'loan'}, {label:'Amount Received (₹)', id:'amtReceived', type:'number', val: e.amtReceived||'', only:'loan'}, {label:'Interest Rate (%/yr)',id:'interestRate', type:'number', val: e.interestRate||'', step:'0.01', only:'loan'}, {label:'Interest Amount (Total)',id:'interestAmt', type:'number', val: e.interestAmt||(e.principal&&e.tenure?Math.max(0,Math.round(e.amount*(e.tenure||0)-e.principal)):0)||'', only:'loan'}, {label:'Target / Maturity (₹)', id:'target', type:'number', val: e.target||'', only:'saving'}, {label:'Tenure (months)', id:'tenure', type:'number', val: e.tenure||'', oninput:'emiRecalcEnd()'}, {label:'Due Day (1–31)', id:'dueDay', type:'number', val: e.dueDay||1, min:1, max:31}, {label:'Start Month', id:'startMonth', type:'__month__', val: e.startMonth||''}, {label:'End / Maturity Month', id:'endMonth', type:'__month__', val: e.endMonth||''}, ].map(f=> f.type==='__month__' ? `
${f.label}
${monthPickerHTML('emiDet_'+f.id, f.val, 'const hv=document.getElementById("emiDet_'+f.id+'_val");if(hv)hv.value=y+"-"+m;'+(f.id==='startMonth'?'emiRecalcEnd();':''))}
` : `
${f.label}
` ).join('')}
Interest Conditions / Notes
Debit Bank
Account Holder
${[...new Set([...(pb.acctHolders||[]), ...pb.emis.map(x=>x.accountHolder).filter(Boolean), pb.income?.newtonName, pb.income?.smithaName].filter(Boolean))].map(h=>``).join('')}
Category
${[...new Set([...['personal','jewel','gold','device','vehicle','credit','home','business','other'], ...(pb.emiCats||[]), ...pb.emis.map(x=>x.category).filter(Boolean)])].map(c=>``).join('')}
Status
${isSaving ? `
${emiLedgerHTML(emiIdx)}
` : ''}
`; modal.addEventListener('click', ev => { if (ev.target === modal) modal.remove(); }); document.body.appendChild(modal); } const isSavingsKind = k => k === 'saving' || k === 'emergency'; function emiKindChange(kind) { const hid = document.getElementById('emiDet_kind'); if (hid) hid.value = kind; const set = (id, on, col) => { const b=document.getElementById(id); if (b){ b.style.background = on?col:'transparent'; b.style.color = on?'#fff':'var(--text-muted)'; } }; set('emiKindLoan', kind==='loan', 'var(--rose)'); set('emiKindSaving', kind==='saving', 'var(--accent)'); set('emiKindEmergency', kind==='emergency', 'var(--amber)'); const sav = isSavingsKind(kind); document.querySelectorAll('.emi-only-loan').forEach(el => el.style.display = sav?'none':''); document.querySelectorAll('.emi-only-saving').forEach(el => el.style.display = sav?'':'none'); const la = document.getElementById('lblAmount'); if (la) la.textContent = sav?'Monthly Saving (₹)':'EMI / Month (₹)'; const ll = document.getElementById('lblLender'); if (ll) ll.textContent = sav?'Institution / Scheme':'Lender'; } // ---- Savings ledger helpers (back-entry of monthly contributions + gold grams) ---- function emiTracksGrams(e) { return e.tracksGrams !== undefined ? !!e.tracksGrams : (e.category === 'gold'); } function emiMonthList(e) { if (!e.startMonth) return []; const out = []; let d = new Date(e.startMonth + '-01'); let end = e.endMonth ? new Date(e.endMonth + '-01') : null; const now = new Date(); now.setDate(1); if (!end || end < now) end = now; // always extend at least to current month let guard = 0; while (d <= end && guard < 360) { out.push(d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')); d.setMonth(d.getMonth()+1); guard++; } return out; } function emiLog(e, ym) { e.monthLog = e.monthLog || {}; return e.monthLog[ym] || { paid:false, amount:0, grams:0 }; } function emiSavedSoFar(e) { if (e.monthLog) { const paid = Object.values(e.monthLog).filter(m => m.paid); if (paid.length) return paid.reduce((s,m) => s + (Number(m.amount)||e.amount||0), 0); } if (!e.startMonth) return e.amount || 0; const m = Math.max(1, Math.round((new Date()-new Date(e.startMonth+'-01'))/(1000*60*60*24*30.44))+1); return (e.amount||0) * m; } function emiTotalGrams(e) { return e.monthLog ? Object.values(e.monthLog).reduce((s,m)=>s+(Number(m.grams)||0),0) : 0; } function emiPaidMonths(e) { return e.monthLog ? Object.values(e.monthLog).filter(m=>m.paid).length : 0; } function emiSetMonthField(idx, ym, field, value) { const pb = pbData(); const e = pb.emis[idx]; if (!e) return; e.monthLog = e.monthLog || {}; const cur = e.monthLog[ym] || { paid:false, amount:0, grams:0 }; if (field === 'paid') { cur.paid = value; if (value && !cur.amount) cur.amount = e.amount||0; } else if (field === 'amount') cur.amount = Math.round(Number(value)||0); else if (field === 'grams') cur.grams = Number(value)||0; e.monthLog[ym] = cur; pbSave(); const led = document.getElementById('emiLedger'); if (led) led.innerHTML = emiLedgerHTML(idx); } function emiToggleGrams(idx, on) { const pb = pbData(); const e = pb.emis[idx]; if (!e) return; e.tracksGrams = on; pbSave(); const led = document.getElementById('emiLedger'); if (led) led.innerHTML = emiLedgerHTML(idx); } function emiLedgerHTML(idx) { const pb = pbData(); const e = pb.emis[idx]; if (!e) return ''; const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; const fmtM = m => { if(!m) return '—'; const [y,mn]=m.split('-'); return months[parseInt(mn)-1]+' '+y; }; const list = emiMonthList(e); if (!list.length) return `
Set a Start Month above (then Save) to open the month-by-month contribution ledger.
`; const grams = emiTracksGrams(e); const totSaved = list.reduce((s,ym)=>{ const m=emiLog(e,ym); return s + (m.paid?(Number(m.amount)||e.amount||0):0); }, 0); const totGrams = list.reduce((s,ym)=>s+(Number(emiLog(e,ym).grams)||0),0); const paidN = list.filter(ym=>emiLog(e,ym).paid).length; const rows = list.map(ym => { const m = emiLog(e, ym); const isFuture = ym > dayKey(new Date()).slice(0,7); return ` ${fmtM(ym)} ${grams?``:''} `; }).join(''); return `
📒 Contribution Ledger
Paid Months
${paidN}/${list.length}
Total Saved
${fmtINR(totSaved)}
${grams?`
Total Gold
${(Math.round(totGrams*1000)/1000)} g
`:''}
${grams?'':''}${rows}
MonthPaidAmountGrams
Tick past months to back-enter contributions; leave future months unticked. ${grams?'Enter grams credited each month — the cumulative total updates above.':''}
`; } // Live-fill End/Maturity month from Start month + Tenure (loans & saving schemes) function emiRecalcEnd() { const tn = Number(document.getElementById('emiDet_tenure')?.value) || 0; const sm = document.getElementById('mpM_emiDet_startMonth')?.value; const sy = document.getElementById('mpY_emiDet_startMonth')?.value; if (!tn || !sm || !sy) return; const d = new Date(Number(sy), Number(sm) - 1, 1); d.setMonth(d.getMonth() + tn); const em = String(d.getMonth() + 1).padStart(2, '0'); const ey = String(d.getFullYear()); const mEl = document.getElementById('mpM_emiDet_endMonth'); if (mEl) mEl.value = em; const yEl = document.getElementById('mpY_emiDet_endMonth'); if (yEl) yEl.value = ey; const hv = document.getElementById('emiDet_endMonth_val'); if (hv) hv.value = ey + '-' + em; } function saveEMIDetail(idx) { const pb = pbData(); const e = pb.emis[idx]; if (!e) return; const g = id => document.getElementById('emiDet_' + id); e.kind = g('kind')?.value || e.kind || 'loan'; if (!isSavingsKind(e.kind)) e.tracksGrams = false; e.target = Number(g('target')?.value) || 0; e.label = g('label')?.value.trim() || e.label; e.lender = g('lender')?.value.trim() || e.lender; e.principal = Number(g('principal')?.value) || e.principal || 0; e.amtReceived = Number(g('amtReceived')?.value) || 0; e.interestRate = Number(g('interestRate')?.value) || 0; e.interestAmt = Number(g('interestAmt')?.value) || 0; e.amount = Number(g('amount')?.value) || e.amount; e.dueDay = Number(g('dueDay')?.value) || e.dueDay; e.tenure = Number(g('tenure')?.value) || 0; e.startMonth = monthPickerVal('emiDet_startMonth') || g('startMonth')?.value || ''; e.endMonth = monthPickerVal('emiDet_endMonth') || g('endMonth')?.value || ''; e.interestNote = g('interestNote')?.value || ''; e.debitBank = g('debitBank')?.value || ''; e.accountHolder= g('accountHolder')?.value.trim() || ''; if (e.accountHolder) { pb.acctHolders = Array.isArray(pb.acctHolders) ? pb.acctHolders : []; if (!pb.acctHolders.includes(e.accountHolder)) pb.acctHolders.push(e.accountHolder); } e.category = (g('category')?.value || '').trim() || e.category; { const presets=['personal','jewel','gold','device','vehicle','credit','home','business','other']; if (e.category && !presets.includes(e.category)) { pb.emiCats = Array.isArray(pb.emiCats)?pb.emiCats:[]; if (!pb.emiCats.includes(e.category)) pb.emiCats.push(e.category); } } e.status = g('status')?.value || e.status; // Auto-calc tenure/endMonth if (e.startMonth && e.tenure && !e.endMonth) { const d = new Date(e.startMonth + '-01'); d.setMonth(d.getMonth() + e.tenure); e.endMonth = d.getFullYear() + '-' + String(d.getMonth()+1).padStart(2,'0'); } if (e.startMonth && e.endMonth && !e.tenure) { const s = new Date(e.startMonth+'-01'), en = new Date(e.endMonth+'-01'); e.tenure = Math.round((en - s) / (1000*60*60*24*30.44)); } pbSave(); document.getElementById('emiDetailModal')?.remove(); renderPersonalEMI(); } function pbAddCustomInv() { const label = prompt('Investment name (e.g. ELSS, RD, Stocks):'); if (!label || !label.trim()) return; const pb = pbData(); pb.customInv = pb.customInv || []; pb.hiddenInv = pb.hiddenInv || []; const key = 'custom_' + label.trim().toLowerCase().replace(/[^a-z0-9]/g,'_').slice(0,20) + '_' + Date.now().toString(36); pb.customInv.push({ key, label:label.trim(), icon:"\u25c8", hint:"Custom investment" }); pbSave(); renderPersonalNWS(); } function pbRemoveInv(key) { const pb = pbData(); const defaults = ['epf','ppf','fd','mf','nps','gold_inv','lic','savings_ac','emergency','other_inv']; const isDefault = defaults.includes(key); const msg = (isDefault ? key + ' is built-in. It will be hidden but data is preserved.\n\n' : '') + 'Remove this card?'; if (!confirm(msg)) return; if (isDefault) { pb.hiddenInv = pb.hiddenInv || []; if (!pb.hiddenInv.includes(key)) pb.hiddenInv.push(key); } else { pb.customInv = (pb.customInv||[]).filter(x => x.key !== key); } pbSave(); renderPersonalNWS(); } function renderPersonalNWS() { const el = document.getElementById('personalNWSContent'); if (!el) return; const pb = pbData(); const ym = window._pbMonth || (()=>{ const n=new Date(); return n.getFullYear()+'-'+String(n.getMonth()+1).padStart(2,'0'); })(); const md = pbMonthData(ym); const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; const fmtMonth = m => { const [y,mn]=m.split('-'); return months[parseInt(mn)-1]+' '+y; }; const income = md.income || pb.income || {}; const totalIncome = (income.newtonSalary||0) + (income.smithaSalary||0) + (income.additionalIncome||0) || pb.income.newtonSalary; const expenses = md.expenses || pb.expenses; el.innerHTML = `
📈 50 / 25 / 15 / 10 Rule — ${fmtMonth(ym)}
Needs 50%  ·  Wants 25%  ·  Savings 15%  ·  Tithe 10%  ·  Based on ₹${Math.round(totalIncome/1000)}k income
${(()=>{ // Category mapping to NWS buckets const NWS = { needs: { label:'Needs', color:'var(--sky)', bg:'rgba(10,132,255,0.07)', icon:'🏠', target:50, cats: ['housing','utilities','health','school','mobile'], extraEMI: false // EMIs moved to debt bucket }, wants: { label:'Wants', color:'var(--violet)', bg:'rgba(88,86,214,0.07)', icon:'🛍', target:25, cats: ['food','transport','shopping','entertainment','personal','misc'], }, savings: { label:'Savings & Investments', color:'var(--accent)', bg:'rgba(48,209,88,0.07)', icon:'💰', target:15, cats: ['savings'], }, tithe: { label:'Tithe / Giving', color:'var(--amber)', bg:'rgba(255,159,10,0.07)', icon:'🙏', target:10, cats: ['tithe','charity','donation'], extraEMI: false } }; const totalEMIAmt = pb.emis.filter(e=>e.status==='active').reduce((s,e)=>s+(e.amount||0),0); // Get actual from bank txns for each category const allTxnsCat = Object.values(md.bankTxns||{}).flat().filter(t=>t.debit>0); const catActuals = {}; allTxnsCat.forEach(t => { const c=t.category||'misc'; catActuals[c]=(catActuals[c]||0)+t.debit; }); const buckets = Object.entries(NWS).map(([bkey, B]) => { const target = totalIncome > 0 ? Math.round(totalIncome * B.target / 100) : 0; // Sum manual expense actuals let actual = B.cats.reduce((s,c) => s + (md.expenses?.[c]?.actual || catActuals[c] || 0), 0); // Add EMIs to needs if (B.extraEMI) actual += totalEMIAmt; const pct = target > 0 ? Math.min(actual/target*100, 150) : 0; const over = actual > target; return { ...B, bkey, target, actual, pct, over }; }); const nwsSummary = buckets.map(B => `
${B.icon}
${B.label}
Target: ${B.target}% = ${fmtINR(B.target)}
${fmtINR(B.actual)}
${Math.round(totalIncome>0?B.actual/totalIncome*100:0)}% of income
${Math.round(B.pct)}% used ${B.over?'Over by '+fmtINR(B.actual-B.target):'Saves '+fmtINR(B.target-B.actual)}
`).join(''); const nwsDetail = ` ${buckets.flatMap(B => { const rows = []; // Bucket header row rows.push(``); // EMI row for needs if (B.extraEMI) { rows.push(``); } // Expense rows B.cats.forEach(c => { const exp = md.expenses?.[c] || pb.expenses?.[c]; if (!exp) return; const bAmt = catActuals[c] || 0; const actual = exp.actual || bAmt || 0; const budget = exp.budget || 0; const diff = budget - actual; const over = budget>0 && actual>budget; const pct = budget>0 ? Math.min(actual/budget*100,150):0; rows.push(``); }); // Bucket total rows.push(``); return rows; }).join('')}
BucketCategoryBudgetActualBarDiff
${B.icon} ${B.label}  ·  Target: ${B.target}% (${fmtINR(B.target)})
💳 EMI Payments (${pb.emis.filter(e=>e.status==='active').length} loans) ${fmtINR(totalEMIAmt)} ${fmtINR(totalEMIAmt)} Fixed
${exp.label||c} ${budget>0?`
`:''}
${budget>0?(over?'-':'+')+ fmtINR(Math.abs(diff)):'—'}
Total ${B.label} ${fmtINR(B.target)} ${fmtINR(B.actual)} ${B.over?'Over '+fmtINR(B.actual-B.target):'Under '+fmtINR(B.target-B.actual)}
`; return `
${nwsSummary}
${window._nwsView==='detail' ? nwsDetail : ''}`; })()}
▶ Allocation Breakdown — ${fmtMonth(ym)}
Budget · Bank · EMIs interlinked
${(()=>{ const totalEMIAmt = pb.emis.filter(e=>e.status==='active').reduce((s,e)=>s+(e.amount||0),0); const allTxns2 = Object.values(md.bankTxns||{}).flat().filter(t=>t.debit>0); const catActuals2 = {}; allTxns2.forEach(t=>{ const c=t.category||'misc'; catActuals2[c]=(catActuals2[c]||0)+t.debit; }); // Build per-EMI paid status const emiRows = pb.emis.filter(e=>e.status==='active'&&e.amount>0).sort((a,b)=>a.dueDay-b.dueDay).map(e=>{ const me = md.emis.find(x=>x.id===e.id); const isPaid2 = me?.paid||false; const actualAmt = me?.actualAmt||e.amount; return {...e, isPaid: isPaid2, actualAmt}; }); const ALLOC = [ { key:'needs', label:'Needs', icon:'🏠', color:'var(--sky)', bg:'rgba(10,132,255,0.07)', target:50, cats:[ {cat:'housing', label:'Housing / Rent'}, {cat:'utilities', label:'Utilities (EB, Water)'}, {cat:'health', label:'Health & Medical'}, {cat:'school', label:'Education'}, {cat:'mobile', label:'Mobile & Internet'}, ], showEMI: false, desc:'Essential living costs — housing, utilities, health, education' }, { key:'wants', label:'Wants', icon:'🛍', color:'var(--violet)', bg:'rgba(88,86,214,0.07)', target:25, cats:[ {cat:'food', label:'Food & Groceries'}, {cat:'transport', label:'Fuel & Transport'}, {cat:'shopping', label:'Shopping'}, {cat:'entertainment', label:'Entertainment'}, {cat:'personal', label:'Personal Care'}, {cat:'misc', label:'Miscellaneous'}, ], showEMI: false, desc:'Lifestyle spending — food, transport, entertainment' }, { key:'savings', label:'Savings & Investments', icon:'💰', color:'var(--accent)', bg:'rgba(48,209,88,0.07)', target:15, cats:[], showEMI: false, showInv: true, desc:'EPF, PPF, MF, FD, Gold, Emergency Fund' }, { key:'tithe', label:'Tithe / Giving', icon:'🙏', color:'var(--amber)', bg:'rgba(255,159,10,0.07)', target:10, cats:[ {cat:'tithe', label:'Tithe'}, {cat:'charity', label:'Charity / Donations'}, {cat:'donation', label:'Donation'}, ], showEMI: false, desc:'10% for giving, charity, church, community' }, ]; // EMI section separately const targetEMI = Math.round(totalIncome * 0.10); // guideline: EMI within 40% (shown separately) const emiPaid = emiRows.filter(e=>e.isPaid).reduce((s,e)=>s+(e.actualAmt||0),0); const emiUnpaid = totalEMIAmt - emiRows.filter(e=>e.isPaid).reduce((s,e)=>s+e.amount,0); const invData = md.investments||{}; const totalInvActual = Object.values(invData).reduce((s,v)=>s+(v.actual||0),0); const totalInvTarget = Object.values(invData).reduce((s,v)=>s+(v.budget||0),0); return ALLOC.map(A=>{ const targetAmt = Math.round(totalIncome * A.target / 100); let catTotal = 0; let catBudget = 0; A.cats.forEach(({cat})=>{ catTotal += catActuals2[cat]||(md.expenses?.[cat]?.actual||0); catBudget += md.expenses?.[cat]?.budget||pb.expenses?.[cat]?.budget||0; }); const actual = A.showInv ? totalInvActual : catTotal; const budget = A.showInv ? totalInvTarget : catBudget; const pct = targetAmt > 0 ? Math.min(actual/targetAmt*100,150) : 0; const over = actual > targetAmt; const gap = targetAmt - actual; return `
${A.icon}
${A.label} (${A.target}% target)
${A.desc}
${fmtINR(actual)}
Target: ${fmtINR(targetAmt)}
${Math.round(pct)}% used ${over?'Over by '+fmtINR(actual-targetAmt):'Saves '+fmtINR(gap)}
${A.cats.length > 0 ? `
${A.cats.map(({cat,label})=>{ const bAmt = catActuals2[cat]||0; const mAmt = md.expenses?.[cat]?.actual||0; const bgt = md.expenses?.[cat]?.budget||pb.expenses?.[cat]?.budget||0; const tot = bAmt||mAmt; const src = bAmt>0?'Bank':'Budget'; return `
${label}
${bgt>0?'Budget: '+fmtINR(bgt):''} ${tot>0?'('+src+')':''}
${tot>0?fmtINR(tot):'—'}
${bgt>0&&tot>bgt?`
Over ${fmtINR(tot-bgt)}
`:''}
`; }).join('')}
` : ''} ${A.showInv ? `
${[['epf','EPF/PF'],['ppf','PPF'],['fd','Fixed Deposit'],['mf','Mutual Funds'],['nps','NPS'],['gold_inv','Gold'],['lic','Insurance'],['savings_ac','Savings A/c'],['emergency','Emergency Fund'],['other_inv','Other'],...(pb.customInv||[]).map(x=>[x.key,x.label])].filter(([k])=>!(pb.hiddenInv||[]).includes(k)&&((invData[k]?.actual||0)+(invData[k]?.budget||0)>0)).map(([k,lbl])=>{ const v = invData[k]||{}; const over2 = v.budget>0&&v.actual>v.budget; return `
${lbl}
Target: ${fmtINR(v.budget||0)}
${v.actual>0?fmtINR(v.actual):'—'}
${over2?`
Over
`:''}
`; }).join('')||'
Enter amounts in Savings cards below
'}
` : ''}
`; }).join('') + // EMI section `
🏦
EMI Obligations (Fixed monthly)
Loan repayments deducted from income — ${emiRows.filter(e=>e.isPaid).length}/${emiRows.length} paid this month
${fmtINR(totalEMIAmt)}
${totalIncome>0?(totalEMIAmt/totalIncome*100).toFixed(1)+'% of income':''}
${fmtINR(emiPaid)} paid via bank ${emiUnpaid>0?fmtINR(emiUnpaid)+' pending':'\u2713 All cleared'}
${emiRows.map(e=>`
${escHtml(e.label)}
${escHtml(e.lender)}  ·  Due ${e.dueDay}th${e.debitBank?'  ·  '+e.debitBank:''}
${fmtINR(e.amount)}
${e.isPaid?'\u2713 Paid':'Pending'}
`).join('')}
`; })()}
💰 Savings & Investments
${totalIncome>0?'Target: '+fmtINR(Math.round(totalIncome*0.2))+' (20% rule)':''}
${(()=>{ // Default instruments — always shown unless hidden const DEFAULT_INV = [ {key:'epf', label:'EPF / PF', icon:'🏦', hint:'Employee Provident Fund'}, {key:'ppf', label:'PPF', icon:'📋', hint:'Public Provident Fund'}, {key:'fd', label:'Fixed Deposit', icon:'🔒', hint:'Bank FDs'}, {key:'mf', label:'Mutual Funds / SIP',icon:'📈', hint:'SIP / Lumpsum'}, {key:'nps', label:'NPS', icon:'🏛', hint:'National Pension Scheme'}, {key:'gold_inv', label:'Gold Investment', icon:'🥇', hint:'Gold coins / ETF (non-pledged)'}, {key:'lic', label:'Insurance / LIC', icon:'🛡', hint:'Life insurance premium'}, {key:'savings_ac',label:'Savings Account', icon:'💳', hint:'Bank savings balance grown'}, {key:'emergency', label:'Emergency Fund', icon:'🆘', hint:'3-6 months expense reserve'}, {key:'other_inv', label:'Other Investments', icon:'💼', hint:'Stocks, crypto, real estate etc.'}, ]; pb.hiddenInv = pb.hiddenInv || []; pb.customInv = pb.customInv || []; const defaultKeys = DEFAULT_INV.map(x=>x.key); const allInv = [...DEFAULT_INV, ...pb.customInv.filter(x=>!defaultKeys.includes(x.key))]; const visible = allInv.filter(x=>!pb.hiddenInv.includes(x.key)); const md_inv = md.investments || {}; return visible.map(inv => { const val = md_inv[inv.key] || { budget:0, actual:0 }; const over = val.budget>0 && val.actual>val.budget; const isDefault = defaultKeys.includes(inv.key); return `
${inv.icon}
${escHtml(inv.label)}
${escHtml(inv.hint||'')}
Target/mo
Actual
${val.budget>0?`
`:''} ${val.actual>0?`
${fmtINR(val.actual)} invested
`:''}
`; }).join(''); })()}
Add Instrument
Click to add a custom savings or investment category
${(()=>{ const md_inv = md.investments || {}; pb.hiddenInv = pb.hiddenInv || []; pb.customInv = pb.customInv || []; const allKeys = ['epf','ppf','fd','mf','nps','gold_inv','lic','savings_ac','emergency','other_inv',...pb.customInv.map(x=>x.key)]; const visKeys = allKeys.filter(k=>!pb.hiddenInv.includes(k)); const totalInvTarget = visKeys.reduce((s,k)=>s+(md_inv[k]?.budget||0),0); const totalInvActual = visKeys.reduce((s,k)=>s+(md_inv[k]?.actual||0),0); const rule20 = Math.round(totalIncome*0.20); const gap = rule20 - totalInvActual; if (!totalInvActual && !totalInvTarget) return ''; return `
Invested This Month
${fmtINR(totalInvActual)}
20% Target
${fmtINR(rule20)}
${fmtINR(totalInvActual)} invested ${gap>0?'Gap: '+fmtINR(gap):'On target!'}
`; })()}
`; } function renderPersonalEMI() { const el = document.getElementById('personalEMIContent'); if (!el) return; const pb = pbData(); const now = new Date(); const today = now.getDate(); const ym = window._pbMonth || (now.getFullYear()+'-'+String(now.getMonth()+1).padStart(2,'0')); const md = pbMonthData(ym); const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; const fmtM = m => { if(!m) return '—'; const [y,mn]=m.split('-'); return months[parseInt(mn)-1]+' '+y; }; const CAT_ICON = { personal:'👤', jewel:'💎', gold:'🥇', device:'📱', vehicle:'🚗', credit:'💳', home:'🏠', business:'🏢', other:'📦' }; const CAT_COLOR = { personal:'var(--sky)', jewel:'var(--amber)', device:'var(--violet)', vehicle:'var(--sky)', credit:'var(--rose)', gold:'var(--amber)', home:'var(--sky)', business:'var(--violet)', other:'var(--text-muted)' }; window._emiView = window._emiView || 'card'; // card | list const totalEMI = pb.emis.filter(e=>e.status==='active' && !isSavingsKind(e.kind)).reduce((s,e)=>s+(e.amount||0),0); const totalSavings = pb.emis.filter(e=>e.status==='active' && isSavingsKind(e.kind)).reduce((s,e)=>s+(e.amount||0),0); const totalIncome = pb.income.newtonSalary + pb.income.smithaSalary + pb.income.additionalIncome; const _cur = (()=>{ const [y,m]=ym.split('-'); return parseInt(y)*12+parseInt(m); })(); const activeEMIs = pb.emis.filter(e=>{ if (e.status!=='active' || !e.amount) return false; const sm = e.startMonth?(()=>{const p=e.startMonth.split('-');return parseInt(p[0])*12+parseInt(p[1]);})():0; const em = e.endMonth ?(()=>{const p=e.endMonth.split('-'); return parseInt(p[0])*12+parseInt(p[1]);})():99999; return (!sm||_cur>=sm) && _cur<=em; }).sort((a,b)=>a.dueDay-b.dueDay); const paidCount = md.emis.filter(e=>e.paid).length; const paidAmt = md.emis.filter(e=>e.paid).reduce((s,me)=>{ const emi=pb.emis.find(x=>x.id===me.id); return s+(me.actualAmt||emi?.amount||0); },0); const unpaidAmt = totalEMI - md.emis.filter(e=>e.paid).reduce((s,me)=>{ const emi=pb.emis.find(x=>x.id===me.id); return s+(emi?.amount||0); },0); const [calY,calMn] = ym.split('-').map(Number); const daysInMonth = new Date(calY, calMn, 0).getDate(); const firstWeekday = new Date(calY, calMn-1, 1).getDay(); const dayEMIMap = {}; activeEMIs.forEach(emi => { const d=emi.dueDay; if(!dayEMIMap[d])dayEMIMap[d]=[]; dayEMIMap[d].push(emi); }); const emiRatioPct = totalIncome>0?(totalEMI/totalIncome*100).toFixed(0)+'%':'--'; el.innerHTML = `
EMI Tracker
Personal loan installments  ·  ${fmtM(ym)}
${fmtINR(totalEMI)}
${paidCount}/${activeEMIs.length} paid  ·  ${totalIncome>0?(totalEMI/totalIncome*100).toFixed(0)+'% income':''}
Total Payable
${fmtINR(totalEMI)}
${activeEMIs.length} active EMIs
Paid This Month
${fmtINR(paidAmt)}
${paidCount} of ${activeEMIs.length} paid
Balance Due
${fmtINR(unpaidAmt)}
${activeEMIs.length-paidCount} EMIs remaining
EMI / Income
${emiRatioPct}
${totalIncome>0?'of income':''}
🐷 Monthly Savings
${fmtINR(totalSavings)}
${pb.emis.filter(e=>e.status==='active'&&isSavingsKind(e.kind)).length} scheme${pb.emis.filter(e=>e.status==='active'&&isSavingsKind(e.kind)).length===1?'':'s'} · not debt
Cleared This Month ${fmtINR(paidAmt)} / ${fmtINR(totalEMI)}
✓ ${fmtINR(paidAmt)} paid ${fmtINR(unpaidAmt)} pending
📅 ${fmtM(ym)} — EMI Calendar
${paidCount}/${activeEMIs.length} paid
${["Sun","Mon","Tue","Wed","Thu","Fri","Sat"].map(d=>`
${d}
`).join("")}
${Array.from({length:firstWeekday}).map(()=>`
`).join("")} ${Array.from({length:daysInMonth},(_,i)=>i+1).map(day=>{ const emis = dayEMIMap[day]||[]; const isToday = day===today && calY===new Date().getFullYear() && calMn===new Date().getMonth()+1; const hasEMI = emis.length>0; const allPaid = hasEMI && emis.every(emi=>md.emis.find(x=>x.id===emi.id)?.paid); const anyOverdue= hasEMI && days+(e.amount||0),0); return `
${day}${isToday?'
':''}
${emis.slice(0,2).map(emi=>{ const me2=md.emis.find(x=>x.id===emi.id); const p2=me2?.paid; return `
${p2?'✓':''} ${escHtml(emi.label.slice(0,9))}
`; }).join("")} ${emis.length>2?`
+${emis.length-2} more
`:""} ${hasEMI?`
${fmtINR(totalDayAmt)}
`:""}
`; }).join("")}
EMI Due Paid Overdue Today
${window._emiView === 'card' ? `
${activeEMIs.map(emi => { const i = pb.emis.indexOf(emi); const me = md.emis.find(x=>x.id===emi.id); const isPaid = me?.paid || false; // Check if EMI is active in current month const _ym2 = ym.split('-'); const _curYM = parseInt(_ym2[0])*12 + parseInt(_ym2[1]); const _startYM = emi.startMonth ? (()=>{ const p=emi.startMonth.split('-'); return parseInt(p[0])*12+parseInt(p[1]); })() : 0; const _endYM = emi.endMonth ? (()=>{ const p=emi.endMonth.split('-'); return parseInt(p[0])*12+parseInt(p[1]); })() : 99999; const _emiActive = (!_startYM || _curYM >= _startYM) && _curYM <= _endYM; const overdue = _emiActive && !isPaid && emi.dueDay < today; const dueToday = _emiActive && !isPaid && emi.dueDay === today; const soon = _emiActive && !isPaid && emi.dueDay > today && emi.dueDay <= today+5; const notStarted = _startYM > 0 && _curYM < _startYM; const completed = _endYM < _curYM; const isSaving = isSavingsKind(emi.kind); const daysOverdue = overdue ? today - emi.dueDay : 0; const status_color = isPaid?'var(--accent)':completed?'var(--text-muted)':notStarted?'var(--text-muted)':isSaving?((overdue||dueToday)?'var(--amber)':soon?'var(--sky)':'var(--accent)'):overdue?'var(--rose)':dueToday?'var(--amber)':soon?'var(--sky)':'var(--border)'; const statusLabel = isPaid?(isSaving?'✓ Saved':'✓ Paid'):completed?(isSaving?'Matured':'Completed'):notStarted?'Starts '+fmtM(emi.startMonth):isSaving?((overdue||dueToday)?'Contribution due':soon?'In '+(emi.dueDay-today)+'d':'Saves '+emi.dueDay+'th'):overdue?'Overdue '+daysOverdue+'d':dueToday?'Due Today':soon?'In '+(emi.dueDay-today)+'d':emi.dueDay+'th'; const savedSoFar = isSaving ? emiSavedSoFar(emi) : 0; const savePct = isSaving && emi.target>0 ? Math.min(100, Math.round(savedSoFar/emi.target*100)) : 0; const cardGrams = isSaving && emiTracksGrams(emi) ? emiTotalGrams(emi) : 0; const moLeft = emi.endMonth ? Math.max(0,Math.round((new Date(emi.endMonth+'-01')-new Date())/(1000*60*60*24*30.44))) : null; const pct = emi.tenure > 0 ? Math.min(100, Math.round( emi.startMonth ? Math.max(0,Math.round((new Date()-new Date(emi.startMonth+'-01'))/(1000*60*60*24*30.44)))/emi.tenure*100 : 0 )) : 0; const catColor = CAT_COLOR[emi.category]||'var(--text-muted)'; const totalInt = emi.principal && emi.tenure ? Math.max(0, Math.round(emi.amount*emi.tenure - emi.principal)) : 0; return `
${escHtml(emi.label)}
${escHtml(emi.lender)} ${emi.kind==='emergency'?`🚨 Emergency`:isSaving?`🐷 Saving`:''} ${emi.debitBank?`${emi.debitBank}`:''} ${emi.category?`${CAT_ICON[emi.category]||''}`:''}
${statusLabel}
${fmtINR(emi.amount)}
${isSaving?'saved / month':'/ month'}  ·  ${isSaving?'Saves':'Due'} ${emi.dueDay}th
${overdue && !isSaving ? `
${daysOverdue} day${daysOverdue!==1?"s":""} overdue
` : ""} ${overdue && isSaving ? `
🐷 Contribution pending
` : ""} ${dueToday ? `
⏱ ${isSaving?'Save today!':'Due today!'}
` : ""} ${soon&&!dueToday ? `
🕑 ${isSaving?'Save':'Due'} in ${emi.dueDay-today} days
` : ""} ${isSaving ? `
Saved so far
${fmtINR(savedSoFar)}
${cardGrams?`
Gold
${Math.round(cardGrams*1000)/1000} g
`:''} ${emi.target?`
Target
${fmtINR(emi.target)}
`:''} ${emi.target?`
Progress
${savePct}%
`:''}
${emi.target?`
`:''} ` : ''} ${!isSaving && (emi.principal || totalInt) ? `
${emi.principal?`
Principal
${fmtINR(emi.principal)}
`:''} ${totalInt?`
Interest
${fmtINR(totalInt)}
`:''} ${emi.tenure?`
Tenure
${emi.tenure}mo
`:''}
` : ''} ${emi.tenure > 0 ? `
${fmtM(emi.startMonth)} ${pct}% ${moLeft!==null?moLeft+'mo left':fmtM(emi.endMonth)}
` : moLeft!==null ? `
${moLeft > 0 ? moLeft+' months remaining' : '✓ Completed'}
` : ''} ${me?.actualAmt && Math.abs(me.actualAmt - emi.amount) > 5 ? `
✓ Bank: ${fmtINR(me.actualAmt)} on ${me.paidDate||''}
` : ''}
`; }).join('')}
` : `
✎ EMI Details — List View
${activeEMIs.length} active EMIs  ·  ${paidCount} paid this month
${pb.emis.map((e, i) => { const me2 = md.emis.find(x=>x.id===e.id); const isPaid2 = me2?.paid || false; return ``; }).join('')}
#EMI LabelLenderDebit Bank Amount/moDue Day StartEnd TenureCategoryStatusPaid?
${i+1} ${monthPickerHTML("emiS_"+i, e.startMonth||"", "pbData().emis["+i+"].startMonth=y+'-'+m;const t=pbData().emis["+i+"];if(t.startMonth&&t.tenure){const d=new Date(t.startMonth+'-01');d.setMonth(d.getMonth()+Number(t.tenure));t.endMonth=d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0');}pbSave();renderPersonalEMI();") } ${monthPickerHTML("emiE_"+i, e.endMonth||"", "pbData().emis["+i+"].endMonth=y+'-'+m;const t=pbData().emis["+i+"];if(t.startMonth&&t.endMonth){const s=new Date(t.startMonth+'-01');const en=new Date(y+'-'+m+'-01');t.tenure=Math.round((en-s)/(1000*60*60*24*30.44));}pbSave();renderPersonalEMI();") }
Total Active EMI ${fmtINR(totalEMI)}
`}
`; } // Spending period state window._spendPeriod = window._spendPeriod || 'month'; // month|quarter|half|year window._spendPeriodRef = window._spendPeriodRef || window._pbMonth || (() => { const n = new Date(); return n.getFullYear() + '-' + String(n.getMonth()+1).padStart(2,'0'); })(); function renderPersonalSpend() { const el = document.getElementById('personalSpendContent'); if (!el) return; const pb = pbData(); const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; const fmtMonth = m => { const [y,mn]=m.split('-'); return months[parseInt(mn)-1]+' '+y; }; const period = window._spendPeriod || 'month'; const refYm = window._spendPeriodRef || window._pbMonth || (() => { const n = new Date(); return n.getFullYear()+'-'+String(n.getMonth()+1).padStart(2,'0'); })(); // Build list of months covered by selected period function getMonthsForPeriod(ref, prd) { const [y,mn] = ref.split('-').map(Number); const allYms = []; if (prd === 'month') { allYms.push(ref); } else if (prd === 'quarter') { const qStart = Math.floor((mn-1)/3)*3 + 1; for (let m=qStart; m { const md2 = pb.months?.[m]; return md2 ? Object.values(md2.bankTxns||{}).flat() : []; }); const hasBankData = allTxns.length > 0; // Sum debits per category const bankCatTotals = {}; allTxns.filter(t => t.debit > 0).forEach(t => { const c = t.category || 'misc'; bankCatTotals[c] = (bankCatTotals[c]||0) + t.debit; }); const totalActual = Object.values(bankCatTotals).reduce((s,v)=>s+v,0); const totalCredits = allTxns.filter(t=>t.credit>0).reduce((s,t)=>s+t.credit,0); // EMI total for the period const totalEMI = pb.emis.filter(e=>e.status==='active').reduce((s,e)=>s+(e.amount||0),0) * coveredMonths.length; const totalIncome = pb.income.newtonSalary * coveredMonths.length; // Month options for ref selector const monthOpts = []; for (let y=2026; y<=2027; y++) for (let m=1; m<=12; m++) { const k = `${y}-${String(m).padStart(2,'0')}`; monthOpts.push(``); } // Period nav: prev/next function shiftPeriod(ref, prd, dir) { const [y,mn] = ref.split('-').map(Number); const shift = { month:1, quarter:3, half:6, year:12 }[prd] * dir; const d = new Date(y, mn-1+shift, 1); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}`; } const prevRef = shiftPeriod(refYm, period, -1); const nextRef = shiftPeriod(refYm, period, +1); el.innerHTML = `
Spending Tracker
${periodLabel}  ·  ${hasBankData ? `Auto-categorized from bank statements (${allTxns.filter(t=>t.debit>0).length} debits)` : 'No bank data loaded for this period'}
${fmtINR(totalActual)}
total spend
${['month','quarter','half','year'].map(p => ` `).join('')}
${coveredMonths.length > 1 ? `
${coveredMonths.map(m => { const mmd = pb.months?.[m]; const hasTxns = mmd && Object.values(mmd.bankTxns||{}).flat().length > 0; return `${fmtMonth(m)}${hasTxns?' ✓':''}`; }).join('')}
` : ''}
${!hasBankData ? `
No bank statements loaded for ${periodLabel}. Upload statements in My Budget for each month in this period.
` : ''}
Total Spend — ${periodLabel}
${fmtINR(totalActual)}
${allTxns.filter(t=>t.debit>0).length} debit txns
Total Credits
${fmtINR(totalCredits)}
${allTxns.filter(t=>t.credit>0).length} credit txns
EMI Obligations
${fmtINR(totalEMI)}
${coveredMonths.length} month${coveredMonths.length>1?'s':''} x ${pb.emis.filter(e=>e.status==='active').length} EMIs
Net (Income - Spend - EMI)
${fmtINR(Math.abs(totalIncome-totalActual-totalEMI))}
${totalIncome>totalActual+totalEMI?'▲ Surplus':'▼ Shortfall'}
${hasBankData ? `
${SPEND_CATEGORIES.filter(cat => (bankCatTotals[cat.key]||0) > 0).sort((a,b)=>(bankCatTotals[b.key]||0)-(bankCatTotals[a.key]||0)).map(cat => { const amt = bankCatTotals[cat.key] || 0; const pct = totalActual > 0 ? (amt/totalActual*100).toFixed(1) : 0; const txns = allTxns.filter(t=>t.debit>0&&(t.category||'misc')===cat.key); const color = cat.color; // Monthly breakdown for this category const monthBreak = coveredMonths.map(m => { const mTxns = (pb.months?.[m] ? Object.values(pb.months[m].bankTxns||{}).flat() : []) .filter(t=>t.debit>0&&(t.category||'misc')===cat.key); return { m, amt: mTxns.reduce((s,t)=>s+t.debit,0) }; }).filter(r=>r.amt>0); return `
${cat.icon} ${cat.label} ${txns.length} txn${txns.length!==1?'s':''}
${fmtINR(amt)}
${pct}% of spend
${coveredMonths.length > 1 && monthBreak.length > 0 ? `
${monthBreak.map(r=>`${fmtMonth(r.m)}: ${fmtINR(r.amt)}`).join('')}
` : ''}
${txns.slice(0,3).map(t=>`
${escHtml((t.desc||'').slice(0,35))} ${fmtINR(t.debit)}
`).join('')} ${txns.length > 3 ? `
+ ${txns.length-3} more
` : ''}
`; }).join('')}
${totalIncome > 0 ? `
Income Allocation — ${periodLabel}
${[...SPEND_CATEGORIES.filter(c=>(bankCatTotals[c.key]||0)>0).sort((a,b)=>(bankCatTotals[b.key]||0)-(bankCatTotals[a.key]||0)).map(c=>({label:c.label,val:bankCatTotals[c.key]||0,color:c.color,icon:c.icon})), {label:'EMI Payments',val:totalEMI,color:'var(--rose)',icon:'🏦'} ].filter(r=>r.val>0).map(r=>`
${r.icon} ${r.label}
${fmtINR(r.val)} ${(r.val/totalIncome*100).toFixed(0)}%
`).join('')}
` : ''}
✎ Reclassify Transactions
${allTxns.filter(t=>t.debit>0).length} debit transactions  ·  ${periodLabel}
🔍
${(()=>{ const rclSearch = (document.getElementById('rclSearch')?.value||''). toLowerCase(); const rclBank = document.getElementById('rclBank')?.value||''; const rclCat = document.getElementById('rclCat')?.value||''; const rclType = document.getElementById('rclType')?.value||'debit'; let count = 0; const rows = coveredMonths.flatMap(ym2 => { const mmd = pb.months?.[ym2]; if (!mmd) return []; return Object.entries(mmd.bankTxns||{}).flatMap(([bname,txns]) => (txns||[]).filter(t => { if (rclType==='debit' && !t.debit) return false; if (rclType==='credit' && !t.credit) return false; if (rclBank && bname !== rclBank) return false; if (rclCat && (t.category||'misc') !== rclCat) return false; if (rclSearch && !(t.desc||''). toLowerCase().includes(rclSearch)) return false; return true; }).map((t,ti) => { count++; const cc = txnCatColor(t.category||'misc'); return ``; }) ); }); setTimeout(()=>{const el=document.getElementById('rclCount');if(el)el.textContent=count+' shown';},0); return rows.join(''); })()}
MonthDateDescriptionAmountCategoryBank
${fmtMonth(ym2)} ${t.date} ${escHtml((t.desc||'').slice(0,55))} ${fmtINR(t.debit)} ${bname}
` : ''}`; } function renderPersonalNetWorth() { const el = document.getElementById('personalNetContent'); if (!el) return; const pb = pbData(); // ── Aggregate gold data from debt records ────────────────────────── const loans = (data.loans||[]); const goldLoans = loans.filter(l => l.jewelWeight && l.jewelWeight > 0 && ['jewel','gold'].includes(l.category||l.purpose||'')); const totalGoldGrams = goldLoans.reduce((s,l) => s + Number(l.jewelWeight||0), 0); const extraGrams = pb.extraGoldGrams || 0; const allGoldGrams = totalGoldGrams + extraGrams; const goldRate = pb.goldRatePerGram || 0; const goldValue = Math.round(allGoldGrams * goldRate); // Auto-sync gold asset value if rate is set const a1 = pb.assets.find(a => a.id === 'a1'); if (a1 && goldRate > 0 && allGoldGrams > 0 && a1.amount !== goldValue) { a1.amount = goldValue; a1.note = `${allGoldGrams.toFixed(2)}g x ₹${goldRate}/g`; } // Also auto-sync gold loan liability (a3 = Gold Loan Outstanding) const l3 = pb.liabilities.find(l => l.id === 'l3'); const goldLoanBal = goldLoans.reduce((s,l) => s + getLoanBalance(l), 0); if (l3 && goldLoanBal > 0) { l3.amount = Math.round(goldLoanBal); l3.note = `Auto-synced from ${goldLoans.length} jewel/gold loan(s)`; } const totalAssets = pb.assets.reduce((s,a) => s+(a.amount||0), 0); const totalLiab = pb.liabilities.reduce((s,l) => s+(l.amount||0), 0); const netWorth = totalAssets - totalLiab; el.innerHTML = `
Net Worth Tracker
Assets minus liabilities — your true financial position. Gold auto-valued from loan records.
Total Assets
${fmtINR(totalAssets)}
What you own
Total Liabilities
${fmtINR(totalLiab)}
What you owe
Net Worth
${fmtINR(Math.abs(netWorth))}
${netWorth>=0?'▲ Positive':'▼ Liabilities exceed assets'}
Asset / Debt Ratio
${totalLiab>0?(totalAssets/totalLiab).toFixed(2)+'x':'∞'}
Target: above 1.5x
🏍 Total Gold Held
${allGoldGrams.toFixed(2)}g
${goldLoans.length} loan(s)${extraGrams>0?' + '+extraGrams+'g manual':''}
🏍 Gold Market Value
${goldRate>0&&allGoldGrams>0?fmtINR(goldValue):'Set rate below'}
${goldRate>0?'₹'+goldRate.toLocaleString('en-IN')+'/g':'Rate not set'}
Physical Gold Valuation — Auto-linked from Jewel/Gold Loans
${goldLoans.length > 0 ? `
${goldLoans.map(l => { const gv = Math.round(Number(l.jewelWeight||0) * goldRate); const bal = getLoanBalance(l); const ltv = gv > 0 ? Math.round(bal/gv*100) : null; return ``; }).join('')}
LenderLoan Category Weight (g) Loan Balance Gold Value LTV
${escHtml(l.lender)} ${escHtml(l.category||l.purpose||'jewel')} ${Number(l.jewelWeight||0).toFixed(2)}g ${fmtINR(bal)} ${goldRate>0?fmtINR(gv):'Set rate'} ${ltv != null ? `${ltv}%` : '—'}
Total ${totalGoldGrams.toFixed(2)}g ${fmtINR(goldLoanBal)} ${goldRate>0?fmtINR(Math.round(totalGoldGrams*goldRate)):'—'} ${goldRate>0&&Math.round(totalGoldGrams*goldRate)>0?Math.round(goldLoanBal/Math.round(totalGoldGrams*goldRate)*100)+'%':'—'}
` : `
No jewel/gold loans with weight recorded yet. Add jewellery weight (grams) to loans in Funds → Borrow/Debt to auto-populate here.
`}
Total Gold Value
${goldRate>0&&allGoldGrams>0?fmtINR(goldValue):'—'}
${allGoldGrams.toFixed(2)}g total
${goldRate > 0 && allGoldGrams > 0 ? `
Equity in Gold: ${fmtINR(goldValue - goldLoanBal)}  (Gold value ${fmtINR(goldValue)} − Loan balance ${fmtINR(goldLoanBal)})  ·  Loan-to-Value: ${goldValue>0?Math.round(goldLoanBal/goldValue*100):0}% ${Math.round(goldLoanBal/goldValue*100) > 75 ? ' ⚠ High LTV — risk of top-up demand' : ''}
` : ''}
▲ Assets
${fmtINR(totalAssets)}
${pb.assets.map((a,i) => { const isGold = a.id === 'a1'; return `
${isGold?'🏍':''}${a.label} ${isGold&&goldRate>0&&allGoldGrams>0?'Auto':''}
${escHtml(a.note||'')}
0&&allGoldGrams>0?'title="Auto-calculated from gold loans. Change rate above to update."':''} style="width:110px;height:30px;text-align:right;font-size:12px;font-weight:700;color:${isGold?'var(--amber)':'var(--sky)'};">
`; }).join('')}
Total Assets${fmtINR(totalAssets)}
▼ Liabilities
${fmtINR(totalLiab)}
${pb.liabilities.map((l,i) => { const isGoldLoan = l.id === 'l3'; return `
${l.label} ${isGoldLoan&&goldLoanBal>0?'Auto':''}
${escHtml(l.note||'')}
`; }).join('')}
Total Liabilities${fmtINR(totalLiab)}
`; } // ===== BALANCE SHEET MODULE ===== function renderBalanceSheet() { const el = document.getElementById('balSheetContent'); if (!el) return; const projects = (data.projects||[]); const loans = (data.loans||[]); const vendors = (data.vendors||[]); const banks = (data.banks||[]); const pb = (data.personalBudget||{}); const pf = pfData(); const now = new Date(); const dateStr = now.toLocaleDateString('en-IN',{day:'2-digit',month:'short',year:'numeric'}); // ── ASSETS ───────────────────────────────────────────────────── // Current Assets const cashInHand = (pf.balances?.income||0) + (pf.balances?.operating||0); const bankBalances = banks.reduce((s,b) => s + (b.transactions||[]).filter(t=>t.type==='credit').reduce((x,t)=>x+t.amount,0) - (b.transactions||[]).filter(t=>t.type==='debit').reduce((x,t)=>x+t.amount,0), 0); const personalCash = pb.accountBalances ? Object.values(pb.accountBalances).reduce((s,v)=>s+(v||0),0)-(pb.accountBalances?.total||0) : 0; const receivables = projects.filter(p=>p.status!=='completed').reduce((s,p)=>s+Math.max((Number(p.estimate)||0)-(Number(p.collected)||0),0),0); const profitAcct = pf.balances?.profit||0; const taxAcct = pf.balances?.tax||0; const ownerPayAcct = pf.balances?.ownerPay||0; // Fixed Assets const goldVal = (pb.goldRatePerGram||0) * ((pb.extraGoldGrams||0) + (loans.filter(l=>l.jewelWeight).reduce((s,l)=>s+Number(l.jewelWeight||0),0))); const vehicleVal = (pb.assets||[]).find(a=>a.id==='a2')?.amount||0; const equipment = 0; // user-entered const property = (pb.assets||[]).find(a=>a.id==='a7')?.amount||0; // Work in Progress const wip = projects.filter(p=>p.status==='in-progress').reduce((s,p)=>s+(computeProjectForecast(p).spent||0),0); const totalCurrentAssets = cashInHand + personalCash + receivables + profitAcct + taxAcct + ownerPayAcct; const totalFixedAssets = goldVal + vehicleVal + property; const totalAssets = totalCurrentAssets + totalFixedAssets + wip; // ── LIABILITIES ──────────────────────────────────────────────── const activeLoans = loans.filter(l=>l.status!=='closed'); const shortTermDebt = activeLoans.filter(l=>!l.tenure||l.tenure<=12).reduce((s,l)=>s+getLoanBalance(l),0); const longTermDebt = activeLoans.filter(l=>l.tenure>12).reduce((s,l)=>s+getLoanBalance(l),0); const vendorPayables = vendors.reduce((s,v)=>s+(Number(v.outstanding)||0),0); const creditCardDebt = (pb.liabilities||[]).find(l=>l.id==='l7')?.amount||0; const emiMonthly = activeLoans.reduce((s,l)=>s+(l.emi||0),0); const totalCurrentLiab = shortTermDebt + vendorPayables + creditCardDebt; const totalLongLiab = longTermDebt; const totalLiabilities = totalCurrentLiab + totalLongLiab; // ── EQUITY ───────────────────────────────────────────────────── const equity = totalAssets - totalLiabilities; // ── RATIOS ───────────────────────────────────────────────────── const currentRatio = totalCurrentLiab > 0 ? (totalCurrentAssets/totalCurrentLiab).toFixed(2) : 'N/A'; const debtEquity = equity > 0 ? (totalLiabilities/equity).toFixed(2) : 'N/A'; const debtToAsset = totalAssets > 0 ? (totalLiabilities/totalAssets*100).toFixed(1) : '0'; const workingCapital = totalCurrentAssets - totalCurrentLiab; // Lender type breakdown for liabilities const lenderTypeBreak = {}; activeLoans.forEach(l => { const lt = l.lenderType||'bank'; lenderTypeBreak[lt] = (lenderTypeBreak[lt]||0) + getLoanBalance(l); }); const row = (label, amount, color='', bold=false, indent=false) => ` ${label} ${amount===''?'':fmtINR(amount)} `; const divider = () => ``; const header = (label, color) => `${label}`; const LTYPE = {bank:'Bank',private_finance:'Pvt Finance',private_banking:'Pvt Banking',nbfc:'NBFC',government:'Govt',chit:'Chit',friend:'Friend/Family',other:'Other'}; el.innerHTML = `
☰ Balance Sheet
Business financial position  ·  As at ${dateStr}  ·  Auto-calculated from all MYDAS data
Total Assets
${fmtINR(totalAssets)}
What the business owns
Total Liabilities
${fmtINR(totalLiabilities)}
What the business owes
Net Worth / Equity
${fmtINR(Math.abs(equity))}
${equity>=0?'Positive equity':'Negative equity'}
Working Capital
${fmtINR(Math.abs(workingCapital))}
${workingCapital>=0?'Current surplus':'Current deficit'}
Current Ratio
${currentRatio}x
Target: above 1.5x
Debt / Equity
${debtEquity}x
Target: below 2x
▲ Assets
${fmtINR(totalAssets)}
${header('Current Assets', 'var(--sky)')} ${row('Cash & Operating Accounts', cashInHand, '', false, true)} ${row('Personal Bank Accounts', personalCash, '', false, true)} ${row('Profit First Account', profitAcct, '', false, true)} ${row('Tax Reserve Account', taxAcct, '', false, true)} ${row('Owner Pay Account', ownerPayAcct, '', false, true)} ${row('Project Receivables (unbilled)', receivables, 'var(--sky)', false, true)} ${divider()} ${row('Total Current Assets', totalCurrentAssets, 'var(--sky)', true)} ${header('Work In Progress', 'var(--amber)')} ${row('Active Project Spend (WIP)', wip, '', false, true)} ${divider()} ${row('Total WIP', wip, 'var(--amber)', true)} ${header('Fixed Assets', 'var(--violet)')} ${row('Gold / Jewellery', goldVal, '', false, true)} ${row('Vehicle', vehicleVal, '', false, true)} ${row('Property', property, '', false, true)} ${divider()} ${row('Total Fixed Assets', totalFixedAssets, 'var(--violet)', true)} ${divider()} ${row('TOTAL ASSETS', totalAssets, 'var(--sky)', true)}
▼ Liabilities & Equity
${fmtINR(totalLiabilities + equity)}
${header('Current Liabilities', 'var(--rose)')} ${row('Short-Term Loans (tenure ≤12mo)', shortTermDebt, '', false, true)} ${row('Credit Card Outstanding', creditCardDebt, '', false, true)} ${row('Vendor / Supplier Payables', vendorPayables, '', false, true)} ${divider()} ${row('Total Current Liabilities', totalCurrentLiab, 'var(--rose)', true)} ${header('Long-Term Liabilities', 'var(--amber)')} ${row('Long-Term Loans (tenure >12mo)', longTermDebt, '', false, true)} ${divider()} ${row('Total Long-Term Liabilities', totalLongLiab, 'var(--amber)', true)} ${divider()} ${row('TOTAL LIABILITIES', totalLiabilities, 'var(--rose)', true)} ${header("Owner's Equity", 'var(--accent)')} ${row('Net Worth (Assets - Liabilities)', equity, equity>=0?'var(--accent)':'var(--rose)', false, true)} ${divider()} ${row('TOTAL LIAB. + EQUITY', totalAssets, 'var(--accent)', true)}
🏢 Loan Portfolio by Lender Type
${fmtINR(totalLiabilities)} total  ·  ${fmtINR(emiMonthly)}/mo EMI
${Object.entries(lenderTypeBreak).map(([lt,amt]) => { const pct = totalLiabilities > 0 ? Math.round(amt/totalLiabilities*100) : 0; return `
${LTYPE[lt]||lt}
${fmtINR(amt)}
${pct}% of total debt
`; }).join('')} ${Object.keys(lenderTypeBreak).length === 0 ? '
No active loans recorded
' : ''}
Note: Values are auto-calculated from MYDAS data (loans, projects, banks, personal budget, Profit First accounts). For full accuracy: (1) Enter vendor outstanding amounts in the Vendors tab, (2) Enter personal account balances in My Budget, (3) Set gold rate in Net Worth tab, (4) Record all bank transactions in the Banks tab.
`; } function printBalanceSheet() { const el = document.getElementById('balSheetContent'); if (!el) return; const now = new Date().toLocaleDateString('en-IN',{day:'2-digit',month:'short',year:'numeric'}); const html = 'Balance Sheet' + '
' + 'Balance Sheet - Print View' + '' + '
' + '

Balance Sheet

' + '
Newton Construction Business  |  As at ' + now + '
' + el.querySelector('table') ? el.innerHTML.replace(/]*>.*?<\/button>/gs,'') : el.innerHTML + ''; const w = window.open('','_blank','width=900,height=700'); if (w) { w.document.write(html); w.document.close(); } } // ===== BANKS MODULE ===== function addBank() { const name = document.getElementById('bankName')?.value.trim(); const accNo = document.getElementById('bankAccNo')?.value.trim() || ''; const holder = document.getElementById('bankHolder')?.value.trim() || ''; const accType = document.getElementById('bankAccType')?.value || 'current'; if (!name) { alert('Bank name required'); return; } data.banks = Array.isArray(data.banks) ? data.banks : []; data.banks.push({ id: Date.now(), name, accNo, holder, accType, statements: [], transactions: [] }); ['bankName','bankAccNo','bankHolder'].forEach(id => { const el = document.getElementById(id); if (el) el.value = ''; }); saveData(); renderBanksTab(); } function deleteBank(bankId) { if (!confirm('Delete this bank and all its statements?')) return; data.banks = (data.banks || []).filter(b => b.id !== bankId); saveData(); renderBanksTab(); } // ── Statement parser helpers ─────────────────────────────────────── function parseCSVtoRows(text) { const lines = text.split(/\r?\n/).filter(l => l.trim()); return lines.map(l => { const cols = []; let cur = '', inQ = false; for (let c of l) { if (c === '"') { inQ = !inQ; } else if (c === ',' && !inQ) { cols.push(cur.trim()); cur = ''; } else cur += c; } cols.push(cur.trim()); return cols; }); } function autoDetectColumns(rows) { // Find header row (first row with text-like content) const header = rows[0] || []; const hLow = header.map(h => (h||'').toLowerCase()); const find = (...keys) => { for (const k of keys) { const i = hLow.findIndex(h => h.includes(k)); if (i >= 0) return i; } return -1; }; return { date: find('date','dt','trans date','txn date','value date','posting date'), desc: find('description','narration','particulars','detail','remarks','txn desc','transaction'), debit: find('debit','dr','withdrawal','paid','amount dr'), credit: find('credit','cr','deposit','received','amount cr'), amount: find('amount','amt'), balance: find('balance','bal','closing','running'), ref: find('ref','chq','cheque','utr','transaction id','txn id'), headerRow: 0 }; } function parseAmount(str) { if (!str) return 0; const n = parseFloat((str+'').replace(/[₹,\s]/g,'').replace(/[()]/g,m => m==='('?'-':'')); return isNaN(n) ? 0 : n; } function parseDateFlex(str) { if (!str) return ''; str = str.trim(); // dd/mm/yyyy, dd-mm-yyyy, dd.mm.yyyy const m1 = str.match(/^(\d{1,2})[\/\-\.](\d{1,2})[\/\-\.](\d{2,4})$/); if (m1) { let [,d,mn,y] = m1; if (y.length === 2) y = '20' + y; return `${y}-${mn.padStart(2,'0')}-${d.padStart(2,'0')}`; } // yyyy-mm-dd const m2 = str.match(/^(\d{4})-(\d{2})-(\d{2})/); if (m2) return str.slice(0,10); // dd-Mon-yyyy const m3 = str.match(/^(\d{1,2})[\-\s]([A-Za-z]{3})[\-\s](\d{2,4})$/i); if (m3) { const months = {jan:'01',feb:'02',mar:'03',apr:'04',may:'05',jun:'06',jul:'07',aug:'08',sep:'09',oct:'10',nov:'11',dec:'12'}; let [,d,mn,y] = m3; if (y.length === 2) y = '20' + y; return `${y}-${(months[mn.toLowerCase()]||'01')}-${d.padStart(2,'0')}`; } return str; } function rowsToTransactions(rows, cols, bankId) { const txns = []; for (let i = cols.headerRow + 1; i < rows.length; i++) { const r = rows[i]; if (!r || r.length < 2) continue; const dateStr = parseDateFlex(r[cols.date] || ''); if (!dateStr) continue; let amount = 0, type = 'credit'; if (cols.debit >= 0 && cols.credit >= 0) { const dr = parseAmount(r[cols.debit]); const cr = parseAmount(r[cols.credit]); if (dr > 0) { amount = dr; type = 'debit'; } else if (cr > 0) { amount = cr; type = 'credit'; } else continue; } else if (cols.amount >= 0) { amount = parseAmount(r[cols.amount]); type = amount < 0 ? 'debit' : 'credit'; amount = Math.abs(amount); } if (!amount) continue; const desc = (r[cols.desc] || '').replace(/\s+/g,' ').trim(); const balance = cols.balance >= 0 ? parseAmount(r[cols.balance]) : null; const ref = cols.ref >= 0 ? (r[cols.ref]||'').trim() : ''; txns.push({ id: `${bankId}_${dateStr}_${amount}_${txns.length}`, date: dateStr, desc, amount, type, balance, ref, linkedTo: null, matchStatus: 'unmatched' }); } return txns; } async function handleStatementUpload(bankId, file, monthArg) { const bank = (data.banks||[]).find(b => b.id === bankId); if (!bank) return; const ext = file.name.split('.').pop().toLowerCase(); let txns = []; // Capture raw PDF bytes (base64) for in-app viewing & library storage let _pdfBase64 = null; if (ext === 'pdf') { try { const ab = await file.arrayBuffer(); let bin = ''; const bytes = new Uint8Array(ab); for (let i=0;i { const isDebit = (t.debit||0) > 0; const amount = isDebit ? t.debit : t.credit; return { id: `${bankId}_${t.date}_${amount}_${idx}_${(t.balance||0)}`, date: t.date, desc: t.desc || '', amount, type: isDebit ? 'debit' : 'credit', balance: (t.balance != null ? t.balance : null), ref: t.ref || '', linkedTo: null, matchStatus: 'unmatched' }; }; if (isIndianBank && (ext === 'xlsx' || ext === 'xls' || ext === 'csv')) { // Indian Bank Excel/CSV: header sits below metadata rows; use 'Indian' parser let rows = []; if (ext === 'csv') { rows = parseCSVtoRows(await file.text()); } else { const XLSX = window.XLSX; if (!XLSX) { alert('SheetJS not loaded. Use CSV format.'); return; } const wb = XLSX.read(await file.arrayBuffer(), { type:'array', cellDates:true }); const ws = wb.Sheets[wb.SheetNames[0]]; rows = XLSX.utils.sheet_to_json(ws, { header:1, raw:true, cellDates:true, defval:'' }); rows = rows.map(r => (r||[]).map(v => { if (v === null || v === undefined || v === '') return ''; if (v instanceof Date) return v.getFullYear()+'-'+String(v.getMonth()+1).padStart(2,'0')+'-'+String(v.getDate()).padStart(2,'0'); return String(v); })); } const parsed = (PB_BANK_PARSERS['Indian'] || PB_BANK_PARSERS['Generic'])(rows).filter(t => t.date); txns = parsed.map(toBankTxn); } else if (isIndianBank && ext === 'pdf') { // Indian Bank PDF: use the INB_PDF text parser (auto-fetches header) if (!window.pdfjsLib) { alert('PDF reader unavailable. Use CSV/Excel.'); return; } let text = ''; try { text = await extractPdfText(await file.arrayBuffer()); } catch(e){ alert('Could not read PDF: ' + (e.message||e)); return; } const result = PB_PDF_PARSERS['INB_PDF'](text); txns = (result.txns||[]).filter(t => t.date).map(toBankTxn); // Auto-fill bank metadata from the statement header if missing if (result.header) { if (!bank.accNo && result.header.accNo) bank.accNo = result.header.accNo; if (!bank.holder && result.header.holder) bank.holder = result.header.holder; } // Footer reconciliation if (result.header && txns.length) { const sumD = txns.filter(t=>t.type==='debit').reduce((s,t)=>s+t.amount,0); const sumC = txns.filter(t=>t.type==='credit').reduce((s,t)=>s+t.amount,0); const dOk = !result.header.totDebit || Math.abs(sumD-result.header.totDebit) < 1; const cOk = !result.header.totCredit || Math.abs(sumC-result.header.totCredit) < 1; if ((!dOk||!cOk) && !confirm( 'PDF parsed but totals do not match the statement footer:\n\n' +'Debits : '+fmtINR(sumD)+' vs stated '+fmtINR(result.header.totDebit)+'\n' +'Credits : '+fmtINR(sumC)+' vs stated '+fmtINR(result.header.totCredit)+'\n\n' +'OK = import anyway · Cancel = abort')) return; } } else if (ext === 'csv') { const text = await file.text(); const rows = parseCSVtoRows(text); if (rows.length < 2) { alert('CSV appears empty or unreadable'); return; } const cols = autoDetectColumns(rows); txns = rowsToTransactions(rows, cols, bankId); } else if (ext === 'xlsx' || ext === 'xls') { // Use SheetJS via CDN (already loaded in page or load on demand) try { const ab = await file.arrayBuffer(); const XLSX = window.XLSX; if (!XLSX) { alert('SheetJS not loaded. Use CSV format.'); return; } const wb = XLSX.read(ab, { type: 'array', cellDates: true }); const ws = wb.Sheets[wb.SheetNames[0]]; const rows = XLSX.utils.sheet_to_json(ws, { header: 1, raw: false, dateNF: 'yyyy-mm-dd' }); const cols = autoDetectColumns(rows); txns = rowsToTransactions(rows, cols, bankId); } catch(e) { alert('Excel parse error: ' + e.message + '\nTry exporting as CSV.'); return; } } else if (ext === 'pdf') { // PDF: instruct user — we extract text via pdfjsLib if available if (window.pdfjsLib) { try { const ab = await file.arrayBuffer(); const pdf = await pdfjsLib.getDocument({ data: ab }).promise; let allText = ''; for (let p = 1; p <= pdf.numPages; p++) { const page = await pdf.getPage(p); const tc = await page.getTextContent(); allText += tc.items.map(i => i.str).join(' ') + '\n'; } // Try to parse lines as CSV-like const lines = allText.split('\n').map(l => l.trim()).filter(Boolean); // Heuristic: lines with dates const dateRe = /\d{1,2}[\/\-\.]\d{1,2}[\/\-\.]\d{2,4}|\d{4}-\d{2}-\d{2}/; const amtRe = /[\d,]+\.\d{2}/; lines.forEach((line, idx) => { if (!dateRe.test(line) || !amtRe.test(line)) return; const dateM = line.match(dateRe); const amts = [...line.matchAll(/[\d,]+\.\d{2}/g)].map(m => parseAmount(m[0])); if (!dateM || !amts.length) return; const date = parseDateFlex(dateM[0]); const amount = amts[amts.length > 1 ? amts.length - 2 : 0] || 0; const isDebit = /dr|debit|withdrawal/i.test(line); if (amount > 0) { txns.push({ id: `${bankId}_${date}_${amount}_${idx}`, date, desc: line.slice(0, 120), amount, type: isDebit ? 'debit' : 'credit', balance: amts[amts.length - 1] || null, ref: '', linkedTo: null, matchStatus: 'unmatched' }); } }); } catch(e) { alert('PDF parse failed: ' + e.message + '\nPlease use CSV or Excel export from your bank.'); return; } } else { alert('PDF parsing requires PDF.js library.\nPlease export your bank statement as CSV or Excel instead.'); return; } } if (!txns.length) { alert('No transactions found. Check the file format.\nTip: Ensure your CSV/Excel has columns for Date, Description, Debit, Credit (or Amount).'); return; } // Deduplicate by id const existingIds = new Set((bank.transactions||[]).map(t => t.id)); const newTxns = txns.filter(t => !existingIds.has(t.id)); bank.transactions = [...(bank.transactions||[]), ...newTxns] .sort((a,b) => b.date.localeCompare(a.date)); // Determine the statement month: explicit arg → detected from txns → current let stmtMonth = monthArg || ''; if (!stmtMonth && txns.length) { const mc = {}; txns.forEach(t => { const m=(t.date||'').slice(0,7); if(m) mc[m]=(mc[m]||0)+1; }); stmtMonth = Object.entries(mc).sort((a,b)=>b[1]-a[1])[0]?.[0] || ''; } if (!stmtMonth) stmtMonth = dayKey(new Date()).slice(0,7); bank.statements = bank.statements || []; const stmtId = Date.now() + '_' + Math.random().toString(36).slice(2,7); const stmtRec = { id: stmtId, name: file.name, month: stmtMonth, type: ext, date: dayKey(new Date()), count: txns.length, added: newTxns.length, hasPdf: !!_pdfBase64 }; bank.statements.push(stmtRec); // Persist the PDF separately (kept out of main payload to avoid quota overflow) if (_pdfBase64) { await saveBankPdf(stmtId, _pdfBase64); } saveData(); renderBanksTab(); alert(`Loaded ${txns.length} transactions (${newTxns.length} new) from ${file.name}` + `\nMonth: ${stmtMonth}` + (_pdfBase64 ? '\nPDF saved — click “View” to cross-check.' : '')); } // ── View a stored bank-statement PDF side-by-side with transactions ── async function viewBankStatementPdf(stmtId, name, bankId) { const b64 = await loadBankPdf(stmtId); if (!b64) { alert('PDF not found in storage for this statement.'); return; } const bank = (data.banks||[]).find(x => x.id === bankId); const stmt = (bank?.statements||[]).find(s => s.id === stmtId); const dataUrl = 'data:application/pdf;base64,' + b64; // Build a compact transaction table for the same month for cross-checking let txnTable = '
No parsed transactions to compare.
'; if (bank) { const mon = stmt?.month || ''; const list = (bank.transactions||[]).filter(t => !mon || (t.date||'').startsWith(mon)) .sort((a,b)=>(a.date||'').localeCompare(b.date||'')); if (list.length) { const dr = list.filter(t=>t.type==='debit').reduce((s,t)=>s+t.amount,0); const cr = list.filter(t=>t.type==='credit').reduce((s,t)=>s+t.amount,0); txnTable = `
${list.length} txns · Dr ${fmtINR(dr)} · Cr ${fmtINR(cr)}
` + '' + list.map(t => ``).join('') + '
DateDescriptionDrCrBal
${t.date} ${escHtml((t.desc||'').slice(0,50))} ${t.type==='debit'?fmtINR(t.amount):''} ${t.type==='credit'?fmtINR(t.amount):''} ${t.balance!=null?fmtINR(t.balance):''}
'; } } document.getElementById('bankPdfModal')?.remove(); const modal = document.createElement('div'); modal.id = 'bankPdfModal'; modal.style.cssText = 'position:fixed;inset:0;z-index:10000;background:rgba(0,0,0,0.45);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);display:flex;align-items:center;justify-content:center;padding:18px;'; modal.innerHTML = `
📄 ${escHtml(name||'Statement')} ${stmt?.month ? `${stmt.month}` : ''} ⬇ Download
Parsed Transactions (cross-check)
${txnTable}
`; modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); }); document.body.appendChild(modal); } async function deleteBankStatement(stmtId, bankId) { const bank = (data.banks||[]).find(x => x.id === bankId); if (!bank) return; if (!confirm('Remove this statement record? (Transactions already imported are kept.)')) return; bank.statements = (bank.statements||[]).filter(s => s.id !== stmtId); await deleteBankPdf(stmtId); saveData(); renderBanksTab(); } // updateLinkEntity: standalone so no inline SCRIPT-tag is needed inside modal HTML function updateLinkEntity() { const cat = document.getElementById('linkCategory')?.value; const ew = document.getElementById('linkEntityWrap'); const ow = document.getElementById('linkOtherWrap'); const sel = document.getElementById('linkEntitySel'); const d = window._linkData || {}; if (!ew || !ow || !sel) return; ew.style.display = (cat && cat !== 'other') ? '' : 'none'; ow.style.display = cat === 'other' ? '' : 'none'; if (cat === 'project') sel.innerHTML = d.projOpts || ''; else if (cat === 'vendor') sel.innerHTML = d.venOpts || ''; else if (cat === 'labour') sel.innerHTML = d.labOpts || ''; else if (cat === 'loan') sel.innerHTML = d.loanOpts || ''; else if (cat === 'subcon') sel.innerHTML = d.subOpts || ''; } function linkTransaction(bankId, txnId) { const bank = (data.banks||[]).find(b => b.id === bankId); const txn = (bank?.transactions||[]).find(t => t.id === txnId); if (!bank || !txn) return; // Set data on window BEFORE opening modal (avoids an inline SCRIPT-tag) window._linkData = { projOpts: (data.projects||[]).map(p => ``).join(''), venOpts: (data.vendors||[]).map(v => ``).join(''), labOpts: (data.labours||[]).map(l => ``).join(''), loanOpts: (data.loans||[]).map(l => ``).join(''), subOpts: (data.subcontractors||[]).map(s => ``).join('') }; const amtColor = txn.type === 'debit' ? 'var(--rose)' : 'var(--accent)'; const amtSign = txn.type === 'debit' ? '−' : '+'; openGenModal( '🔗 Link Transaction', `
Date${txn.date}
Amount ${amtSign} ${fmtINR(txn.amount)}
${escHtml((txn.desc||"").slice(0,120))}
`, () => { const cat = document.getElementById('linkCategory').value; const status = document.getElementById('linkStatus').value; const note = document.getElementById('linkNote').value.trim(); let linkedTo = null; if (cat && cat !== 'other') { const selVal = document.getElementById('linkEntitySel')?.value; if (selVal) linkedTo = selVal; } else if (cat === 'other') { const txt = document.getElementById('linkOtherText')?.value.trim(); if (txt) linkedTo = 'other::' + txt; } txn.linkedTo = linkedTo; txn.matchStatus = status; txn.linkNote = note; if (linkedTo && status === 'unmatched') txn.matchStatus = 'matched'; saveData(); renderBanksTab(); closeGenModal(); } ); } function deleteBankTxn(bankId, txnId) { const bank = (data.banks||[]).find(b => b.id === bankId); if (!bank) return; bank.transactions = (bank.transactions||[]).filter(t => t.id !== txnId); saveData(); renderBanksTab(); } function renderBanksTab() { data.banks = Array.isArray(data.banks) ? data.banks : []; const el = document.getElementById('banksList'); if (!el) return; if (!data.banks.length) { el.innerHTML = '
No banks added yet — add a bank above to upload statements and track transactions.
'; return; } const ACC_TYPE = { current:'Current A/c', savings:'Savings A/c', od:'OD / CC', fixed:'Fixed Deposit' }; const STATUS_COLOR = { matched:'var(--accent)', unmatched:'var(--amber)', review:'var(--rose)', ignored:'var(--text-muted)' }; const STATUS_ICON = { matched:'\u2713', unmatched:'\u25cb', review:'\u26a0', ignored:'\u2014' }; el.innerHTML = data.banks.map(bank => { const txns = bank.transactions || []; const debits = txns.filter(t => t.type==='debit'); const credits= txns.filter(t => t.type==='credit'); const totalDr= debits.reduce((s,t) => s+t.amount, 0); const totalCr= credits.reduce((s,t) => s+t.amount, 0); const matched= txns.filter(t => t.matchStatus==='matched').length; const unmatched = txns.filter(t => t.matchStatus==='unmatched').length; const latest = txns[0]; const stmts = bank.statements || []; // Active filter state per bank const fKey = `bankFilter_${bank.id}`; const sKey = `bankSearch_${bank.id}`; window._bankFilters = window._bankFilters || {}; window._bankFilters[fKey] = window._bankFilters[fKey] || 'all'; window._bankFilters[sKey] = window._bankFilters[sKey] || ''; const fMode = window._bankFilters[fKey]; const sQuery = window._bankFilters[sKey].toLowerCase(); let filtered = txns; if (fMode === 'debit') filtered = filtered.filter(t => t.type==='debit'); else if (fMode === 'credit') filtered = filtered.filter(t => t.type==='credit'); else if (fMode === 'unmatched') filtered = filtered.filter(t => t.matchStatus==='unmatched'); else if (fMode === 'matched') filtered = filtered.filter(t => t.matchStatus==='matched'); if (sQuery) filtered = filtered.filter(t => (t.desc||'').toLowerCase().includes(sQuery) || (t.ref||'').toLowerCase().includes(sQuery) || (t.linkNote||'').toLowerCase().includes(sQuery) ); const txnRows = filtered.slice(0, 200).map(t => { const statusColor = STATUS_COLOR[t.matchStatus] || 'var(--text-muted)'; const statusIcon = STATUS_ICON[t.matchStatus] || ''; let linkLabel = ''; if (t.linkedTo) { const [cat, id] = t.linkedTo.split('::'); if (cat === 'project') { const p = (data.projects||[]).find(x => String(x.id) === id); linkLabel = p ? '▸ ' + projNick(p) : 'Project'; } else if (cat === 'vendor') { const v = (data.vendors||[]).find(x => String(x.id) === id); linkLabel = v ? '▸ ' + v.name : 'Vendor'; } else if (cat === 'labour') { const l = (data.labours||[]).find(x => String(x.id) === id); linkLabel = l ? '▸ ' + l.name : 'Labour'; } else if (cat === 'loan') { const ln = (data.loans||[]).find(x => String(x.id) === id); linkLabel = ln ? '▸ ' + ln.lender : 'Loan'; } else if (cat === 'sub') { const s = (data.subcontractors||[]).find(x => String(x.id) === id); linkLabel = s ? '▸ ' + s.name : 'Subcon'; } else if (cat === 'other') { linkLabel = '▸ ' + id.slice(0,30); } } return ` ${t.date} ${escHtml((t.desc||'').slice(0,70))} ${t.type==='debit' ? '\u2212 ' + fmtINR(t.amount) : ''} ${t.type==='credit' ? '+ ' + fmtINR(t.amount) : ''} ${t.balance != null ? fmtINR(t.balance) : ''} ${statusIcon} ${t.matchStatus} ${linkLabel ? `
${escHtml(linkLabel)}
` : ''} `; }).join(''); return `
${escHtml(bank.name)} ${ACC_TYPE[bank.accType]||bank.accType}
${bank.accNo ? 'A/c: ' + escHtml(bank.accNo) + ' · ' : ''} ${bank.holder ? escHtml(bank.holder) + ' · ' : ''} ${txns.length} transactions ${latest ? ' · Latest: ' + latest.date : ''}
Total Debit
${fmtINR(totalDr)}
${debits.length} txns
Total Credit
${fmtINR(totalCr)}
${credits.length} txns
Net Flow
${fmtINR(Math.abs(totalCr-totalDr))}
${totalCr-totalDr>=0?'\u25b2 Surplus':'\u25bc Deficit'}
Matched
${matched}
of ${txns.length}
Unmatched
${unmatched}
need linking
Upload Statement
${stmts.length ? `
Uploaded statements (by month)
${stmts.slice().sort((a,b)=>(b.month||'').localeCompare(a.month||'')).map(s => { const monLabel = s.month ? (()=>{const [y,m]=s.month.split('-');const M=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];return M[parseInt(m)-1]+' '+y;})() : '—'; return `
${monLabel} ${escHtml(s.name)} ${s.count||0} txns${s.added!=null?' · '+s.added+' new':''} ${s.hasPdf ? `` : `${(s.type||'').toUpperCase()}`}
`; }).join('')}
` : ''}
${txns.length ? `
${['all','debit','credit','unmatched','matched'].map(f => ``).join('')}
${filtered.length} / ${txns.length}
${txnRows}
DateDescription Debit Credit Balance Status / LinkAction
${filtered.length > 200 ? `
Showing first 200 of ${filtered.length} transactions
` : ''}
` : `
No transactions yet — upload a statement above.
`}
`; }).join(''); } // ===== BORROW / DEBT MODULE ===== function calcEMI(principal, annualRate, months) { if (annualRate === 0) return principal / months; const r = annualRate / 100 / 12; return principal * r * Math.pow(1+r, months) / (Math.pow(1+r, months) - 1); } // Show Jewel Wt field only for Jewel Loan / Gold Loan function loanCategoryChanged() { const cat = document.getElementById('loanCategory')?.value || ''; const wrap = document.getElementById('loanJewelWeightWrap'); if (!wrap) return; const show = (cat === 'jewel' || cat === 'gold'); wrap.style.display = show ? '' : 'none'; if (!show) { const jw = document.getElementById('loanJewelWeight'); if (jw) jw.value = ''; } } // Auto-calculate interest & EMI from principal, rate, tenure and interest type function loanAutoCalc() { const principal = Number(document.getElementById('loanPrincipal')?.value || 0); const rateRaw = Number(document.getElementById('loanRate')?.value || 0); const rateUnit = document.getElementById('loanRateUnit')?.value || 'yearly'; const annualRate= rateUnit === 'monthly' ? rateRaw * 12 : rateRaw; const tenure = Number(document.getElementById('loanTenure')?.value || 0); const lType = document.getElementById('loanType')?.value || 'reducing'; const emiEl = document.getElementById('loanEMIManual'); if (!principal) { alert('Enter Principal amount first.'); return; } if (!annualRate){ alert('Enter the interest Rate first.'); return; } let emi = 0, totalInterest = 0, totalPayable = 0, monthlyInterest = 0; const monthlyRate = annualRate / 100 / 12; if (lType === 'bullet') { // Interest-only monthly; principal repaid at end (typical for jewel/gold loans) monthlyInterest = principal * monthlyRate; emi = Math.round(monthlyInterest); // monthly interest servicing totalInterest = tenure > 0 ? monthlyInterest * tenure : monthlyInterest; totalPayable = principal + totalInterest; } else if (lType === 'simple') { if (!tenure) { alert('Enter Tenure (months) for simple interest.'); return; } totalInterest = principal * (annualRate/100) * (tenure/12); totalPayable = principal + totalInterest; emi = Math.round(totalPayable / tenure); } else if (lType === 'flat') { if (!tenure) { alert('Enter Tenure (months) for flat rate.'); return; } totalInterest = principal * (annualRate/100) * (tenure/12); totalPayable = principal + totalInterest; emi = Math.round(totalPayable / tenure); } else { // reducing balance if (!tenure) { alert('Enter Tenure (months) for reducing balance.'); return; } emi = calcEMI(principal, annualRate, tenure); totalPayable = emi * tenure; totalInterest = totalPayable - principal; emi = Math.round(emi); } // Interest per month — standard basis: principal × monthly rate // (for bullet this equals the interest-only servicing amount) monthlyInterest = Math.round(principal * monthlyRate); if (emiEl) emiEl.value = emi; const miEl = document.getElementById('loanMonthlyInterest'); if (miEl) miEl.value = monthlyInterest; // Auto-fill end date if start + tenure present loanAutoEndDate(); // Build a readable breakdown const catLabel = { reducing:'Reducing Balance', flat:'Flat Rate', simple:'Simple Interest', bullet:'Bullet / Interest-only' }[lType]; let msg = 'AUTO CALCULATION ('+catLabel+')\n\n' + 'Principal : ' + fmtINR(principal) + '\n' + 'Rate : ' + rateRaw + (rateUnit==='monthly'?'% p.m. ('+annualRate.toFixed(2)+'% p.a.)':'% p.a.') + '\n'; if (lType === 'bullet') { msg += 'Interest / Month: ' + fmtINR(monthlyInterest) + '\n' + (tenure?('Tenure : '+tenure+' months\n'):'') + 'Total Interest : ' + fmtINR(Math.round(totalInterest)) + (tenure?'':' /month') + '\n' + 'Total Payable : ' + fmtINR(Math.round(totalPayable)) + '\n\n' + '→ EMI & Interest/Month set to ' + fmtINR(monthlyInterest); } else { msg += 'Tenure : ' + tenure + ' months\n' + 'EMI / Month : ' + fmtINR(emi) + '\n' + 'Interest / Month: ' + fmtINR(monthlyInterest) + '\n' + 'Total Interest : ' + fmtINR(Math.round(totalInterest)) + '\n' + 'Total Payable : ' + fmtINR(Math.round(totalPayable)) + '\n\n' + '→ EMI ' + fmtINR(emi) + ' · Interest/Month ' + fmtINR(monthlyInterest); } alert(msg); } // Loan sort/filter/view state let loanSortMode = 'date'; let loanFilterMode = 'all'; let loanViewMode = 'list'; let loanLenderFilter = 'all'; let loanSearchQuery = ''; let loanLenderTypeQuickKey = ''; function setLoanSort(mode) { loanSortMode = mode; document.querySelectorAll('#loanSortGroup .filter-btn').forEach(b => b.classList.toggle('active', b.dataset.lsort === mode)); renderLoans(); } function setLoanFilter(mode) { loanFilterMode = mode; document.querySelectorAll('#loanFilterGroup .filter-btn').forEach(b => b.classList.toggle('active', b.dataset.lfilter === mode)); renderLoans(); } function setLoanView(mode) { loanViewMode = mode; document.querySelectorAll('#loanViewGroup .filter-btn').forEach(b => b.classList.toggle('active', b.dataset.lview === mode)); renderLoans(); } function setLoanLenderFilter(val) { loanLenderFilter = val; renderLoans(); } function setLoanSearch(q) { loanSearchQuery = q.toLowerCase().trim(); renderLoans(); } function setLoanLenderTypeQuickFilter(key) { loanLenderTypeQuickKey = (loanLenderTypeQuickKey === key) ? '' : key; renderLoans(); } function clearLoanForm() { ['loanLender','loanAccountHolder','loanPrincipal','loanRate','loanJewelWeight','loanTenure','loanNote','loanMonthlyInterest'].forEach(id => { const el = document.getElementById(id); if (el) el.value = ''; }); const su = document.getElementById('loanRateUnit'); if (su) su.value = 'yearly'; const sc = document.getElementById('loanCategory'); if (sc) sc.value = 'jewel'; const st = document.getElementById('loanType'); if (st) st.value = 'reducing'; const slt = document.getElementById('loanLenderType'); if (slt) slt.value = 'bank'; const sd = document.getElementById('loanStart'); if (sd) sd.value = ''; const emiEl = document.getElementById('loanEMIManual'); if (emiEl) emiEl.value = ''; const amtR = document.getElementById('loanAmtReceived'); if (amtR) amtR.value = ''; const inote = document.getElementById('loanInterestNote'); if (inote) inote.value = ''; const endEl = document.getElementById('loanEnd'); if (endEl) endEl.value = ''; loanCategoryChanged(); // category resets to jewel → show jewel field } function loanAutoEndDate() { const start = document.getElementById('loanStart')?.value; const tenure = Number(document.getElementById('loanTenure')?.value||0); const endEl = document.getElementById('loanEnd'); const emiEl = document.getElementById('loanEMIManual'); if (start && tenure > 0 && endEl && !endEl.value) { const d = new Date(start); d.setMonth(d.getMonth() + tenure); endEl.value = dayKey(d); } // Auto-calc EMI preview const principal = Number(document.getElementById('loanPrincipal')?.value||0); const rateRaw = Number(document.getElementById('loanRate')?.value||0); const rateUnit = document.getElementById('loanRateUnit')?.value||'yearly'; const rate = rateUnit==='monthly' ? rateRaw*12 : rateRaw; const lType = document.getElementById('loanType')?.value||'reducing'; if (principal && rate && tenure && emiEl && !emiEl.value) { let emi = 0; if (lType==='reducing') emi = calcEMI(principal, rate, tenure); else emi = (principal + principal*rate/100*tenure/12)/tenure; emiEl.value = Math.round(emi); } } function addLoan() { const lender = document.getElementById('loanLender')?.value.trim(); const accountHolder = document.getElementById('loanAccountHolder')?.value.trim() || ''; const principal = Number(document.getElementById('loanPrincipal')?.value || 0); const amtReceived = Number(document.getElementById('loanAmtReceived')?.value || 0); const rateRaw = Number(document.getElementById('loanRate')?.value || 0); const rateUnit = document.getElementById('loanRateUnit')?.value || 'yearly'; const rate = rateUnit === 'monthly' ? Math.round(rateRaw * 12 * 100) / 100 : rateRaw; const type = document.getElementById('loanType')?.value || 'reducing'; const tenure = Number(document.getElementById('loanTenure')?.value || 0); const start = document.getElementById('loanStart')?.value || dayKey(new Date()); const endDate = document.getElementById('loanEnd')?.value || ''; const emiManual = Number(document.getElementById('loanEMIManual')?.value || 0); const interestNote = document.getElementById('loanInterestNote')?.value.trim() || ''; const purpose = document.getElementById('loanCategory')?.value || 'business'; const note = document.getElementById('loanNote')?.value.trim() || ''; const jewelWeight = Number(document.getElementById('loanJewelWeight')?.value || 0); const lenderType = document.getElementById('loanLenderType')?.value || 'bank'; let monthlyInterest = Number(document.getElementById('loanMonthlyInterest')?.value || 0); if (!lender || !principal) return; // Fallback: derive monthly interest if not auto-calculated if (!monthlyInterest && rate > 0) monthlyInterest = Math.round(principal * (rate/100/12)); let emi = emiManual || 0; if (!emi && tenure > 0) { if (type === 'reducing') emi = calcEMI(principal, rate, tenure); else if (type === 'flat' || type === 'simple') emi = (principal + principal * rate/100 * tenure/12) / tenure; } const totalPayable = (tenure > 0 && type !== 'bullet') ? Math.round(emi * tenure * 100)/100 : (type === 'bullet' && tenure > 0) ? principal + principal * rate/100 * tenure/12 : principal; const totalInterest = Math.round((totalPayable - principal) * 100)/100; // Auto end date from start + tenure let calcEndDate = endDate; if (!calcEndDate && start && tenure > 0) { const d = new Date(start); d.setMonth(d.getMonth() + tenure); calcEndDate = dayKey(d); } data.loans = Array.isArray(data.loans) ? data.loans : []; data.loans.push({ id: Date.now(), lender, accountHolder, lenderType, principal, amtReceived: amtReceived || principal, rate, rateUnit, type, tenure, start, endDate: calcEndDate, purpose, note, interestNote, jewelWeight: jewelWeight || undefined, monthlyInterest: monthlyInterest || 0, status: 'active', emi: Math.round(emi * 100)/100, totalPayable: Math.round(totalPayable * 100)/100, totalInterest: Math.round(totalInterest * 100)/100, repayments: [] }); clearLoanForm(); saveData(); renderLoans(); } function addRepayment(loanId) { const loan = (data.loans || []).find(l => l.id === loanId); if (!loan) return; // Monthly interest on current balance const balance = getLoanBalance(loan); const monthlyInterest = loan.rate > 0 ? Math.round(balance * (loan.rate / 100 / 12) * 100) / 100 : 0; const emiAmt = loan.emi || 0; openGenModal(`💰 Record Payment — ${loan.lender}`, `
Outstanding Principal
${fmtINR(balance)}
Monthly Interest
${fmtINR(monthlyInterest)}
EMI Amount
${emiAmt ? fmtINR(emiAmt) : '—'}
${emiAmt ? `` : ''} ${monthlyInterest ? `` : ''}
`, () => { const repDate = document.getElementById('repDate').value || dayKey(new Date()); const note = document.getElementById('repNote').value.trim(); const mode = document.querySelector('#repModeGroup .filter-btn.active')?.dataset.rmode || 'combined'; loan.repayments = Array.isArray(loan.repayments) ? loan.repayments : []; let principalPaid = 0, interestPaid = 0; if (mode === 'combined') { const total = Number(document.getElementById('repCombined').value || 0); if (!total) return; // Apportion: interest first, rest to principal interestPaid = Math.min(total, monthlyInterest); principalPaid = Math.max(total - interestPaid, 0); loan.repayments.push({ date: repDate, amount: principalPaid, interest: interestPaid, total, mode: 'combined', note }); } else if (mode === 'split') { interestPaid = Number(document.getElementById('repInterestAmt').value || 0); principalPaid = Number(document.getElementById('repPrincipalAmt').value || 0); if (!interestPaid && !principalPaid) return; loan.repayments.push({ date: repDate, amount: principalPaid, interest: interestPaid, total: principalPaid + interestPaid, mode: 'split', note }); } else if (mode === 'interest') { interestPaid = Number(document.getElementById('repInterestOnly').value || 0); if (!interestPaid) return; principalPaid = 0; loan.repayments.push({ date: repDate, amount: 0, interest: interestPaid, total: interestPaid, mode: 'interest-only', note }); } else if (mode === 'principal') { principalPaid = Number(document.getElementById('repPrincipalOnly').value || 0); if (!principalPaid) return; loan.repayments.push({ date: repDate, amount: principalPaid, interest: 0, total: principalPaid, mode: 'principal-only', note }); } // Close loan if principal fully paid const totalPrincipalPaid = loan.repayments.reduce((s,r) => s + Number(r.amount||0), 0); if (totalPrincipalPaid >= loan.principal) loan.status = 'closed'; saveData(); renderLoans(); closeGenModal(); }); } function setRepMode(mode) { document.querySelectorAll('#repModeGroup .filter-btn').forEach(b => b.classList.toggle('active', b.dataset.rmode === mode)); document.getElementById('repCombinedField').style.display = mode === 'combined' ? '' : 'none'; document.getElementById('repSplitField').style.display = mode === 'split' ? '' : 'none'; document.getElementById('repInterestField').style.display = mode === 'interest' ? '' : 'none'; document.getElementById('repPrincipalField').style.display = mode === 'principal' ? '' : 'none'; } function toggleLoanEMIPlan(loanId, enabled) { const loan = (data.loans || []).find(l => l.id === loanId); if (!loan) return; loan.emiPlan = enabled; if (enabled && !loan.tenure) { const months = Number(prompt('Enter planned repayment tenure (months):', 12) || 0); if (months > 0) loan.tenure = months; } saveData(); renderLoans(); } function editLoan(loanId) { const loan = (data.loans || []).find(l => l.id === loanId); if (!loan) return; const PURPOSE_OPTS = [ ['jewel','💍 Jewel Loan'],['gold','🥇 Gold Loan'],['home','🏠 Home Loan'], ['car','🚗 Car / Vehicle'],['business','💼 Business'],['project','🏗 Project'], ['equipment','⚙️ Equipment'],['personal','👤 Personal'], ['overdraft','🏦 Overdraft / CC'],['mortgage','🏢 Mortgage'], ['chit','🤝 Chit Fund'],['friend','👥 Friend / Family'],['other','📦 Other'] ]; const TYPE_OPTS = [ ['reducing','📉 Reducing Balance'],['flat','📋 Flat Rate'], ['simple','➗ Simple Interest'],['bullet','💥 Bullet (lump sum)'] ]; const RATE_UNIT_OPTS = [['yearly','% p.a. (Yearly)'],['monthly','% p.m. (Monthly)']]; const LTYPE_OPTS = [['bank','🏦 Bank'],['private_finance','💸 Private Finance'],['private_banking','🏢 Private Banking'],['nbfc','📊 NBFC'],['government','🏛 Government'],['chit','🤝 Chit Fund'],['friend','👥 Friend / Family'],['other','📦 Other']]; openGenModal(`✎ Edit Loan — ${loan.lender}`, `
`, () => { const lender = document.getElementById('elLender').value.trim(); const accountHolder = document.getElementById('elAccountHolder').value.trim(); const principal = Number(document.getElementById('elPrincipal').value || 0); const rateRaw = Number(document.getElementById('elRate').value || 0); const rateUnit = document.getElementById('elRateUnit').value || 'yearly'; const rate = rateUnit === 'monthly' ? Math.round(rateRaw * 12 * 100)/100 : rateRaw; const purpose = document.getElementById('elPurpose').value; const type = document.getElementById('elType').value; const tenure = Number(document.getElementById('elTenure').value || 0); const jewelWeight = Number(document.getElementById('elJewelWeight').value || 0); const start = document.getElementById('elStart').value || loan.start; const note = document.getElementById('elNote').value.trim(); if (!lender || !principal) return; let monthlyInterest = Number(document.getElementById('elMonthlyInterest').value || 0); if (!monthlyInterest && rate > 0) monthlyInterest = Math.round(principal * (rate/100/12)); const emi = tenure > 0 ? (type === 'reducing' ? calcEMI(principal, rate, tenure) : type === 'bullet' ? 0 : (principal + principal * rate/100 * tenure/12) / tenure) : 0; const totalPayable = (tenure > 0 && type === 'bullet') ? principal + principal * rate/100 * tenure/12 : tenure > 0 ? Math.round(emi * tenure * 100)/100 : principal; const lenderType = document.getElementById('elLenderType').value; Object.assign(loan, { lender, accountHolder, lenderType, principal, rate, rateUnit, purpose, type, tenure, start, note, jewelWeight: jewelWeight || undefined, monthlyInterest: monthlyInterest || 0, emi: Math.round(emi*100)/100, totalPayable: Math.round(totalPayable*100)/100, totalInterest: Math.round((totalPayable-principal)*100)/100 }); saveData(); renderLoans(); closeGenModal(); }); } // Edit-modal: toggle jewellery weight by category function elCategoryChanged() { const cat = document.getElementById('elPurpose')?.value || ''; const wrap = document.getElementById('elJewelWrap'); if (!wrap) return; const show = (cat === 'jewel' || cat === 'gold'); wrap.style.display = show ? '' : 'none'; if (!show) { const jw = document.getElementById('elJewelWeight'); if (jw) jw.value = ''; } } // Edit-modal: auto-calculate monthly interest from principal + rate function elAutoCalc() { const principal = Number(document.getElementById('elPrincipal')?.value || 0); const rateRaw = Number(document.getElementById('elRate')?.value || 0); const rateUnit = document.getElementById('elRateUnit')?.value || 'yearly'; const annualRate= rateUnit === 'monthly' ? rateRaw * 12 : rateRaw; const miEl = document.getElementById('elMonthlyInterest'); if (!principal) { alert('Enter Principal first.'); return; } if (!annualRate){ alert('Enter the interest Rate first.'); return; } const monthlyInterest = Math.round(principal * (annualRate/100/12)); if (miEl) miEl.value = monthlyInterest; alert('Monthly Interest\n\n' + 'Principal : ' + fmtINR(principal) + '\n' + 'Rate : ' + rateRaw + (rateUnit==='monthly'?'% p.m.':'% p.a. ('+(annualRate/12).toFixed(2)+'% p.m.)') + '\n' + '→ Interest / Month: ' + fmtINR(monthlyInterest)); } function closeLoan(loanId) { const loan = (data.loans || []).find(l => l.id === loanId); if (loan) { loan.status = 'closed'; saveData(); renderLoans(); } } function delLoan(loanId) { data.loans = (data.loans || []).filter(l => l.id !== loanId); saveData(); renderLoans(); } function getLoanBalance(loan) { // Only principal repayments reduce the balance — interest-only payments don't const paid = (loan.repayments || []).reduce((s,r) => s + Number(r.amount||0), 0); return Math.max(loan.principal - paid, 0); } // ══ Debt KPI cards: drag-and-drop reorder + custom cards (persisted) ══ let _debtDragKey = null; function debtCardDragStart(e, key) { _debtDragKey = key; e.dataTransfer.effectAllowed = 'move'; try { e.dataTransfer.setData('text/plain', key); } catch(_) {} e.currentTarget.style.opacity = '0.45'; } function debtCardDragOver(e) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; const card = e.currentTarget; if (card && card.dataset.cardkey !== _debtDragKey) card.style.outline = '2px dashed var(--sky)'; } function debtCardDragEnd(e) { if (e.currentTarget) e.currentTarget.style.opacity = ''; document.querySelectorAll('#debtKPIs .kpi-card').forEach(c => c.style.outline = ''); } function debtCardDrop(e, targetKey) { e.preventDefault(); document.querySelectorAll('#debtKPIs .kpi-card').forEach(c => c.style.outline = ''); const src = _debtDragKey; _debtDragKey = null; if (!src || src === targetKey) return; const order = Array.isArray(data.debtCardOrder) ? data.debtCardOrder.slice() : []; const from = order.indexOf(src), to = order.indexOf(targetKey); if (from < 0 || to < 0) return; order.splice(from, 1); order.splice(order.indexOf(targetKey) + (from < to ? 1 : 0), 0, src); data.debtCardOrder = order; saveData(); renderLoans(); } function debtDeleteCustomCard(cid) { if (!confirm('Delete this custom card?')) return; data.debtCustomCards = (data.debtCustomCards||[]).filter(c => String(c.id) !== String(cid)); data.debtCardOrder = (data.debtCardOrder||[]).filter(k => k !== 'custom_'+cid); saveData(); renderLoans(); } function debtAddCardModal() { const metrics = window._debtMetrics || {}; const metricOpts = Object.entries(metrics) .map(([k,m]) => ``).join(''); const COLORS = [['var(--violet)','Violet'],['var(--sky)','Blue'],['var(--accent)','Green'],['var(--amber)','Amber'],['var(--rose)','Red']]; openGenModal('+ Add Debt Card', `
`, () => { const mode = document.getElementById('ncMode').value; const label = document.getElementById('ncLabel').value.trim(); const color = document.getElementById('ncColor').value; if (!label) { alert('Enter a card label.'); return; } const card = { id: Date.now(), mode, label, color }; if (mode === 'metric') { card.metric = document.getElementById('ncMetric').value; } else { card.value = document.getElementById('ncValue').value.trim(); card.sub = document.getElementById('ncSub').value.trim(); if (card.value === '') { alert('Enter a value for the manual card.'); return; } } data.debtCustomCards = Array.isArray(data.debtCustomCards) ? data.debtCustomCards : []; data.debtCustomCards.push(card); data.debtCardOrder = Array.isArray(data.debtCardOrder) ? data.debtCardOrder : []; data.debtCardOrder.push('custom_'+card.id); saveData(); renderLoans(); closeGenModal(); }); // Set initial field visibility after modal renders setTimeout(debtAddCardModeChanged, 0); } function debtAddCardModeChanged() { const mode = document.getElementById('ncMode')?.value; const mw = document.getElementById('ncMetricWrap'); const vw = document.getElementById('ncValueWrap'); const sw = document.getElementById('ncSubWrap'); if (!mw || !vw) return; const manual = mode === 'manual'; mw.style.display = manual ? 'none' : ''; vw.style.display = manual ? '' : 'none'; if (sw) sw.style.display = manual ? '' : 'none'; } function getTotalInterestPaid(loan) { return (loan.repayments || []).reduce((s,r) => s + Number(r.interest||0), 0); } function getAmortisationSchedule(loan) { if (loan.type === 'bullet') return []; const rows = []; let balance = loan.principal; const r = loan.rate / 100 / 12; const emi = loan.emi; const startDate = new Date(loan.start + 'T00:00:00'); for (let i = 1; i <= loan.tenure; i++) { const interest = loan.type === 'reducing' ? Math.round(balance * r * 100)/100 : Math.round(loan.principal * loan.rate/100 / 12 * 100)/100; const principal = Math.min(Math.round((emi - interest) * 100)/100, balance); balance = Math.round((balance - principal) * 100)/100; const dueDate = new Date(startDate); dueDate.setMonth(dueDate.getMonth() + i); rows.push({ month: i, due: dayKey(dueDate), emi, interest, principal, balance }); } return rows; } function renderLoans() { data.loans = Array.isArray(data.loans) ? data.loans : []; let loans = data.loans; // Populate lender datalist const lenderDl = document.getElementById('loanLenderList'); if (lenderDl) { const lenders = Array.from(new Set(loans.map(l => l.lender).filter(Boolean))).sort(); lenderDl.innerHTML = lenders.map(l => ``).join(''); const llfSel = document.getElementById('loanLenderFilter'); if (llfSel) { const prevLender = llfSel.value; llfSel.innerHTML = '' + lenders.map(l => ``).join(''); if (lenders.includes(prevLender)) llfSel.value = prevLender; } } // KPI bar (always all loans) const active = loans.filter(l => l.status !== 'closed'); const totalOwed = active.reduce((s,l) => s + getLoanBalance(l), 0); const totalInterest = active.reduce((s,l) => s + l.totalInterest, 0); const monthlyEMI = active.reduce((s,l) => s + (l.emi||0), 0); // Monthly interest payable: per-loan stored value, else derived from current balance × rate const loanMonthlyInt = (l) => { if (l.monthlyInterest) return l.monthlyInterest; const bal = getLoanBalance(l); return l.rate > 0 ? bal * (l.rate/100/12) : 0; }; const monthlyInterestTotal = active.reduce((s,l) => s + loanMonthlyInt(l), 0); // Friend / Family subset (count, principal owed, and monthly interest) const ffLoans = active.filter(l => (l.lenderType||'bank') === 'friend'); const ffOwed = ffLoans.reduce((s,l) => s + getLoanBalance(l), 0); const ffMonthlyInt = ffLoans.reduce((s,l) => s + loanMonthlyInt(l), 0); const kpiEl = document.getElementById('debtKPIs'); if (kpiEl) { const LTYPE_LABEL = {bank:'🏦 Bank',private_finance:'💸 Pvt Finance',private_banking:'🏢 Pvt Banking',nbfc:'📊 NBFC',government:'🏛 Govt',chit:'🤝 Chit',friend:'👥 Friend/Family',other:'📦 Other'}; const ltypes = ['bank','private_finance','private_banking','nbfc','government','chit','friend','other']; const ltBreakdown = ltypes.map(lt => { const ltL = active.filter(l => (l.lenderType||'bank') === lt); const ltA = ltL.reduce((s,l) => s + getLoanBalance(l), 0); if (!ltL.length) return ''; return `
${LTYPE_LABEL[lt]}${fmtINR(ltA)} (${ltL.length})
`; }).join(''); // ── Metric registry: values custom metric-cards can reference ── const interestPaidSoFar = loans.reduce((s,l)=>s+getTotalInterestPaid(l),0); const loansClosed = loans.filter(l=>l.status==='closed').length; const catMonthlyInt = {}; // monthly interest by category active.forEach(l => { const c=l.purpose||'other'; catMonthlyInt[c]=(catMonthlyInt[c]||0)+loanMonthlyInt(l); }); window._debtMetrics = { totalOwed: { label:'Total Debt Outstanding', value: totalOwed }, monthlyEMI: { label:'Monthly EMI Obligation', value: monthlyEMI }, monthlyInterest: { label:'Monthly Interest Payable', value: Math.round(monthlyInterestTotal) }, ffMonthlyInt: { label:'Interest to Friend/Family (mo)', value: Math.round(ffMonthlyInt) }, ffOwed: { label:'Friend/Family Owed', value: ffOwed }, totalInterest: { label:'Total Interest Payable', value: totalInterest }, interestPaid: { label:'Interest Paid So Far', value: interestPaidSoFar }, loansClosed: { label:'Loans Closed', value: loansClosed, raw:true }, bankMonthlyInt: { label:'Interest to Banks (mo)', value: Math.round(active.filter(l=>(l.lenderType||'bank')==='bank').reduce((s,l)=>s+loanMonthlyInt(l),0)) }, nbfcMonthlyInt: { label:'Interest to NBFC (mo)', value: Math.round(active.filter(l=>(l.lenderType||'bank')==='nbfc').reduce((s,l)=>s+loanMonthlyInt(l),0)) } }; // ── Built-in cards (keyed) ── const builtin = { totalDebt: `
Total Debt Outstanding
${fmtINR(totalOwed)}
${active.length} active loan${active.length===1?'':'s'}
`, monthlyEMI: `
Monthly EMI Obligation
${fmtINR(monthlyEMI)}
Due every month
`, monthlyInt: `
Monthly Interest Payable
${fmtINR(Math.round(monthlyInterestTotal))}
Interest portion / month
`, ffInt: `
👥 Interest to Friend/Family
${fmtINR(Math.round(ffMonthlyInt))}
${ffLoans.length ? '/month · '+fmtINR(ffOwed)+' owed ('+ffLoans.length+')' : 'No friend/family loans'}
`, totalInt: `
Total Interest Payable
${fmtINR(totalInterest)}
Estimated across all loans
`, intPaid: `
Interest Paid So Far
${fmtINR(interestPaidSoFar)}
Actual interest recorded
`, loansClosed: `
Loans Closed
${loansClosed}
Fully repaid
`, byLender: `
🏦 By Lender Type
${ltBreakdown||'
No active loans
'}` }; const builtinCls = { totalDebt:'rose', monthlyEMI:'amber', monthlyInt:'amber', ffInt:'', totalInt:'sky', intPaid:'amber', loansClosed:'', byLender:'' }; const builtinStyle = { ffInt:'border-left:3px solid var(--violet);', byLender:'border-left:3px solid var(--violet);' }; const defaultOrder = ['totalDebt','monthlyEMI','monthlyInt','ffInt','totalInt','intPaid','loansClosed','byLender']; // ── Persisted state (lazy init) ── data.debtCustomCards = Array.isArray(data.debtCustomCards) ? data.debtCustomCards : []; let order = Array.isArray(data.debtCardOrder) ? data.debtCardOrder.slice() : defaultOrder.slice(); // Ensure every builtin + custom key is present (append new ones), drop stale const customKeys = data.debtCustomCards.map(c => 'custom_'+c.id); const allKeys = defaultOrder.concat(customKeys); order = order.filter(k => allKeys.includes(k)); allKeys.forEach(k => { if (!order.includes(k)) order.push(k); }); data.debtCardOrder = order; // ── Render a custom card body ── const renderCustomBody = (c) => { let valStr; if (c.mode === 'metric') { const m = window._debtMetrics[c.metric]; const v = m ? m.value : 0; valStr = (m && m.raw) ? String(v) : fmtINR(v); } else { const n = Number(c.value); valStr = (!isNaN(n) && c.value !== '' && c.value != null) ? fmtINR(n) : escHtml(String(c.value||'—')); } const col = c.color || 'var(--violet)'; return `
${escHtml(c.label||'Custom')}
` + `
${valStr}
` + `
${escHtml(c.sub|| (c.mode==='metric'?'Live metric':'Custom card'))}
`; }; // ── Build the card list in saved order ── const cardsHtml = order.map(key => { let body, cls='', extraStyle='', isCustom=false, cid=null; if (key.startsWith('custom_')) { cid = key.slice(7); const c = data.debtCustomCards.find(x => String(x.id) === cid); if (!c) return ''; body = renderCustomBody(c); isCustom=true; extraStyle = 'border-left:3px solid '+(c.color||'var(--violet)')+';'; } else { if (!builtin[key]) return ''; body = builtin[key]; cls = builtinCls[key]||''; extraStyle = builtinStyle[key]||''; } return `
${isCustom ? `` : ''} ${body}
`; }).join(''); // ── + Add Card tile ── const addTile = `
+ Add Card
`; kpiEl.innerHTML = cardsHtml + addTile; } const loanEl = document.getElementById('loanList'); if (!loanEl) return; // Apply filter const today = dayKey(new Date()); if (loanFilterMode === 'active') loans = loans.filter(l => l.status !== 'closed'); else if (loanFilterMode === 'closed') loans = loans.filter(l => l.status === 'closed'); else if (loanFilterMode === 'overdue') { loans = loans.filter(l => { if (l.status === 'closed') return false; const sched = getAmortisationSchedule(l); return sched.some(s => s.due < today); }); } // Apply lender name filter if (loanLenderFilter && loanLenderFilter !== 'all') { loans = loans.filter(l => l.lender === loanLenderFilter); } // Apply lender type quick-filter (card click) if (loanLenderTypeQuickKey) { loans = loans.filter(l => (l.lenderType||'bank') === loanLenderTypeQuickKey); } // Apply search query if (loanSearchQuery) { loans = loans.filter(l => (l.lender||''). toLowerCase().includes(loanSearchQuery) || (l.accountHolder||''). toLowerCase().includes(loanSearchQuery) || (l.note||''). toLowerCase().includes(loanSearchQuery)); } const scEl = document.getElementById('loanSearchCount'); if (scEl) scEl.textContent = loanSearchQuery ? `${loans.length} result${loans.length===1?'':'s'}` : ''; // Apply sort loans = [...loans]; if (loanSortMode === 'asc') loans.sort((a,b) => a.principal - b.principal); else if (loanSortMode === 'desc') loans.sort((a,b) => b.principal - a.principal); else if (loanSortMode === 'rate') loans.sort((a,b) => b.rate - a.rate); else loans.sort((a,b) => (b.id||0) - (a.id||0)); // date desc // Lender Type Summary Cards const typeCardEl = document.getElementById('loanTypeCards'); if (typeCardEl) { const allActive = (data.loans||[]).filter(l => l.status !== 'closed'); const LTCARD = [ {key:'bank',label:'🏦 Bank',color:'var(--sky)',bg:'rgba(10,132,255,0.08)'}, {key:'nbfc',label:'📊 NBFC',color:'var(--violet)',bg:'rgba(88,86,214,0.08)'}, {key:'private_finance',label:'💸 Pvt Finance',color:'var(--amber)',bg:'rgba(255,159,10,0.08)'}, {key:'private_banking',label:'🏢 Pvt Banking',color:'var(--accent)',bg:'rgba(48,209,88,0.08)'}, {key:'government',label:'🏛 Govt',color:'var(--rose)',bg:'rgba(255,69,58,0.08)'}, {key:'chit',label:'🤝 Chit Fund',color:'var(--sky)',bg:'rgba(10,132,255,0.05)'}, {key:'friend',label:'👥 Friend/Family',color:'var(--text-secondary)',bg:'var(--bg-surface)'}, {key:'other',label:'📦 Other',color:'var(--text-muted)',bg:'var(--bg-surface)'}, ]; const activeCards = LTCARD.filter(c => allActive.some(l => (l.lenderType||'bank') === c.key)); if (activeCards.length) { typeCardEl.innerHTML = '
' + activeCards.map(c => { const cL = allActive.filter(l => (l.lenderType||'bank') === c.key); const cAmt = cL.reduce((s,l) => s + getLoanBalance(l), 0); const cEMI = cL.reduce((s,l) => s + (l.emi||0), 0); const active = loanLenderTypeQuickKey === c.key; return `
` + `
${c.label}${active ? ' ' : ''}
` + `
${fmtINR(cAmt)}
` + `
${cL.length} loan${cL.length===1?'':'s'}  ·  ₹${Math.round(cEMI).toLocaleString('en-IN')}/mo
` + '
'; }).join('') + '
'; } else { typeCardEl.innerHTML = ''; } } if (!loans.length) { loanEl.innerHTML = '
No loans match the current filter — add a borrow/loan entry above to track EMI, interest & repayments
'; return; } const PURPOSE_ICON = { jewel:'💍',gold:'🥇',home:'🏠',car:'🚗',business:'💼',project:'🏗',equipment:'⚙️',personal:'👤',overdraft:'🏦',mortgage:'🏢',chit:'🤝',friend:'👥',other:'📦' }; const PURPOSE_LABEL = { jewel:'Jewel Loan',gold:'Gold Loan',home:'Home Loan',car:'Car / Vehicle',business:'Business Loan',project:'Project Loan',equipment:'Equipment Loan',personal:'Personal Loan',overdraft:'Overdraft / CC',mortgage:'Mortgage',chit:'Chit Fund',friend:'Friend / Family',other:'Other' }; const TYPE_LABEL = { reducing:'Reducing Balance',flat:'Flat Rate',simple:'Simple Interest',bullet:'Bullet Repayment' }; const LENDER_TYPE_LABEL = {bank:'🏦 Bank',private_finance:'💸 Pvt Finance',private_banking:'🏢 Pvt Banking',nbfc:'📊 NBFC',government:'🏛 Govt',chit:'🤝 Chit',friend:'👥 Friend/Family',other:'📦 Other'}; // ── KANBAN VIEW ────────────────────────────────────────────────────────── if (loanViewMode === 'kanban') { const cols = [ { key:'active', label:'🟠 Active', filter: l => l.status !== 'closed', color:'var(--amber)' }, { key:'overdue', label:'🔴 Overdue', filter: l => { const s=getAmortisationSchedule(l); return l.status!=='closed' && s.some(x=>x.due l.status === 'closed', color:'var(--accent)' } ]; loanEl.innerHTML = `
` + cols.map(col => { const colLoans = loans.filter(col.filter); return `
${col.label} (${colLoans.length})
${colLoans.length === 0 ? `
None
` : colLoans.map(loan => { const balance = getLoanBalance(loan); const pct = Math.min(Math.round((loan.principal - balance) / loan.principal * 100), 100); const rateDisplay = loan.rateUnit === 'monthly' ? `${(loan.rate/12).toFixed(2)}% p.m. (${loan.rate}% p.a.)` : `${loan.rate}% p.a.`; return `
${PURPOSE_ICON[loan.purpose]||'💰'} ${escHtml(loan.lender)} ${loan.accountHolder ? `· ${escHtml(loan.accountHolder)}` : ''}
${PURPOSE_LABEL[loan.purpose]||''} · ${rateDisplay}${loan.jewelWeight ? ` · ${loan.jewelWeight}g` : ''}${loan.lenderType ? ` · ${LENDER_TYPE_LABEL[loan.lenderType]||loan.lenderType}` : ''}
Principal${fmtINR(loan.principal)}
Balance${fmtINR(balance)}
${loan.status !== 'closed' ? `` : ''}
`; }).join('')}
`; }).join('') + `
`; return; } // ── LIST VIEW (default) ───────────────────────────────────────────────── loanEl.innerHTML = loans.map(loan => { const balance = getLoanBalance(loan); const paidAmt = loan.principal - balance; const pct = Math.min(Math.round(paidAmt / loan.principal * 100), 100); const isClosed = loan.status === 'closed'; const schedule = getAmortisationSchedule(loan); const overduePayments = schedule.filter(s => s.due < today && !isClosed).length; const nextDue = schedule.find(s => s.due >= today); const rateDisplay = loan.rateUnit === 'monthly' ? `${(loan.rate/12).toFixed(2)}% p.m. (${loan.rate}% p.a.)` : `${loan.rate}% p.a.`; return `
${PURPOSE_ICON[loan.purpose]||'💰'} ${escHtml(loan.lender)} ${loan.accountHolder ? `A/c: ${escHtml(loan.accountHolder)}` : ''} ${PURPOSE_LABEL[loan.purpose]||loan.purpose} ${loan.lenderType ? `${LENDER_TYPE_LABEL[loan.lenderType]||loan.lenderType}` : ''} ${isClosed ? `✓ Closed` : `Active`} ${overduePayments > 0 ? `⚠ ${overduePayments} EMI overdue` : ''}
${loan.note ? escHtml(loan.note) + ' · ' : ''}${TYPE_LABEL[loan.type]} · ${rateDisplay} ${loan.jewelWeight ? ` · Jewel: ${loan.jewelWeight}g` : ''} · Started ${loan.start}
Principal
${fmtINR(loan.principal)}
Rate
${rateDisplay}
Monthly EMI
${loan.type==='bullet'?'Bullet':loan.emi?fmtINR(loan.emi):'—'}
Monthly Interest
${(() => { const mi = loan.monthlyInterest || (loan.rate > 0 ? Math.round(loan.principal * (loan.rate/100/12)) : 0); return mi ? fmtINR(mi) : '—'; })()}
Total Interest
${fmtINR(loan.totalInterest)}
Balance Left
${fmtINR(balance)}
Paid
${fmtINR(paidAmt)}
${loan.jewelWeight ? `
Jewel Weight
${loan.jewelWeight}g
` : ''}
Repaid ${pct}% ${loan.tenure ? loan.tenure + ' months · ' : ''}${isClosed?'Closed':nextDue?'Next EMI '+nextDue.due:'—'}
${(loan.repayments||[]).length ? `
Repayment History (${loan.repayments.length} payments · Principal paid: ${fmtINR(loan.repayments.reduce((s,r)=>s+Number(r.amount||0),0))} · Interest paid: ${fmtINR(loan.repayments.reduce((s,r)=>s+Number(r.interest||0),0))}) ${loan.repayments.map((r,i)=>{ const modeLabel = {combined:'EMI','interest-only':'Interest','principal-only':'Principal',split:'Split'}[r.mode]||'Payment'; return ``; }).join('')}
#DateModeInterestPrincipalTotalNote
${i+1}${r.date} ${modeLabel} ${r.interest ? fmtINR(r.interest) : '—'} ${r.amount ? fmtINR(r.amount) : '—'} ${fmtINR(r.total||r.amount)} ${r.note||''}
` : ''} ${(loan.type === 'bullet' || loan.purpose === 'jewel' || loan.purpose === 'gold') ? `
Plan EMI Repayment
${loan.emiPlan && loan.tenure ? `
EMI: ${fmtINR(calcEMI(getLoanBalance(loan), loan.rate, loan.tenure))} / month over ${loan.tenure} months
` : ''}` : ''} ${!isClosed ? `
${loan.tenure ? `` : ''}
` : `
`}
`; }).join(''); } function showAmortisation(loanId) { const loan = (data.loans || []).find(l => l.id === loanId); if (!loan) return; const schedule = getAmortisationSchedule(loan); if (!schedule.length) { alert(loan.type === 'bullet' ? `Bullet repayment due at end of tenure.\nPrincipal: ${fmtINR(loan.principal)}\nInterest: ${fmtINR(loan.totalInterest)}\nTotal due: ${fmtINR(loan.totalPayable)}` : 'No schedule available.'); return; } const rows = schedule.map(s => `${s.month}${s.due}${fmtINR(s.emi)}${fmtINR(s.interest)}${fmtINR(s.principal)}${fmtINR(s.balance)}`).join(''); openGenModal(`Amortisation — ${loan.lender}`, `
${rows}
#Due DateEMIInterestPrincipalBalance
`, null); } // ===== EMI CALCULATOR ===== function computeEMI() { const principal = Number(document.getElementById('emiPrincipal')?.value || 0); const rate = Number(document.getElementById('emiRate')?.value || 0); const tenureRaw = Number(document.getElementById('emiTenure')?.value || 0); const tenureUnit = document.getElementById('emiTenureUnit')?.value || 'months'; const type = document.getElementById('emiType')?.value || 'reducing'; const resultEl = document.getElementById('emiResult'); const schedEl = document.getElementById('emiSchedule'); if (!resultEl || !schedEl) return; if (!principal || !tenureRaw) { resultEl.innerHTML = ''; schedEl.innerHTML = ''; return; } const months = tenureUnit === 'years' ? tenureRaw * 12 : tenureRaw; let emi, totalPayable, totalInterest; if (type === 'reducing') { emi = rate === 0 ? principal / months : calcEMI(principal, rate, months); totalPayable = Math.round(emi * months * 100)/100; } else if (type === 'flat') { emi = (principal + principal * rate/100 * months/12) / months; totalPayable = Math.round(emi * months * 100)/100; } else { emi = (principal + principal * rate/100 * months/12) / months; totalPayable = Math.round(emi * months * 100)/100; } totalInterest = Math.round((totalPayable - principal) * 100)/100; resultEl.innerHTML = `
Monthly EMI${fmtINR(Math.round(emi * 100)/100)}
Principal${fmtINR(principal)}
Total Interest${fmtINR(totalInterest)}
Total Payable${fmtINR(totalPayable)}
Tenure${months} months
Effective Rate (monthly)${(rate/12).toFixed(4)}%
`; // Amortisation if (type !== 'simple') { const r = rate / 100 / 12; let balance = principal; const rows = []; const today = new Date(); for (let i = 1; i <= Math.min(months, 360); i++) { const interest = type === 'reducing' ? Math.round(balance * r * 100)/100 : Math.round(principal * rate/100/12 * 100)/100; const prinPart = Math.min(Math.round((emi - interest) * 100)/100, balance); balance = Math.round((balance - prinPart) * 100)/100; const dueDate = new Date(today); dueDate.setMonth(dueDate.getMonth() + i); rows.push(`${i}${dayKey(dueDate)}${fmtINR(Math.round(emi*100)/100)}${fmtINR(interest)}${fmtINR(prinPart)}${fmtINR(Math.max(balance,0))}`); } schedEl.innerHTML = `${rows.join('')}
#DateEMIInterestPrincipalBalance
`; } else { schedEl.innerHTML = '
Schedule not applicable for Simple Interest type
'; } } function updateSiteProgress(projId, pct) { const p = (data.projects||[]).find(x=>x.id===projId); if (!p) return; p.siteProgress = Number(pct); saveData(); // Live update slider and label without full re-render const sliders = document.querySelectorAll(`input[oninput*="updateSiteProgress(${projId}"]`); sliders.forEach(sl => { sl.value = pct; const siteColor = pct>=100?'var(--accent)':pct>=60?'var(--sky)':pct>=30?'var(--amber)':'var(--rose)'; sl.style.accentColor = siteColor; const card = sl.closest('div[style*="border-radius:12px"]'); if (card) { const bar = card.querySelector('div[style*="transition:width"]'); if (bar) { bar.style.width = pct+'%'; bar.style.background = siteColor; } const pctLabel = card.querySelector('span[style*="font-weight:800"]'); if (pctLabel) { pctLabel.textContent = pct+'%'; pctLabel.style.color = siteColor; } } }); } // ===== TODAY'S COMMAND CENTER ===== function renderTodayCommand() { const el = document.getElementById('todayCommandContent'); if (!el) return; const todayKey = dayKey(new Date()); const todayFmt = new Date().toLocaleDateString('en-IN', { weekday:'long', day:'numeric', month:'long', year:'numeric' }); // --- Data pulls --- const todayTasks = (data.tasks||[]).filter(t => dayKey(t.date)===todayKey && !t.skipped); const doneTasks = todayTasks.filter(t => t.completed); const pendingTasks = todayTasks.filter(t => !t.completed); const urgentTasks = pendingTasks.filter(t => t.urgent); const rescheduled = (data.tasks||[]).filter(t => t.originalDate && dayKey(t.date)===todayKey && dayKey(t.originalDate)!==todayKey); const todayAtt = (data.attendance||[]).filter(a => a.date===todayKey && a.status!=='absent'); const todayFunds = (data.funds||[]).filter(f => f.date===todayKey && f.status!=='paid'); const overdueFunds = (data.funds||[]).filter(f => f.date { const d=scheduleDayKey(s); return d===todayKey; }); const activeProj = (data.projects||[]).filter(p => p.status==='in-progress'); // Workers by project today const workersByProj = {}; todayAtt.forEach(a => { const projId = String(a.projectId||'unassigned'); if (!workersByProj[projId]) workersByProj[projId] = { workers:[], proj:null }; workersByProj[projId].workers.push(a.labourName||'Unknown'); if (!workersByProj[projId].proj && a.projectId) { workersByProj[projId].proj = (data.projects||[]).find(p=>p.id===a.projectId)||null; } }); const totalWorkersToday = todayAtt.length; // Capital needed today const capitalToday = todayFunds.reduce((s,f)=>s+Number(f.amount||0),0); const capitalOverdue = overdueFunds.reduce((s,f)=>s+Number(f.amount||0),0); // Mandatory next actions: pending tasks that are urgent+important const mandatoryActions = pendingTasks.filter(t=>t.urgent&&t.important); el.innerHTML = `
🎯 Today's Command Center
${todayFmt}
Tasks Today
${todayTasks.length}
${doneTasks.length} done · ${pendingTasks.length} pending${urgentTasks.length?' · 🔴 '+urgentTasks.length+' urgent':''}
Workers on Site
${totalWorkersToday}
${Object.keys(workersByProj).length} project${Object.keys(workersByProj).length===1?'':'s'} active today
Capital Needed
${fmtINR(capitalToday+capitalOverdue)}
Today: ${fmtINR(capitalToday)} · Overdue: ${fmtINR(capitalOverdue)}
Active Projects
${activeProj.length}
${todaySched.length} scheduled activities
👷 Workers on Site Today (${totalWorkersToday})
${Object.keys(workersByProj).length ? Object.entries(workersByProj).map(([pid,info])=>`
📁 ${info.proj ? escHtml(projNick(info.proj)) : 'Unassigned'} · ${info.workers.length} worker${info.workers.length===1?'':'s'}
${info.workers.map(w=>`${escHtml(w)}`).join('')}
`).join('') : '
No attendance marked for today yet
'}
⚡ Today's Priorities
${urgentTasks.length ? urgentTasks.map(t=>`
${t.completed?'✓':''}
${escHtml(t.text)}
${t.category?`🏷 ${escHtml(t.category)}`:''}
🔴 Urgent
`).join('') : '
✅ No urgent tasks — all clear
'} ${mandatoryActions.length ? `
⚠ Mandatory Actions to Progress
${mandatoryActions.map(t=>`
→ ${escHtml(t.text)}
`).join('')}
` : ''}
💵 Capital Needs Today
${overdueFunds.length ? `
⚠ ${overdueFunds.length} overdue requirement${overdueFunds.length===1?'':'s'} — ${fmtINR(capitalOverdue)} pending
` : ''} ${[...overdueFunds, ...todayFunds].length ? [...overdueFunds.map(f=>({...f,tag:'⚠ Overdue'})), ...todayFunds.map(f=>({...f,tag:'Today'}))].map(f=>{ const cat = getFundCat(f.cat); return `
${escHtml(f.desc)}
${f.tag} · ${cat.label}${f.vendor?' · 🏪 '+escHtml(f.vendor):''}
${fmtINR(f.amount)}
`}).join('') : '
No capital requirements for today
'}
🗓 Today's Schedule (${todaySched.length})
${todaySched.length ? todaySched.sort((a,b)=>(a.time||'').localeCompare(b.time||'')).map(s=>{ const linked = s.taskId ? (data.tasks||[]).find(t=>t.id===s.taskId) : null; const done = linked?.completed || false; return `
${s.time||'—'}
${escHtml(s.activity)}
${s.endTime?`
Until ${s.endTime}
`:''}
`}).join('') : '
Nothing scheduled for today
'}
🏗 Active Projects — Today's Status
${activeProj.length ? activeProj.map(p=>{ const fc = computeProjectForecast(p); const projWorkers = Object.values(workersByProj).find(w=>w.proj?.id===p.id)?.workers||[]; const projFunds = todayFunds.filter(f=>f.projectId===p.id||f.projectLabel===projNick(p)); const gstR = Number(p.gstRate??18); const netBal = (gstR>0?Math.round(fc.budget/(1+gstR/100)*100)/100:fc.budget) - fc.moneyNeeded; // ── 1. DOCUMENTATION progress ── const allStages = DOC_STAGES || []; const doneStages = allStages.filter(s=>(p.docStatus||{})[s]?.done); const pendingStages= allStages.filter(s=>!(p.docStatus||{})[s]?.done); const nextStage = pendingStages[0]||null; const docPct = allStages.length ? Math.round(doneStages.length/allStages.length*100) : 0; const STAGE_GROUPS = [ { name:'Stage 1 – Estimate to Agreement', stages: ['ESTIMATE PREPARATION','NOTE FILE','AS','AS PROCEEDINGS','TS','WORK ORDER','AGREEMENT'] }, { name:'Stage 2 – Work in Progress', stages: ['WORK IN PROGRESS','WORK COMPLETED','BILL WORKOUT','M BOOK WRITTEN','CHECK & PUTUP','SITE INSPECTION','COMM SIGNED'] }, { name:'Stage 3 – Bill to Cheque', stages: ['BILL PROCESS','ACCOUNT SECTION','CHEQUE/AMOUNT RECEIVED'] } ]; // ── 2. PHYSICAL / SITE progress (stored as p.siteProgress, 0-100) ── const sitePct = Number(p.siteProgress||0); const siteColor = sitePct>=100?'var(--accent)':sitePct>=60?'var(--sky)':sitePct>=30?'var(--amber)':'var(--rose)'; return `
${escHtml(projNick(p))}
${escHtml(p.client||'')}${p.zone?' · '+escHtml(p.zone):''}
${projWorkers.length?`👷 ${projWorkers.length} on site today`:'No attendance today'} ${projFunds.length?`💵 ${fmtINR(projFunds.reduce((s,f)=>s+Number(f.amount||0),0))} needed`:''}
Budget ex.GST
${fmtINR(gstR>0?Math.round(fc.budget/(1+gstR/100)*100)/100:fc.budget)}
To Complete
${fmtINR(fc.moneyNeeded)}
Net Balance
${fmtINR(Math.abs(netBal))}
📋 Documentation ${docPct}%
${doneStages.length} / ${allStages.length} stages done
${STAGE_GROUPS.map(sg=>{ const grpDone = sg.stages.filter(s=>(p.docStatus||{})[s]?.done).length; const grpPct = sg.stages.length ? Math.round(grpDone/sg.stages.length*100) : 0; const grpNext = sg.stages.find(s=>!(p.docStatus||{})[s]?.done); return `
${sg.name} ${grpPct===100?'✓ Done':grpDone+'/'+sg.stages.length}
${grpNext&&grpPct<100?`
→ ${grpNext}
`:''}
`; }).join('')}
🏗 Physical / Site ${sitePct}%
${[0,25,50,75,100].map(v=>``).join('')}
${sitePct>=100?'✅ Site work complete':sitePct>=75?'🔵 Final stage works':sitePct>=50?'🟡 Mid-stage works':sitePct>=25?'🟠 Foundation / early works':'🔴 Not yet started / early stage'}
${projWorkers.length?`
Today: ${projWorkers.map(w=>`${escHtml(w)}`).join(' ')}
`:''}
`}).join('') : '
No projects currently in progress
'}
${rescheduled.length ? `
↷ Carried Forward Tasks (${rescheduled.length} moved here today)
${rescheduled.map(t=>{ const origDate = t.originalDate ? new Date(t.originalDate+'T00:00:00').toLocaleDateString('en-IN',{day:'numeric',month:'short'}) : '—'; const si = taskStatusInfo(t); return `
${t.completed?'✓':''}
${escHtml(t.text)}
Originally due ${origDate} · Moved ×${t.carryCount||1}
${si.label}
`;}).join('')}
` : ''}`; } let repayStrategy = 'snowball'; function setRepayStrategy(strategy) { repayStrategy = strategy; document.querySelectorAll('#repayStrategyGroup .filter-btn').forEach(b => b.classList.toggle('active', b.dataset.strategy === strategy)); renderRepayPlanner(); } function renderRepayPlanner() { const kpiEl = document.getElementById('repayPlannerKPIs'); const contentEl = document.getElementById('repayPlannerContent'); if (!contentEl) return; const loans = (data.loans || []).filter(l => l.status !== 'closed'); if (!loans.length) { if (kpiEl) kpiEl.innerHTML = ''; contentEl.innerHTML = '
No active loans to plan. Add loans in the Borrow / Debt tab first.
'; return; } // Sort by strategy let sorted = [...loans]; if (repayStrategy === 'snowball') { // Smallest balance first — quick wins to build momentum sorted.sort((a, b) => getLoanBalance(a) - getLoanBalance(b)); } else if (repayStrategy === 'avalanche') { // Highest interest rate first — saves most money sorted.sort((a, b) => (b.rate || 0) - (a.rate || 0)); } // custom: order as entered const totalDebt = sorted.reduce((s, l) => s + getLoanBalance(l), 0); const totalMonthlyEMI = sorted.reduce((s, l) => s + (l.emi || 0), 0); const totalInterestLeft = sorted.reduce((s, l) => { const balance = getLoanBalance(l); const mths = l.tenure || 12; const r = (l.rate || 0) / 100 / 12; if (!r) return s + 0; const emiCalc = balance * r * Math.pow(1+r,mths) / (Math.pow(1+r,mths)-1); return s + Math.max(emiCalc * mths - balance, 0); }, 0); // Estimate total debt-free date (months until all closed) const longestMonths = sorted.reduce((mx, l) => { const bal = getLoanBalance(l); const emi = l.emi || 0; if (!emi) return mx; const months = Math.ceil(bal / emi); return Math.max(mx, months); }, 0); const debtFreeDate = new Date(); debtFreeDate.setMonth(debtFreeDate.getMonth() + longestMonths); if (kpiEl) kpiEl.innerHTML = `
Total Debt Remaining
${fmtINR(totalDebt)}
${loans.length} active loan${loans.length===1?'':'s'}
Monthly EMI Load
${fmtINR(totalMonthlyEMI)}
Total monthly outflow
Est. Interest Remaining
${fmtINR(totalInterestLeft)}
If paid as scheduled
Est. Debt-Free Date
${debtFreeDate.toLocaleDateString('en-IN',{month:'short',year:'numeric'})}
~${longestMonths} months
`; const PURPOSE_ICON = {jewel:'💍',gold:'🥇',home:'🏠',car:'🚗',business:'💼',project:'🏗',equipment:'⚙️',personal:'👤',overdraft:'🏦',mortgage:'🏢',chit:'🤝',friend:'👥',other:'📦'}; const stratLabel = { snowball: 'Pay off smallest balance first → roll that freed EMI into the next', avalanche: 'Pay off highest interest rate first → minimises total interest paid', custom: 'Loans in the order they were entered' }; contentEl.innerHTML = `
💡 Strategy: ${stratLabel[repayStrategy]}
${sorted.map((loan, idx) => { const balance = getLoanBalance(loan); const totalPrincipalPaid = (loan.repayments||[]).reduce((s,r)=>s+Number(r.amount||0),0); const totalInterestPaid = getTotalInterestPaid(loan); const pct = Math.min(Math.round(totalPrincipalPaid / loan.principal * 100), 100); const mthsLeft = loan.emi ? Math.ceil(balance / loan.emi) : '?'; const closeDate = loan.emi ? (() => { const d = new Date(); d.setMonth(d.getMonth() + (typeof mthsLeft === 'number' ? mthsLeft : 0)); return d.toLocaleDateString('en-IN', {month:'short', year:'numeric'}); })() : '—'; // Snowball: after this loan is paid, roll its EMI to the next const rolledEMI = repayStrategy === 'snowball' && idx > 0 ? sorted.slice(0, idx).reduce((s, l) => s + (l.emi || 0), 0) : 0; return `
${PURPOSE_ICON[loan.purpose]||'💰'}
#${idx+1} · ${escHtml(loan.lender)} ${idx===0?'🎯 Pay First':''} ${loan.rate}% p.a. Balance: ${fmtINR(balance)}
Monthly EMI
${loan.emi?fmtINR(loan.emi):'—'}
Est. Close
${closeDate} (~${mthsLeft}m)
Interest Paid
${fmtINR(totalInterestPaid)}
${rolledEMI > 0 ? `
Rolled EMI Added
+${fmtINR(rolledEMI)}
` : ''}
${pct}% principal repaid · ${fmtINR(totalPrincipalPaid)} paid of ${fmtINR(loan.principal)}
`; }).join('')} ${repayStrategy === 'snowball' ? `
❄ Snowball Roll-Over Plan: Once Loan #1 is closed, add its ${fmtINR(sorted[0]?.emi||0)} EMI to Loan #2's payment. When Loan #2 closes, roll both into Loan #3 — accelerating payoff with each closure.
` : repayStrategy === 'avalanche' ? `
🌊 Avalanche Advantage: By tackling the highest interest loan first, you minimise total interest paid across all loans. Slower initial wins but maximum savings.
` : ''}`; } // ===== BALANCE VIEWS ===== let balView = 'overview'; function setBalView(view) { balView = view; document.querySelectorAll('#balViewGroup .filter-btn').forEach(b => b.classList.toggle('active', b.dataset.bv === view)); renderBalanceViews(); } function renderBalanceViews() { // Resolve the correct container: // - If Overview > Balance tab is visible, use the one inside overviewBalancesView // - If Money Management > Balance Views tab is visible, use fundsBalanceViewContent const fundsBalEl = document.getElementById('fundsBalanceViewContent'); const fundsTabVisible = document.getElementById('fundsTabBalance')?.style.display !== 'none'; const ovBalVisible = document.getElementById('overviewBalancesView')?.style.display !== 'none'; const el = fundsTabVisible ? fundsBalEl : ovBalVisible ? document.getElementById('overviewBalancesView')?.querySelector('#balanceViewContent') : fundsBalEl || document.getElementById('balanceViewContent'); const compSel = fundsTabVisible ? document.getElementById('fundsBalCompanyFilter') : document.getElementById('balCompanyFilter'); if (!el) return; // Populate company filter if (compSel) { const companies = Array.from(new Set((data.projects||[]).map(p=>p.company||'Unassigned').filter(Boolean))).sort(); const prev = compSel.value; compSel.innerHTML = '' + companies.map(c=>``).join(''); compSel.value = companies.includes(prev) ? prev : 'all'; } const compFilter = compSel?.value || 'all'; // Filtered projects const projects = (data.projects||[]).filter(p => compFilter==='all' || (p.company||'Unassigned')===compFilter); const loans = (data.loans||[]).filter(l => l.status !== 'closed'); const funds = (data.funds||[]).filter(f => f.status !== 'paid'); // Core numbers const totalBudgetGST = projects.reduce((s,p) => s+(Number(p.estimate)||0), 0); const totalBudgetExGST = projects.reduce((s,p) => { const est = Number(p.estimate)||0; const r = Number(p.gstRate??18); return s + (r>0 ? Math.round(est/(1+r/100)*100)/100 : est); }, 0); const totalMoneyNeeded = projects.reduce((s,p) => s+computeProjectForecast(p).moneyNeeded, 0); const totalDebt = loans.reduce((s,l) => s+getLoanBalance(l), 0); const monthlyEMI = loans.reduce((s,l) => s+(l.emi||0), 0); const fundsPending = funds.reduce((s,f) => s+Number(f.amount||0), 0); const projNetBalance = totalBudgetExGST - totalMoneyNeeded; const netAfterDebt = projNetBalance - totalDebt; const netAfterAll = projNetBalance - totalDebt - fundsPending; const totalSpent = projects.reduce((s,p) => s+computeProjectForecast(p).spent, 0); const card = (label, val, sub, color='var(--text-primary)', bgClass='') => `
${label}
${fmtINR(Math.abs(val))}
${val<0?'▼ Shortfall · ':val>0?'▲ Surplus · ':''}${sub}
`; const row = (label, val, positive=true) => `
${label} ${positive?'':'−'}${fmtINR(Math.abs(val))}
`; if (balView === 'overview') { el.innerHTML = `
${card('Project Budget (ex. GST)', totalBudgetExGST, `${projects.length} projects`, 'var(--sky)')} ${card('Money to Complete', -totalMoneyNeeded, 'Forecast cost remaining', 'var(--amber)')} ${card('Active Debt (Loans)', -totalDebt, `${loans.length} active loans`, 'var(--rose)')} ${card('Funds Pending (Capital)', -fundsPending, `${funds.length} requirements`, 'var(--violet)')}
💡 Net Position Summary
${row('Project Budget (ex. GST)', totalBudgetExGST)} ${row('− Money to Complete', totalMoneyNeeded, false)}
= Project Net Balance ${projNetBalance<0?'−':''}${fmtINR(Math.abs(projNetBalance))}
${row('− Total Active Debt', totalDebt, false)}
= Net Balance After Debt ${netAfterDebt<0?'−':''}${fmtINR(Math.abs(netAfterDebt))}
${row('− Pending Capital Requirements', fundsPending, false)}
= Net Free Capital Position ${netAfterAll<0?'−':''}${fmtINR(Math.abs(netAfterAll))}
`; } else if (balView === 'projdebt') { el.innerHTML = `
🏗 Project Financials
${row('Total Budget (with GST)', totalBudgetGST)} ${row('Total Budget (ex. GST)', totalBudgetExGST)} ${row('Total Spent So Far', totalSpent, false)} ${row('Money to Complete', totalMoneyNeeded, false)}
Project Net Balance (ex. GST) ${fmtINR(Math.abs(projNetBalance))}
🏦 Debt Summary
${loans.map(l=>`
${escHtml(l.lender)} ${l.rate}% p.a. ${fmtINR(getLoanBalance(l))}
`).join('')} ${loans.length===0?'
No active loans
':''}
Total Debt ${fmtINR(totalDebt)}
Net Balance − Debt ${fmtINR(Math.abs(netAfterDebt))}
🧮 Debt-to-Project Ratio
Debt / Budget
${totalBudgetGST?Math.round(totalDebt/totalBudgetGST*100):0}%
Monthly EMI Load
${fmtINR(monthlyEMI)}
Debt Coverage
${totalDebt?Math.round(projNetBalance/totalDebt*100):100}%
`; } else if (balView === 'cashflow') { const todayFunds = funds.filter(f => f.date === dayKey(new Date())); const overdueFunds = funds.filter(f => f.date < dayKey(new Date())); const incomePending = (data.accounts||[]).filter(e => ACCOUNT_TYPES[e.type]?.direction==='in' && e.status==='pending').reduce((s,e)=>s+Number(e.amount||0),0); const payablePending = (data.accounts||[]).filter(e => ACCOUNT_TYPES[e.type]?.direction==='out' && e.status==='pending').reduce((s,e)=>s+Number(e.amount||0),0); el.innerHTML = `
${card('Receivable (Accounts)', incomePending, 'Pending income from accounts', 'var(--accent)')} ${card('Payable (Accounts)', -payablePending, 'Pending payments in accounts', 'var(--rose)')} ${card('Capital Needed Today', todayFunds.reduce((s,f)=>s+Number(f.amount||0),0), `${todayFunds.length} items`, 'var(--amber)')} ${card('Overdue Capital', overdueFunds.reduce((s,f)=>s+Number(f.amount||0),0), `${overdueFunds.length} overdue`, 'var(--rose)')}
💸 Net Cash Flow Position
${row('Receivable (pending income)', incomePending)} ${row('− Payable (pending payments)', payablePending, false)} ${row('− Monthly EMI Obligation', monthlyEMI, false)} ${row('− Capital Requirements (total pending)', fundsPending, false)}
Net Cash Flow Position ${fmtINR(Math.abs(incomePending-payablePending-monthlyEMI-fundsPending))}
`; } else if (balView === 'byproject') { el.innerHTML = projects.length ? `
${projects.map(p => { const fc = computeProjectForecast(p); const gstR = Number(p.gstRate??18); const exGST = gstR>0 ? Math.round(fc.budget/(1+gstR/100)*100)/100 : fc.budget; const netBal = exGST - fc.moneyNeeded; return `
${escHtml(projNick(p))}
${escHtml(p.client||'')} · ${escHtml(p.company||'')} · GST ${gstR}%
${fc.overBudget?'Over Budget':fc.progress+'% complete'}
Budget (incl.GST)
${fmtINR(fc.budget)}
Budget (ex.GST)
${fmtINR(exGST)}
Spent
${fmtINR(fc.spent)}
To Complete
${fmtINR(fc.moneyNeeded)}
Net Balance
${netBal<0?'−':''}${fmtINR(Math.abs(netBal))}
`; }).join('')}
` : '
No projects found for this filter.
'; } } function renderFunds() { data.funds = Array.isArray(data.funds) ? data.funds : []; data.loans = Array.isArray(data.loans) ? data.loans : []; const todayKey = dayKey(new Date()); const tmrKey = (() => { const d = new Date(); d.setDate(d.getDate()+1); return dayKey(d); })(); // Populate fund category datalist with default + custom categories const fundCatDl = document.getElementById('fundCategoryList'); if (fundCatDl) { const FUND_DEFAULTS = ['Wages','Material','Transport','Equipment','Subcontractor','Utility','Miscellaneous']; const allFundCats = Array.from(new Set([ ...FUND_DEFAULTS, ...(Array.isArray(data.fundCategories) ? data.fundCategories : []), ...(data.funds || []).map(f => f.cat).filter(Boolean) ])).sort(); fundCatDl.innerHTML = allFundCats.map(c => ``).join(''); } // Populate vendor datalist from labours + vendors + past fund entries { const vendorDl = document.getElementById('fundVendorList'); if (vendorDl) { const allVendors = Array.from(new Set([ ...(data.vendors||[]).map(v=>v.name).filter(Boolean), ...(data.labours||[]).map(l=>l.name).filter(Boolean), ...(data.funds||[]).map(f=>f.vendor).filter(Boolean) ])).sort(); vendorDl.innerHTML = allVendors.map(v=>``).join(''); } } const projDl = document.getElementById('fundProjectList'); if (projDl) { projDl.innerHTML = (data.projects || []).map(p => `` ).join(''); } const projSel = document.getElementById('fundProjectLink'); if (projSel) { const prev = projSel.value; projSel.innerHTML = '' + (data.projects||[]).map(p => ``).join(''); } // (vendor datalist populated above) // Default fund date to today if empty const fdateEl = document.getElementById('fundDate'); if (fdateEl && !fdateEl.value) fdateEl.value = todayKey; // KPIs const all = data.funds; const pending = all.filter(f => f.status !== 'paid'); const paid = all.filter(f => f.status === 'paid'); const todayFunds = pending.filter(f => f.date === todayKey); const tmrFunds = pending.filter(f => f.date === tmrKey); const overdueFunds = pending.filter(f => f.date < todayKey); const totalPending = pending.reduce((s, f) => s + Number(f.amount||0), 0); const todayAmt = todayFunds.reduce((s, f) => s + Number(f.amount||0), 0); const kpiEl = document.getElementById('fundsKPIs'); if (kpiEl) kpiEl.innerHTML = `
Total Pending
${fmtINR(totalPending)}
${pending.length} items pending
Needed Today
${fmtINR(todayAmt)}
${todayFunds.length} items
Needed Tomorrow
${fmtINR(tmrFunds.reduce((s,f)=>s+Number(f.amount||0),0))}
${tmrFunds.length} items
Overdue
${fmtINR(overdueFunds.reduce((s,f)=>s+Number(f.amount||0),0))}
${overdueFunds.length} items
`; // Urgency strip — today + tomorrow const urgentEl = document.getElementById('fundsUrgentStrip'); if (urgentEl) { const urgentFunds = [...overdueFunds.map(f=>({...f,urgentLabel:'⚠️ Overdue'})), ...todayFunds.map(f=>({...f,urgentLabel:'🔴 Today'})), ...tmrFunds.map(f=>({...f,urgentLabel:'🟠 Tomorrow'}))]; urgentEl.innerHTML = urgentFunds.length ? `
${urgentFunds.map(f => { const cat = getFundCat(f.cat); const proj = f.projectId ? data.projects.find(p=>p.id===Number(f.projectId)) : null; const projDisplay = proj ? projNick(proj) : (f.projectLabel || null); return `
${f.urgentLabel} · ${cat.label}
${escHtml(f.desc)}
${f.vendor ? `
🏪 ${escHtml(f.vendor)}
` : ''}
${fmtINR(f.amount)}
${projDisplay ? `
📁 ${escHtml(projDisplay)}
` : ''}
`; }).join('')}
` : ''; } // Full list const listEl = document.getElementById('fundList'); if (!listEl) return; let filtered = [...data.funds]; if (fundFilter === 'pending') filtered = filtered.filter(f => f.status !== 'paid'); else if (fundFilter === 'paid') filtered = filtered.filter(f => f.status === 'paid'); filtered.sort((a,b) => a.date.localeCompare(b.date)); // Group by date const byDate = {}; filtered.forEach(f => { if (!byDate[f.date]) byDate[f.date] = []; byDate[f.date].push(f); }); listEl.innerHTML = Object.keys(byDate).length ? Object.keys(byDate).sort().map(dateKey => { const dayFunds = byDate[dateKey]; const dateObj = new Date(dateKey + 'T00:00:00'); const isToday2 = dateKey === todayKey; const isPast2 = dateKey < todayKey; const dayLabel = isToday2 ? 'Today' : dateObj.toLocaleDateString('en-IN', { weekday:'short', day:'numeric', month:'short' }); const dayTotal = dayFunds.reduce((s,f)=>s+Number(f.amount||0),0); return `
${dayLabel}${isPast2&&!isToday2?' ⚠️':''} ${fmtINR(dayTotal)}
${dayFunds.map(f => { const cat = getFundCat(f.cat); const proj = f.projectId ? data.projects.find(p=>p.id===Number(f.projectId)) : null; const projDisplay = proj ? projNick(proj) : (f.projectLabel || null); const isPaid = f.status === 'paid'; const prioColor = f.priority === 'high' ? 'var(--rose)' : f.priority === 'medium' ? 'var(--amber)' : 'var(--text-muted)'; return `
${escHtml(f.desc)} ${cat.label} ${f.vendor?`🏪 ${escHtml(f.vendor)}`:''} ${projDisplay?`📁 ${escHtml(projDisplay)}`:''} ● ${f.priority}
${isPaid?'✓ Paid · ':''}${fmtINR(f.amount)}
${projDisplay||f.fundRef?`
${projDisplay?`📁 ${escHtml(projDisplay)}`:''} ${f.fundRef?`📋 Task linked`:''}
`:''}
`; }).join('')}
`; }).join('') : `
No fund requirements yet — add money needed for wages, materials, transport etc. above
`; } // ===== DASHBOARD CHART TOGGLE ===== function toggleDashboardCharts() { const grid = document.getElementById('dashChartGrid'); const btn = document.getElementById('chartToggleBtn'); if (!grid) return; const hidden = grid.style.display === 'none'; grid.style.display = hidden ? '' : 'none'; if (btn) btn.textContent = hidden ? '⊟ Hide Charts' : '⊞ Show Charts'; try { localStorage.setItem('mydas_chartsHidden', hidden ? 'no' : 'yes'); } catch(e){} } // ===== LEADS MODULE ===== let leadFilter = 'all'; const LEAD_STATUS = { new: { label: '🔵 New', color: 'var(--sky)', pill: 'pill-sky' }, contacted: { label: '📞 Contacted', color: 'var(--violet)', pill: 'pill-violet' }, proposal: { label: '📄 Proposal Sent', color: 'var(--amber)', pill: 'pill-amber' }, negotiation: { label: '🤝 Negotiation', color: '#06b6d4', pill: 'pill-sky' }, won: { label: '✅ Won', color: 'var(--accent)', pill: 'pill-green' }, lost: { label: '❌ Lost', color: 'var(--rose)', pill: 'pill-rose' } }; function setLeadFilter(f) { leadFilter = f; document.querySelectorAll('#leadStatusFilter .filter-btn').forEach(b => b.classList.toggle('active', b.dataset.ls === f)); renderLeads(); } function addLead() { const name = document.getElementById('leadName')?.value.trim(); const client = document.getElementById('leadClient')?.value.trim(); const phone = document.getElementById('leadPhone')?.value.trim(); const location = document.getElementById('leadLocation')?.value.trim(); const type = document.getElementById('leadType')?.value || ''; const estimate = Number(document.getElementById('leadEstimate')?.value || 0); const date = document.getElementById('leadDate')?.value || dayKey(new Date()); const status = document.getElementById('leadStatus')?.value || 'new'; const note = document.getElementById('leadNote')?.value.trim(); if (!name) return; data.leads = Array.isArray(data.leads) ? data.leads : []; data.leads.push({ id: Date.now(), name, client, phone, location, type, estimate, date, status, note, createdAt: dayKey(new Date()), history: [{ date: dayKey(new Date()), status, note: 'Lead created' }] }); ['leadName','leadClient','leadPhone','leadLocation','leadEstimate','leadNote'].forEach(id => { const el=document.getElementById(id); if(el) el.value=''; }); saveData(); renderLeads(); } function updateLeadStatus(id, newStatus) { const lead = (data.leads||[]).find(l=>l.id===id); if (!lead) return; lead.status = newStatus; lead.history = lead.history || []; lead.history.push({ date: dayKey(new Date()), status: newStatus, note: `Status → ${LEAD_STATUS[newStatus]?.label || newStatus}` }); saveData(); renderLeads(); } function delLead(id) { data.leads = (data.leads||[]).filter(l=>l.id!==id); saveData(); renderLeads(); } function convertLeadToProject(id) { const lead = (data.leads||[]).find(l=>l.id===id); if (!lead) return; // Pre-fill project form and jump to Projects tab setActiveTab('projects'); setTimeout(() => { const nameEl = document.getElementById('projName'); const clientEl = document.getElementById('projClient'); const zoneEl = document.getElementById('projZone'); const estEl = document.getElementById('projEstimate'); const catEl = document.getElementById('projCategory'); const nickEl = document.getElementById('projNickname'); if (nameEl) nameEl.value = lead.name; if (clientEl) clientEl.value = lead.client || ''; if (zoneEl) zoneEl.value = lead.location || ''; if (estEl) estEl.value = lead.estimate || ''; if (catEl && lead.type) catEl.value = lead.type; if (nickEl) nickEl.value = lead.client ? lead.client.slice(0,10).toUpperCase() : ''; // Mark lead as won lead.status = 'won'; lead.history = lead.history || []; lead.history.push({ date: dayKey(new Date()), status: 'won', note: 'Converted to project' }); saveData(); showToast('✅ Lead pre-filled in Projects form — review and click + Add Project'); }, 400); } function editLead(id) { const lead = (data.leads||[]).find(l=>l.id===id); if (!lead) return; const typeOpts = ['','residential','commercial','government','infrastructure','maintenance','other'].map(v=> ``).join(''); openGenModal(`✎ Edit Lead — ${escHtml(lead.name)}`, `
`, () => { lead.name = document.getElementById('elName').value.trim() || lead.name; lead.client = document.getElementById('elClient').value.trim(); lead.phone = document.getElementById('elPhone').value.trim(); lead.location = document.getElementById('elLoc').value.trim(); lead.estimate = Number(document.getElementById('elEst').value||0); const ns = document.getElementById('elStat').value; if (ns !== lead.status) { lead.status=ns; (lead.history=lead.history||[]).push({date:dayKey(new Date()),status:ns,note:'Status updated'}); } lead.note = document.getElementById('elNote').value.trim(); saveData(); renderLeads(); closeGenModal(); }); } function renderLeads() { data.leads = Array.isArray(data.leads) ? data.leads : []; const leads = data.leads; // Populate client datalist const dl = document.getElementById('leadClientList'); if (dl) { const clients = Array.from(new Set([...(data.projects||[]).map(p=>p.client),...leads.map(l=>l.client)].filter(Boolean))).sort(); dl.innerHTML = clients.map(c=>``).join(''); } // Set today's date default const dateEl = document.getElementById('leadDate'); if (dateEl && !dateEl.value) dateEl.value = dayKey(new Date()); // KPIs const total = leads.length; const won = leads.filter(l=>l.status==='won').length; const active = leads.filter(l=>!['won','lost'].includes(l.status)).length; const totalEst = leads.filter(l=>l.status!=='lost').reduce((s,l)=>s+Number(l.estimate||0),0); const wonEst = leads.filter(l=>l.status==='won').reduce((s,l)=>s+Number(l.estimate||0),0); const kpiEl = document.getElementById('leadsKPIs'); if (kpiEl) kpiEl.innerHTML = `
Total Leads
${total}
${active} active · ${won} won
Pipeline Value
${fmtINR(totalEst)}
Excl. lost leads
Won Value
${fmtINR(wonEst)}
${won} leads won
Win Rate
${total?Math.round(won/total*100):0}%
Of all leads
`; // Filter list let filtered = leadFilter === 'all' ? leads : leads.filter(l=>l.status===leadFilter); filtered = [...filtered].sort((a,b)=>b.id-a.id); const listEl = document.getElementById('leadsList'); if (!listEl) return; listEl.innerHTML = filtered.length ? filtered.map(lead=>{ const st = LEAD_STATUS[lead.status] || LEAD_STATUS.new; const isWon = lead.status === 'won'; const isLost = lead.status === 'lost'; return `
${escHtml(lead.name)}
${lead.client?`👤 ${escHtml(lead.client)}`:''} ${lead.phone?`· 📞 ${escHtml(lead.phone)}`:''} ${lead.location?`· 📍 ${escHtml(lead.location)}`:''}
Added ${lead.date||lead.createdAt||''}${lead.type?` · ${lead.type}`:''}
${st.label} ${lead.estimate?`${fmtINR(lead.estimate)}`:''}
${lead.note?`
💬 ${escHtml(lead.note)}
`:''}
${Object.entries(LEAD_STATUS).map(([k,v])=>` `).join('')}
${!isWon && !isLost ? `` : ''} ${isWon ? `` : ''}
`; }).join('') : `
No leads yet — add prospects above and track them through the pipeline to project conversion
`; } // ===== GLOBAL TAB NAVIGATION HELPER ===== let navHistory = []; let currentTab = 'overview'; function setActiveTab(tabId) { const tabMap = { 'leads': 'leads', 'dailyTasks': 'tasks', 'labourSection': 'labour', 'overview': 'overview', 'funds': 'funds', 'process': 'process', 'projects': 'projects', 'schedule': 'schedule', 'accounts': 'accounts', 'finance': 'finance', 'reports': 'reports', 'analytics': 'analytics', 'material': 'material', 'habits': 'habits', 'routines': 'routines', 'gst': 'gst', 'tds': 'tds', 'aop': 'aop', 'amj': 'amj', 'jas': 'jas', 'ndj': 'ndj', 'jfm': 'jfm', 'sop': 'sop', 'personal': 'personal', 'personalemi': 'personalemi', 'personalspend': 'personalspend', 'personalnet': 'personalnet' }; const realTab = tabMap[tabId] || tabId; if (realTab !== currentTab) { navHistory.push(currentTab); if (navHistory.length > 20) navHistory.shift(); } const navItem = document.querySelector(`.nav-item[data-tab="${realTab}"]`); if (navItem) navItem.click(); updateBackBtn(); } function goBack() { if (!navHistory.length) return; const prev = navHistory.pop(); const navItem = document.querySelector(`.nav-item[data-tab="${prev}"]`); if (navItem) navItem.click(); updateBackBtn(); } function updateBackBtn() { const btn = document.getElementById('backBtn'); if (btn) btn.style.display = navHistory.length > 0 ? '' : 'none'; } // Hook into the nav-item click handler to track current tab function trackNavClick(tab) { if (tab !== currentTab) currentTab = tab; updateBackBtn(); } function restoreChartToggleState() { try { const hidden = localStorage.getItem('mydas_chartsHidden') === 'yes'; const grid = document.getElementById('dashChartGrid'); const btn = document.getElementById('chartToggleBtn'); if (hidden && grid) { grid.style.display = 'none'; if (btn) btn.textContent = '⊞ Show Charts'; } } catch(e){} } function triggerImport() { document.getElementById('importFileInput')?.click(); } function handleImportFile(input) { const file = input.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = async function(e) { try { const imported = JSON.parse(e.target.result); if (!imported || typeof imported !== 'object') throw new Error('Invalid file'); const keys = ['projects','labours','vendors','subcontractors','attendance','accounts','funds','loans','assets','tasks','invoices','quotations','purchaseOrders']; const found = keys.filter(k => Array.isArray(imported[k]) && imported[k].length > 0); if (!found.length) { alert('File does not look like a MYDAS backup'); return; } if (!confirm('Restore from backup?\n\nThis will MERGE the backup data into current data.\nFound: ' + found.join(', '))) return; keys.forEach(k => { if (!Array.isArray(imported[k])) return; data[k] = data[k] || []; const ids = new Set(data[k].map(x=>x.id)); imported[k].forEach(item => { if (item.id && !ids.has(item.id)) data[k].push(item); }); }); ['personalBudget','profitFirst','planning','finance','theme'].forEach(k => { if (imported[k] && !data[k]) data[k] = imported[k]; }); await saveData(); renderAll(); alert('Restored! ' + found.map(k=>k+':'+imported[k].length).join(', ')); } catch(err) { alert('Import failed: ' + err.message); } }; reader.readAsText(file); input.value = ''; } function exportData() { const blob = new Blob([JSON.stringify(data,null,2)], {type:'application/json'}); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'nexus-data-'+new Date().toLocaleDateString()+'.json'; a.click(); } document.getElementById('editModal').addEventListener('click', e => { if (e.target.id==='editModal') closeModal(); }); document.getElementById('genModal').addEventListener('click', e => { if (e.target.id==='genModal') closeGenModal(); }); document.getElementById('carryForwardModal').addEventListener('click', e => { if (e.target.id==='carryForwardModal') closeCarryForward(); }); document.getElementById('schedEditModal').addEventListener('click', e => { if (e.target.id==='schedEditModal') closeSchedEditModal(); }); document.getElementById('planTaskModal').addEventListener('click', e => { if (e.target.id==='planTaskModal') closePlanTaskModal(); }); document.getElementById('reminderSetModal').addEventListener('click', e => { if (e.target.id==='reminderSetModal') closeReminderModal(); }); window.addEventListener('load', init);
00:00
Focus Timer
Running