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
Entry day:
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.

✓ Tasks
◎ Habits
↻ Routines
+ 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
`); w.document.close(); setTimeout(()=>{ try{ w.focus(); w.print(); }catch(e){} }, 500); } // Calendar-view options (cumulative toggles + layout), persisted in-session function hrpCalSet(k, v) { window._hrpCal = Object.assign({cumDays:false,cumSalary:false,balance:false,layout:'1'}, window._hrpCal||{}); window._hrpCal[k] = v; if (k==='layout' && v==='3' && !window._hrpCal.month) window._hrpCal.month = null; renderHRPayable(); } function hrpCalMonthShift(n) { const o = window._hrpCal = Object.assign({layout:'3'}, window._hrpCal||{}); let base = o.month ? new Date(o.month+'-01T00:00:00') : new Date(); base.setMonth(base.getMonth()+n); o.month = base.getFullYear()+'-'+String(base.getMonth()+1).padStart(2,'0'); renderHRPayable(); } // Monthly calendar view of a worker's attendance — with present shifts, payments/advances, // optional cumulative days / salary / balance-as-on-date, and a compact 3-month layout. function hrpBreakdownCalendarHTML(entries, workerName) { const o = Object.assign({cumDays:false,cumSalary:false,balance:false,layout:'1'}, window._hrpCal||{}); // aggregate attendance per date (split day merges its shifts) const attByDate = {}; (entries||[]).forEach(e => { const d=e.date; if(!d) return; if(!attByDate[d]) attByDate[d]={mult:0,wage:0,statuses:[],paid:true}; attByDate[d].mult+=Number(e.mult)||1; attByDate[d].wage+=Number(e.wage)||0; attByDate[d].statuses.push(e.status); if(!e.paid) attByDate[d].paid=false; }); // payments (received) & advances per date for this worker const payByDate = {}; (data.labourPayments||[]).filter(p=>p.labourName===workerName).forEach(p=>{ const d=p.date; if(!d) return; if(!payByDate[d]) payByDate[d]={total:0,advance:0}; payByDate[d].total+=Number(p.amount)||0; if((p.purpose||'')==='advance') payByDate[d].advance+=Number(p.amount)||0; }); // running timeline (cumulative days / wages / paid → balance as-on-date) const allDates=[...new Set([...Object.keys(attByDate),...Object.keys(payByDate)])].sort(); let cD=0,cW=0,cP=0; const run={}; allDates.forEach(d=>{ const a=attByDate[d]; if(a && a.statuses.some(s=>s!=='absent')){cD++; cW+=a.wage;} const p=payByDate[d]; if(p) cP+=p.total; run[d]={cD,cW,cP,bal:cW-cP}; }); if(!allDates.length) return '
No attendance for this period
'; const col = st => st==='full'?'var(--accent)': st==='half'?'var(--amber)': (st==='overtime'||st==='night')?'var(--violet)': st==='absent'?'var(--rose)':'var(--sky)'; const bgc = st => st==='full'?'rgba(48,209,88,0.15)': st==='half'?'rgba(255,159,10,0.15)': (st==='overtime'||st==='night')?'rgba(191,90,242,0.15)': st==='absent'?'rgba(255,69,58,0.13)':'rgba(10,132,255,0.08)'; const fmtk = n => { n=Math.round(n); return Math.abs(n)>=1000 ? '₹'+(n/1000).toFixed(1)+'k' : '₹'+n; }; const renderMonth = (mk, compact) => { const [y,m]=mk.split('-').map(Number); const first=new Date(y,m-1,1), dim=new Date(y,m,0).getDate(), startDow=first.getDay(); const mDates=allDates.filter(d=>d.slice(0,7)===mk); const mDays=mDates.filter(d=>attByDate[d]&&attByDate[d].statuses.some(s=>s!=='absent')).length; const mShifts=Math.round(mDates.reduce((s,d)=>s+((attByDate[d]&&attByDate[d].statuses.some(x=>x!=='absent'))?attByDate[d].mult:0),0)*100)/100; const mRecv=mDates.reduce((s,d)=>s+((payByDate[d]&&payByDate[d].total)||0),0); const mAdv=mDates.reduce((s,d)=>s+((payByDate[d]&&payByDate[d].advance)||0),0); const pad=compact?'3px':'4px', dFont=compact?'9.5px':'11px', gap=compact?'2px':'4px', rad=compact?'6px':'8px'; let cells=''; for(let i=0;i${day}`; continue; } const present=a && a.statuses.some(s=>s!=='absent'); const st = a ? (a.statuses.find(s=>s!=='absent')||'absent') : 'pay'; const tag = !a ? '💰' : !present ? 'A' : a.mult>1 ? a.mult+'×' : (st==='half'?'½':'✓'); let extra=''; if(!compact){ if(p) extra += `
${p.advance>0?'adv ':'✓₹'}${Math.round(p.total).toLocaleString('en-IN')}
`; const parts=[]; if(r && o.cumDays) parts.push(`Σ${r.cD}d`); if(r && o.cumSalary) parts.push(`Σ${fmtk(r.cW)}`); if(r && o.balance) parts.push(`${fmtk(r.bal)}`); if(parts.length) extra += `
${parts.join('
')}
`; } else if(p){ extra = `
`; } cells+=`
${day}${tag}
${extra}
`; } const WD = compact ? ['S','M','T','W','T','F','S'] : ['Sun','Mon','Tue','Wed','Thu','Fri','Sat']; return `
${first.toLocaleDateString('en-IN',{month:compact?'short':'long',year:'numeric'})}
${mDays}d · ${mShifts}sh${mRecv?' · rcvd '+fmtINR(mRecv):''}${mAdv?' · adv '+fmtINR(mAdv):''}
${WD.map(w=>`
${w}
`).join('')} ${cells}
`; }; // ── Controls: cumulative toggles + 1/3-month layout ── const cb=(k,l)=>``; const lb=(v,l)=>``; const controls=`
Overlay: ${cb('cumDays','Σ Days')} ${cb('cumSalary','Σ Salary')} ${cb('balance','Balance as-on-date')} ${lb('1','1 Month')}${lb('3','3 Months')}
`; const monthsWithData=[...new Set(allDates.map(d=>d.slice(0,7)))].sort(); let body=''; if(o.layout==='3'){ const focus=o.month||monthsWithData[monthsWithData.length-1]; const fd=new Date(focus+'-01T00:00:00'); const mkStr=d=>d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0'); const pv=new Date(fd); pv.setMonth(fd.getMonth()-1); const nx=new Date(fd); nx.setMonth(fd.getMonth()+1); const nav=`
${fd.toLocaleDateString('en-IN',{month:'long',year:'numeric'})} · centre
`; body=nav+`
${renderMonth(mkStr(pv),true)}${renderMonth(mkStr(fd),true)}${renderMonth(mkStr(nx),true)}
`; } else { body=monthsWithData.slice().reverse().map(mk=>renderMonth(mk,false)).join(''); } const legend=`
Full Half OT / Night (×) Absent 💰 Payment day  ·  adv = advance
`; return controls + `
` + body + legend + `
`; } // Print / save the currently-shown attendance calendar as PDF (opens a clean print window) function printAttendanceCalendar(workerName) { const grid = document.getElementById('hrpCalGrid'); if (!grid) { alert('Open the Calendar view first.'); return; } const w = window.open('', '_blank'); if (!w) { alert('Allow pop-ups to print the calendar.'); return; } const stamp = dayKey(new Date()); const money = n => '₹' + Math.round(Number(n)||0).toLocaleString('en-IN'); // ── Summary figures (use the same computed row the payable table shows) ── const row = (window._hrpLabRows||[]).find(r => r.name === workerName) || {}; const lab = (data.labours||[]).find(l => l.name === workerName) || {}; const bonusOn = labBonusOn(workerName); const bd = row.bonusDetail || { weekly:0, monthly:0, total:0 }; const wages = Number(row.wageTotal)||0; const bonus = bonusOn ? (Number(row.bonusTotal)||0) : 0; const earned = wages + bonus; const paid = Number(row.paidWage)||0; const balance = earned - paid; const pendingAdv = (lab.advances||[]).filter(a=>a.status!=='cleared').reduce((s,a)=>s+Number(a.amount||0),0); // Period label let periodLabel = 'All time'; if (window._hrpPeriod === 'week' && window._hrpWeek) { const b = getWeekBounds(window._hrpWeek); if (b) periodLabel = b.from + ' → ' + b.to; } else if (window._hrpPeriod === 'custom') periodLabel = (window._hrpFrom||'start') + ' → ' + (window._hrpTo||'today'); // Payments received (particulars), respecting the current period like the dashboard const isDateKey = s => typeof s === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(s); const inRng = typeof inHrpRange === 'function' ? inHrpRange : () => true; const pays = (data.labourPayments||[]).filter(p => p.labourName === workerName) .filter(p => inRng(p.date) || (isDateKey(p.period) && inRng(p.period))) .sort((a,b)=>(a.date||'').localeCompare(b.date||'')); const advTotal = pays.filter(p=>(p.purpose||'')==='advance').reduce((s,p)=>s+Number(p.amount||0),0); const partHeader = [ ['Worker', escHtml(workerName)], ['Role', escHtml(lab.role||'—')], ['Category', escHtml(lab.category||'—')], ['Daily rate', lab.dailyRate?money(lab.dailyRate):'—'], ['Company', escHtml(lab.company||'—')], ['Gang / Mestri', escHtml(lab.group||'—')], ['Period', periodLabel] ].map(([k,v])=>`
${k}: ${v}
`).join(''); const summary = [ ['Days present', row.daysPresent||0], ['Total shifts', row.shifts||0], ['Gross wages', money(wages)], ['Bonus'+(bonusOn?'':' (excluded)'), money(bonus)+(bd.total?` (weekly ${money(bd.weekly)} + monthly ${money(bd.monthly)})`:'')], ['Total earned', money(earned)], ['Payments received'+(advTotal?` (incl. advance ${money(advTotal)})`:''), money(paid)], pendingAdv?['Advance pending (to deduct)', '-'+money(pendingAdv)]:null, ['Balance to pay', money(balance)] ].filter(Boolean); const summaryTable = ` ${summary.map((r,i)=>``).join('')}
${r[0]}${r[1]}
`; const paysTable = pays.length ? `

Payments Received (${pays.length})

${pays.map(p=>``).join('')}
DatePurposeModeNoteAmount
${p.date||''}${escHtml(p.purpose||'salary')}${escHtml(p.mode||'cash')}${escHtml(p.note||'')}${money(p.amount)}
TOTAL RECEIVED${money(paid)}
` : '
No payments recorded in this period.
'; // Concrete colours so the var()-based cells render outside the app's theme w.document.write(`Attendance — ${escHtml(workerName)}

👷 ${escHtml(workerName)} — Attendance & Wages

Generated ${stamp}
${partHeader}
${grid.innerHTML}

Wage Summary

${summaryTable} ${paysTable}
`); w.document.close(); setTimeout(() => { try { w.focus(); w.print(); } catch(e){} }, 500); } function renderHRPayable() { const el = document.getElementById('hrPayableContent'); if (!el) return; const labours = data.labours || []; const vendors = data.vendors || []; const subcontractors = data.subcontractors || []; const attendance = data.attendance || []; const accounts = data.accounts || []; const funds = data.funds || []; const today = dayKey(new Date()); // ── State ────────────────────────────────────────────────── window._hrpFilter = window._hrpFilter || 'all'; // all|labour|vendor|sub window._hrpLabour = window._hrpLabour || ''; // labour name filter window._hrpPeriod = window._hrpPeriod || 'all'; // all|week|custom window._hrpWeek = window._hrpWeek || ''; // ISO week YYYY-Www window._hrpProject = window._hrpProject || ''; // project id filter window._hrpFrom = window._hrpFrom || ''; window._hrpTo = window._hrpTo || today; const pf = window._hrpFilter; const pl = window._hrpLabour; const pp = window._hrpPeriod; const bview = window._hrpBreakdownView || 'calendar'; // worker breakdown: calendar (default) | list window._hrpGroup = window._hrpGroup || ''; // labour group filter const pg = window._hrpGroup; window._hrpCategory = window._hrpCategory || ''; // labour category filter (C-LABOR / D-LABOR …) const pcat = window._hrpCategory; const _nc = s => String(s||'').trim().toLowerCase(); const matchCat = lab => !pcat || _nc(lab && lab.category) === _nc(pcat); if (window._hrpBonus === undefined) window._hrpBonus = true; // include bonus in payable const includeBonus = window._hrpBonus; const labourGroups = [...new Set((data.labours||[]).map(l => l.group).filter(Boolean))].sort((a,b)=>a.localeCompare(b)); const labourCats = [...new Set((data.labours||[]).map(l => (l.category||'').trim()).filter(Boolean))].sort((a,b)=>a.localeCompare(b)); // Project filter pool = projects assigned to the workers in the current gang/worker view const relevantLabs = (data.labours||[]).filter(l => (!pg || (l.group||'') === pg) && matchCat(l) && (!pl || l.name === pl)); const assignedProjIds = new Set(); relevantLabs.forEach(l => labProjectIds(l).forEach(id => assignedProjIds.add(String(id)))); let hrpProjPool = (data.projects||[]).filter(p => assignedProjIds.has(String(p.id))); if (!hrpProjPool.length) hrpProjPool = (data.projects||[]); // ── Date helpers ─────────────────────────────────────────── function getWeekBounds(sunKey) { // value is the Sunday date key 'YYYY-MM-DD' if (!sunKey) return null; const s = new Date(sunKey); s.setHours(0,0,0,0); if (isNaN(s)) return null; // snap to Sunday in case a mid-week date was passed s.setDate(s.getDate() - s.getDay()); const sat = new Date(s); sat.setDate(s.getDate() + 6); return { from: dayKey(s), to: dayKey(sat) }; } function inRange(dateStr) { if (pp === 'all') return true; if (pp === 'week') { const b = getWeekBounds(window._hrpWeek); if (!b) return true; return dateStr >= b.from && dateStr <= b.to; } if (pp === 'custom') { const f = window._hrpFrom, t = window._hrpTo; return (!f || dateStr >= f) && (!t || dateStr <= t); } return true; } // Build week options (Sunday → Saturday), current week first const weekOpts = []; const now = new Date(); now.setHours(0,0,0,0); const thisSun = new Date(now); thisSun.setDate(now.getDate() - now.getDay()); if (pp === 'week' && !window._hrpWeek) window._hrpWeek = dayKey(thisSun); // sensible default for (let i = 1; i >= -9; i--) { const s = new Date(thisSun); s.setDate(thisSun.getDate() + i*7); const e = new Date(s); e.setDate(s.getDate() + 6); const wStr = dayKey(s); const opt = { day:'numeric', month:'short' }; const wkNo = getISOWeekNumber(s).week; const lbl = `W${wkNo} · ${s.toLocaleDateString('en-IN',opt)} – ${e.toLocaleDateString('en-IN',opt)}${i===0?' (this week)':''}`; weekOpts.push(``); } // ── Labour payable ───────────────────────────────────────── // Group attendance by labour name → sum wages, add bonus, paid = actual payments. // When a project is selected, only that project's attendance/bonus/payments count. const hrpProj = window._hrpProject || ''; const matchProjAtt = a => attMatchesProject(a, hrpProj); const labMap = {}; attendance.filter(a => a.wage > 0).forEach(a => { if (!inRange(a.date)) return; if (!matchProjAtt(a)) return; if (!labMap[a.labourName]) labMap[a.labourName] = { name:a.labourName, entries:[], wageTotal:0, bonusTotal:0, totalWage:0, paidWage:0 }; const acct = a.accountEntryId ? accounts.find(x=>x.id===a.accountEntryId) : null; const paid = acct ? acct.status === 'paid' : false; // Shift-equivalents. Priority: // 1) explicit stored multiplier (survives daily-rate changes) // 2) wage ÷ daily rate — catches OT/night that's baked into the WAGE while status was left 'full' // 3) status default (full=1, half=0.5, overtime=1.5, night=2) const SHIFT_MULT = { full:1, half:0.5, overtime:1.5, night:2 }; const _labRate = Number(((data.labours||[]).find(l => l.name === a.labourName)||{}).dailyRate) || 0; let mult; if (a.mult != null && a.mult !== '' && Number(a.mult) > 0) mult = Number(a.mult); else if (_labRate > 0 && Number(a.wage) > 0) mult = Math.round((Number(a.wage) / _labRate) * 4) / 4; // snap to ¼-shift else mult = SHIFT_MULT[a.status] ?? 1; labMap[a.labourName].entries.push({ date:a.date, wage:a.wage, status:a.status, mult, paid, acctId:a.accountEntryId }); labMap[a.labourName].wageTotal += Number(a.wage); }); // Make sure every roster labour appears (so you can still pay someone with no in-range wages) labours.forEach(l => { if (!labMap[l.name]) labMap[l.name] = { name:l.name, entries:[], wageTotal:0, bonusTotal:0, totalWage:0, paidWage:0 }; }); // Bonus + actual payments (both respect the project filter) Object.values(labMap).forEach(r => { const bonus = computeLabourBonus(r.name, inRange, hrpProj ? matchProjAtt : null); r.bonusDetail = bonus; r.bonusTotal = bonus.total; r.totalWage = r.wageTotal + (labBonusOn(r.name) ? r.bonusTotal : 0); r.paidWage = labourPaidTotal(r.name, inRange, hrpProj); r.daysPresent = new Set(r.entries.map(e => e.date)).size; // distinct days worked // total shift-equivalents (night day = 2 shifts, OT = 1.5, half = 0.5) r.shifts = Math.round(r.entries.reduce((s,e) => s + (Number(e.mult)||1), 0) * 100) / 100; }); const labRows = Object.values(labMap).filter(r => { if (pl && r.name !== pl) return false; if (pg) { const lab = (data.labours||[]).find(l => l.name === r.name); if (!lab || (lab.group||'') !== pg) return false; } if (pcat) { const lab = (data.labours||[]).find(l => l.name === r.name); if (!matchCat(lab)) return false; } if (hrpProj) { const lab = (data.labours||[]).find(l => l.name === r.name); const assigned = lab && labProjectIds(lab).map(String).includes(String(hrpProj)); if (!assigned && r.wageTotal <= 0) return false; // amounts are already project-scoped } return (r.totalWage>0 || r.paidWage>0); }).sort((a,b)=>a.name.localeCompare(b.name)); window._hrpLabRows = labRows; // ── Vendor payable ───────────────────────────────────────── // Dues come from three places: the manual "outstanding" field on the vendor, // Funds entries, and Accounts ledger entries (labour-supply musters, expected // payments, purchases logged as type 'vendor-payment'). const vendRows = vendors.map(v => { const inProj = x => !hrpProj || String(x.projectId ?? '') === String(hrpProj); const vFunds = funds.filter(f=>f.party===v.name||f.vendor===v.name).filter(f=>inRange(f.date||f.dueDate||'')).filter(inProj); const vAccts = accounts.filter(a=>a.type==='vendor-payment' && a.party===v.name).filter(a=>inRange(a.date||a.dueDate||'')).filter(inProj); const pending = vFunds.filter(f=>f.status!=='paid').reduce((s,f)=>s+Number(f.amount||0),0) + vAccts.filter(a=>a.status!=='paid').reduce((s,a)=>s+Number(a.amount||0),0); const paid = vFunds.filter(f=>f.status==='paid').reduce((s,f)=>s+Number(f.amount||0),0) + vAccts.filter(a=>a.status==='paid').reduce((s,a)=>s+Number(a.amount||0),0); const manual = hrpProj ? 0 : Number(v.outstanding||0); // manual outstanding has no project tag const outstanding = manual + pending; return { name:v.name, cat:v.category||'—', outstanding, paid, totalPayable: outstanding + paid }; }).filter(r=>r.outstanding>0||r.paid>0); // ── Subcontractor payable ────────────────────────────────── const subRows = subcontractors.map(s => { const sFunds = funds.filter(f=>f.party===s.name||f.subcontractor===s.name).filter(f=>inRange(f.date||'')); const payable = sFunds.filter(f=>f.status!=='paid').reduce((s,f)=>s+Number(f.amount||0),0); const paid = sFunds.filter(f=>f.status==='paid').reduce((s,f)=>s+Number(f.amount||0),0); return { name:s.name, trade:s.trade||'—', payable, paid }; }).filter(r=>r.payable>0||r.paid>0); // ── Totals ───────────────────────────────────────────────── const totalLabPayable = labRows.reduce((s,r)=>s+r.totalWage-r.paidWage, 0); const totalLabPaid = labRows.reduce((s,r)=>s+r.paidWage, 0); const totalVendPay = vendRows.reduce((s,r)=>s+r.outstanding,0); const totalVendPaid = vendRows.reduce((s,r)=>s+r.paid,0); const totalSubPay = subRows.reduce((s,r)=>s+r.payable,0); const totalSubPaid = subRows.reduce((s,r)=>s+r.paid,0); // Grand totals only count the categories currently shown by the All/Labour/Vendors/Sub-con filter const showLab = (pf==='all'||pf==='labour'); const showVend = (pf==='all'||pf==='vendor'); const showSub = (pf==='all'||pf==='sub'); const grandPayable = (showLab?totalLabPayable:0) + (showVend?totalVendPay:0) + (showSub?totalSubPay:0); const grandPaid = (showLab?totalLabPaid:0) + (showVend?totalVendPaid:0) + (showSub?totalSubPaid:0); const grandGross = grandPayable + grandPaid; // total owed before any payments // Wages that can't be split by project (untagged days of multi-project workers) — warn when a project is filtered let hrpUntagged = 0; if (hrpProj && showLab) { (data.labours||[]).filter(l => (!pg || (l.group||'') === pg) && matchCat(l) && (!pl || l.name === pl)) .forEach(l => { hrpUntagged += untaggedWageTotal(l.name); }); } el.innerHTML = `
${[['all','All'],['labour','👷 Labour'],['vendor','🏪 Vendors'],['sub','🛠 Sub-con']].map(([v,l])=>` `).join('')}
${(pf==='all'||pf==='labour') && labourGroups.length ? `
Group
` : ''} ${(pf==='all'||pf==='labour') && labourCats.length ? `
Category
` : ''} ${(pf==='all'||pf==='labour') ? `
Worker
` : ''} ${(pf==='all'||pf==='labour') && (data.projects||[]).length ? `
Project
` : ''}
Period
${[['all','All Time'],['week','By Week'],['custom','Date Range']].map(([v,l])=>` `).join('')}
${pp==='week' ? `
Week
` : ''} ${pp==='custom' ? `
From
To
` : ''} ${(pf==='all'||pf==='labour') ? `
` : ''}
${hrpUntagged>0 ? `
⚠ ${fmtINR(hrpUntagged)} of wages in this period have no project tag (worker assigned to several projects), so they appear only under All Projects. Tag attendance with a project to split them correctly.
` : ''}
Total Payable
${fmtINR(grandGross)}
Wages + bonus owed
Total Paid
${fmtINR(grandPaid)}
${grandPayable+grandPaid>0?Math.round(grandPaid/(grandPayable+grandPaid)*100)+'% cleared':''}
Balance to Pay
${fmtINR(grandPayable)}
Outstanding now
${showLab ? `
Labour Payable
${fmtINR(totalLabPayable)}
${labRows.length} workers
` : ''} ${showVend ? `
Vendor Payable
${fmtINR(totalVendPay)}
${vendRows.length} vendors
` : ''} ${showSub ? `
Sub-con Payable
${fmtINR(totalSubPay)}
${subRows.length} subcontractors
` : ''}
${(pf==='all'||pf==='labour') ? `
👷 Labour Payable
${(() => { const someOn = labRows.some(r => r.bonusTotal > 0 && labBonusOn(r.name)); const anyBonus = labRows.some(r => r.bonusTotal > 0); return anyBonus ? `` : ''; })()}
${labRows.length} workers  ·  ${fmtINR(totalLabPayable)} balance
${labRows.length ? labRows.map(r => { const lab2 = (data.labours||[]).find(l=>l.name===r.name)||{}; const pendingAdv = (lab2.advances||[]).filter(a=>a.status!=='cleared').reduce((s,a)=>s+Number(a.amount||0),0); const balance = r.totalWage - r.paidWage; const netPay = Math.max(0, balance - pendingAdv); const pct = r.totalWage > 0 ? Math.min(r.paidWage/r.totalWage*100, 100) : 0; return ``; }).join('') : ''}
Worker Days Shifts Wages Bonus Paid Balance Progress
${escHtml(r.name)} ${pendingAdv>0?`
Adv deduct: -${fmtINR(pendingAdv)}
`:''}
${r.daysPresent||0} ${r.shifts||0}${r.shifts>r.daysPresent?`
+OT/night
`:''}
${fmtINR(r.wageTotal)} ${r.bonusTotal>0?`
${labBonusOn(r.name)?'⭐ ':''}${fmtINR(r.bonusTotal)}
`:''}
${fmtINR(r.paidWage)} ${pendingAdv>0?`
${fmtINR(balance)}
`:''}
${netPay>0?fmtINR(netPay):'✓ Cleared'}
${Math.round(pct)}%
No labour wage data for this period
TOTAL ${labRows.reduce((s,r)=>s+(r.daysPresent||0),0)} ${labRows.reduce((s,r)=>s+(r.shifts||0),0)} ${fmtINR(labRows.reduce((s,r)=>s+r.wageTotal,0))} ${fmtINR(labRows.reduce((s,r)=>s+(labBonusOn(r.name)?r.bonusTotal:0),0))} ${fmtINR(totalLabPaid)} ${fmtINR(totalLabPayable)}
${pl ? `
👷 ${escHtml(pl)} — Attendance Breakdown
${bview==='calendar' ? hrpBreakdownCalendarHTML(labMap[pl]?.entries||[], pl) : ` ${(labMap[pl]?.entries||[]).sort((a,b)=>b.date.localeCompare(a.date)).map(e=>` `).join('') || ''}
DateStatusWagePayment
${e.date} ${e.status} ${fmtINR(e.wage)} ${e.paid?'✓ Paid':'Pending'}
No attendance entries for this period
`}
` : ''}
` : ''} ${(pf==='all'||pf==='vendor') ? `
🏪 Vendor Payable
${vendRows.length} vendors  ·  ${fmtINR(totalVendPay)} outstanding
${vendRows.length ? vendRows.sort((a,b)=>b.outstanding-a.outstanding).map(r => { const total = r.outstanding + r.paid; const pct = total > 0 ? Math.min(r.paid/total*100,100) : 0; return ``; }).join('') : ''}
VendorCategory Outstanding Paid Balance Progress
${escHtml(r.name)} ${r.cat} ${fmtINR(r.outstanding)} ${fmtINR(r.paid)} ${r.outstanding>0?fmtINR(r.outstanding):'✓ Cleared'}
No vendor outstanding amounts
TOTAL ${fmtINR(totalVendPay)} ${fmtINR(vendRows.reduce((s,r)=>s+r.paid,0))} ${fmtINR(totalVendPay)}
` : ''} ${(pf==='all'||pf==='sub') ? `
🔨 Subcontractor Payable
${subRows.length} subcontractors  ·  ${fmtINR(totalSubPay)} payable
${subRows.length ? subRows.sort((a,b)=>b.payable-a.payable).map(r => { const total = r.payable + r.paid; const pct = total > 0 ? Math.min(r.paid/total*100,100) : 0; return ``; }).join('') : ''}
NameTrade Payable Paid Progress
${escHtml(r.name)} ${escHtml(r.trade)} ${r.payable>0?fmtINR(r.payable):'✓ Clear'} ${fmtINR(r.paid)}
No subcontractor payment data for this period
TOTAL ${fmtINR(totalSubPay)} ${fmtINR(subRows.reduce((s,r)=>s+r.paid,0))}
` : ''}`; } function renderLibraryPage() { const el = document.getElementById('libraryPageContent'); if (!el) return; const projects = data.projects || []; const woPDFProjects = projects.filter(p => p.woPdfData && p.woPdfName); const today = dayKey(new Date()); el.innerHTML = `
💾 Backup & Restore
All your MYDAS data is stored locally in your browser
↧ Export Full Backup
Download all projects, labours, vendors, accounts, finance data as a JSON file.
⇧ Restore from Backup
Pick a previously exported .json backup file to merge data back in.
📊 Data Summary
📁 Projects: ${projects.length}
👷 Labours: ${(data.labours||[]).length}
🏪 Vendors: ${(data.vendors||[]).length}
📄 Work Order PDFs: ${woPDFProjects.length}
📋 Account Entries: ${(data.accounts||[]).length}
📄 Work Order PDFs
${woPDFProjects.length} work order${woPDFProjects.length!==1?'s':''} uploaded
${woPDFProjects.length ? `
${woPDFProjects.map(p => `
${escHtml(projNick(p))}
${escHtml(p.client||'')}${p.zone?' · '+escHtml(p.zone):''}
📄 PDF
${p.workOrderDate?`WO: ${p.workOrderDate}`:''} ${p.fileNo?`${escHtml(p.fileNo)}`:''} ${p.estimateNo?`${escHtml(p.estimateNo)}`:''}
📄 ${escHtml(p.woPdfName)}
`).join('')}
` : `
No Work Order PDFs uploaded yet. Upload from Edit Project → Work Order PDF section.
`}
${(() => { const stmtRows = []; (data.banks||[]).forEach(b => (b.statements||[]).filter(s=>s.hasPdf).forEach(s => stmtRows.push({ ...s, bankId:b.id, bankName:b.name }))); stmtRows.sort((a,b)=>(b.month||'').localeCompare(a.month||'')); const M=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; const monLabel = m => { if(!m) return '—'; const [y,mm]=m.split('-'); return M[parseInt(mm)-1]+' '+y; }; return `
🏦 Bank Statements
${stmtRows.length} statement PDF${stmtRows.length!==1?'s':''} stored
${stmtRows.length ? `
${stmtRows.map(s => `
${escHtml(s.bankName)}
${escHtml(s.name)}
${monLabel(s.month)}
${s.count||0} txns · uploaded ${s.date||''}
`).join('')}
` : `
No bank statement PDFs stored yet. Upload PDFs in Money Management → Banks.
`}
`; })()}
📊 Project Data Files
Excel and CSV files attached to projects
${projects.some(p=>p.importedFileName) ? `
${projects.filter(p=>p.importedFileName).map(p=>``).join('')}
ProjectFile NameImported On
${escHtml(projNick(p))} 📊 ${escHtml(p.importedFileName)} ${p.importedDate||'—'}
` : `
No project data files imported yet. Use "Upload Data File" on the Projects page.
`}
📋 All Projects — Document Status
${projects.length} projects
${projects.length ? projects.map(p => { const docPct = projectDocPct(p).pct; return ``; }).join('') : ''}
ProjectFile No.Est. No. WO DateEstimateWO PDFProcess
${escHtml(projNick(p))} ${escHtml(p.fileNo||'—')} ${escHtml(p.estimateNo||'—')} ${p.workOrderDate||'—'} ${p.estimate?fmtINR(p.estimate):'—'} ${p.woPdfData ? `
` : ` `}
${docPct}%
No projects yet
`; } // Kind helper — existing records (no kind) are treated as Equipment (machinery) function astKindOf(a){ return a.kind==='asset' ? 'asset' : 'equipment'; } function setAssetView(v){ window._astView = v; renderAssetsPage(); } function renderAssetsPage() { const el = document.getElementById('assetsPageContent'); if (!el) return; const view = window._astView || 'assets'; const projOpts = '' + (data.projects||[]).map(p=>``).join(''); const whOpts = '' + (data.warehouses||[]).map(w=>``).join(''); const tabs = [['assets','🏗 Assets'],['equipment','🚜 Equipment'],['warehouse','🏬 Warehouse'],['stocks','📦 Stocks']]; const tabBar = `
${tabs.map(([v,l])=>``).join('')}
`; let body = ''; if (view === 'assets' || view === 'equipment') { const isAsset = view === 'assets'; const kind = isAsset ? 'asset' : 'equipment'; const list = (data.assets||[]).filter(a=>astKindOf(a)===kind); const statusLabel={available:'Available','in-use':'In Use',maintenance:'Maintenance'}; const statusPill={available:'pill-green','in-use':'pill-sky',maintenance:'pill-amber'}; const rows = list.length ? list.map(a=>{ const proj=a.projectId?(data.projects||[]).find(p=>p.id===a.projectId):null; return ` ${escHtml(a.name)} ${escHtml(a.type||'—')} ${statusLabel[a.status]||a.status} ${proj?escHtml(projNick(proj)):'—'} ${fmtINR(a.rate||0)} ${escHtml(a.company||'Unassigned')} `; }).join('') : `No ${isAsset?'assets':'equipment'} added yet`; const rateLabel = isAsset ? 'Value ₹' : 'Rate ₹/day'; body = `
➕ Add ${isAsset?'Asset':'Equipment'}
${isAsset?'🏗 Fixed Asset Register':'🚜 Equipment Register'}
${list.length} ${isAsset?'asset':'equipment'}${list.length===1?'':'s'}
${rows}
${isAsset?'Asset':'Equipment'}TypeStatusAssigned Project${rateLabel}Company
`; } else if (view === 'warehouse') { const list = data.warehouses||[]; const rows = list.length ? list.map(w=>{ const stk=(data.stocks||[]).filter(s=>String(s.warehouseId)===String(w.id)); const val=stk.reduce((s,x)=>s+(Number(x.qty||0)*Number(x.unitCost||0)),0); return ` ${escHtml(w.name)} ${escHtml(w.location||'—')} ${escHtml(w.capacity||'—')} ${escHtml(w.incharge||'—')} ${stk.length} item${stk.length===1?'':'s'} ${fmtINR(val)} `; }).join('') : 'No warehouses added yet'; body = `
➕ Add Warehouse / Store
🏬 Warehouses & Stores
${list.length} location${list.length===1?'':'s'}
${rows}
NameLocationCapacityIn-chargeStock ItemsStock Value
`; } else if (view === 'stocks') { const list = data.stocks||[]; const totVal = list.reduce((s,x)=>s+(Number(x.qty||0)*Number(x.unitCost||0)),0); const low = list.filter(x=>Number(x.reorder||0)>0 && Number(x.qty||0)<=Number(x.reorder||0)).length; const rows = list.length ? list.map(x=>{ const w=(data.warehouses||[]).find(w=>String(w.id)===String(x.warehouseId)); const isLow=Number(x.reorder||0)>0 && Number(x.qty||0)<=Number(x.reorder||0); return ` ${escHtml(x.item)} ${w?escHtml(w.name):'—'} ${Number(x.qty||0)} ${escHtml(x.unit||'')} ${fmtINR(x.unitCost||0)} ${fmtINR(Number(x.qty||0)*Number(x.unitCost||0))} ${isLow?`Low · reorder ${x.reorder}`:(Number(x.reorder||0)>0?`OK`:'')} `; }).join('') : 'No stock items added yet'; body = `
➕ Add Stock Item
📦 Stock / Inventory
${list.length} item${list.length===1?'':'s'} · ${fmtINR(totVal)} value${low?` · ${low} low`:''}
${rows}
ItemWarehouseQtyUnit CostValueReorder
`; } el.innerHTML = tabBar + body; } function astAddItem(kind) { const name=(document.getElementById('astName').value||'').trim(); if(!name) return; const type=(document.getElementById('astType').value||'').trim(); const status=document.getElementById('astStatus').value; const projectId=document.getElementById('astProject').value?Number(document.getElementById('astProject').value):null; const rate=Number(document.getElementById('astRate').value)||0; const company=(document.getElementById('astCompany').value||'').trim()||'Unassigned'; data.assets.push({ id:Date.now(), name, type, status, projectId, rate, company, kind:(kind==='asset'?'asset':'equipment') }); saveData(); renderAssetsPage(); } function astDelItem(id){ data.assets=(data.assets||[]).filter(x=>x.id!==id); saveData(); renderAssetsPage(); } function astCycleStatus(id){ const a=(data.assets||[]).find(x=>x.id===id); if(!a) return; const o=['available','in-use','maintenance']; a.status=o[(o.indexOf(a.status)+1)%o.length]; saveData(); renderAssetsPage(); } function astAddWarehouse(){ const name=(document.getElementById('whName').value||'').trim(); if(!name) return; data.warehouses.push({ id:Date.now(), name, location:(document.getElementById('whLoc').value||'').trim(), capacity:(document.getElementById('whCap').value||'').trim(), incharge:(document.getElementById('whIncharge').value||'').trim() }); saveData(); renderAssetsPage(); } function astDelWarehouse(id){ if(!confirm('Delete this warehouse? Stock items keep their record but lose the location link.')) return; data.warehouses=(data.warehouses||[]).filter(x=>x.id!==id); saveData(); renderAssetsPage(); } function astAddStock(){ const item=(document.getElementById('stkItem').value||'').trim(); if(!item) return; data.stocks.push({ id:Date.now(), item, warehouseId:document.getElementById('stkWh').value||'', qty:Number(document.getElementById('stkQty').value)||0, unit:(document.getElementById('stkUnit').value||'').trim(), unitCost:Number(document.getElementById('stkCost').value)||0, reorder:Number(document.getElementById('stkReorder').value)||0 }); saveData(); renderAssetsPage(); } function astAdjustStock(id,delta){ const s=(data.stocks||[]).find(x=>x.id===id); if(!s) return; s.qty=Math.max(0,(Number(s.qty||0)+delta)); saveData(); renderAssetsPage(); } function astDelStock(id){ data.stocks=(data.stocks||[]).filter(x=>x.id!==id); saveData(); renderAssetsPage(); } // ===== PAYMENT RECEIPT AUTO-FILL — read a UPI / bank screenshot or PDF and prefill the pay form ===== const RECEIPT_BANKS = ['DBS','AXIS','HDFC','ICICI','SBI','KOTAK','INDIAN OVERSEAS','IOB','CANARA','UNION BANK','PNB','BANK OF BARODA','IDBI','YES BANK','INDUSIND','FEDERAL','CITY UNION','TAMILNAD MERCANTILE','TMB','KARUR VYSYA','KVB','RBL','BANDHAN','AU SMALL','EQUITAS','SARASWAT']; const RECEIPT_MONTHS = {jan:1,feb:2,mar:3,apr:4,may:5,jun:6,jul:7,aug:8,sep:9,oct:10,nov:11,dec:12}; function parseReceiptText(raw) { const flat = String(raw||'').replace(/₹/g,'₹').replace(/\s+/g,' '); const low = flat.toLowerCase(); const out = { amount:0, date:'', ref:'', mode:'', bank:'', payee:'' }; // amount — collect all ₹/Rs/INR figures, pick the most frequent (tie → largest) const amts = []; let m; const re = /(?:₹|rs\.?|inr)\s*([\d][\d,]*(?:\.\d{1,2})?)/gi; while ((m = re.exec(flat))) { const n = Number(m[1].replace(/,/g,'')); if (n>0) amts.push(n); } if (amts.length) { const f={}; amts.forEach(n=>f[n]=(f[n]||0)+1); out.amount = Number(Object.keys(f).sort((a,b)=> f[b]-f[a] || Number(b)-Number(a))[0]); } // date let dm; if ((dm = flat.match(/\b(\d{1,2})\s+([A-Za-z]{3,9})\s+(\d{4})\b/))) { const mo = RECEIPT_MONTHS[dm[2].slice(0,3).toLowerCase()]; if (mo) out.date = dm[3]+'-'+String(mo).padStart(2,'0')+'-'+dm[1].padStart(2,'0'); } else if ((dm = flat.match(/\b(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})\b/))) out.date = dm[3]+'-'+dm[2].padStart(2,'0')+'-'+dm[1].padStart(2,'0'); else if ((dm = flat.match(/\b(\d{4})[\/\-](\d{1,2})[\/\-](\d{1,2})\b/))) out.date = dm[1]+'-'+dm[2].padStart(2,'0')+'-'+dm[3].padStart(2,'0'); // reference let rm; if ((rm = flat.match(/UTR[:\s]*([A-Za-z0-9]{6,})/i))) out.ref = rm[1]; else if ((rm = flat.match(/Reference\s*(?:ID|No\.?|Number)?[:\s]*([A-Za-z0-9]{6,})/i))) out.ref = rm[1]; else if ((rm = flat.match(/(?:UPI\s*)?Transaction\s*ID[:\s]*([A-Za-z0-9]{8,})/i))) out.ref = rm[1]; else if ((rm = flat.match(/\bTxn[:\s#]*([A-Za-z0-9]{8,})/i))) out.ref = rm[1]; // mode if (/\b(imps|neft|rtgs)\b/i.test(flat)) out.mode = 'bank'; else if (/upi|phonepe|phone pe|g\s?pay|google pay|paytm|okaxis|@ok|@ybl|@axl|@paytm/i.test(low)) out.mode = 'upi'; else if (/\bbank\b/i.test(low)) out.mode = 'bank'; // bank (sender) — prefer text near Debited/From/Powered by const ctx = (flat.match(/(?:debited from|from|powered by)([\s\S]{0,90})/i)||[])[1] || ''; const findBank = s => { const u = String(s).toUpperCase(); for (const b of RECEIPT_BANKS) if (u.includes(b)) return b; return ''; }; out.bank = findBank(ctx) || findBank(flat); // payee (best-effort — first 2 words after Paid to / To) let pm; if ((pm = flat.match(/Paid to[:\s]+([A-Za-z][A-Za-z .]{1,40})/i)) || (pm = flat.match(/\bTo[:\s]+(?:PayeeVerified)?\s*([A-Za-z][A-Za-z .]{1,40})/i))) out.payee = pm[1].trim().split(/\s+/).slice(0,2).join(' '); return out; } function fileToDataUrl(file) { return new Promise((res,rej)=>{ const r=new FileReader(); r.onload=()=>res(r.result); r.onerror=()=>rej(r.error); r.readAsDataURL(file); }); } function loadTesseract() { if (window.Tesseract) return Promise.resolve(window.Tesseract); return new Promise((res,rej)=>{ const s=document.createElement('script'); s.src='https://cdn.jsdelivr.net/npm/tesseract.js@5.1.0/dist/tesseract.min.js'; s.onload=()=>res(window.Tesseract); s.onerror=()=>rej(new Error('OCR library needs an internet connection')); document.head.appendChild(s); }); } async function extractReceiptFromFile(file) { const isPdf = /pdf/i.test(file.type) || /\.pdf$/i.test(file.name); if (isPdf) { 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: await file.arrayBuffer() }).promise; let t = ''; for (let i=1;i<=pdf.numPages;i++){ const pg=await pdf.getPage(i); const c=await pg.getTextContent(); t += c.items.map(x=>x.str).join(' ') + '\n'; } return { text:t, dataUrl: await fileToDataUrl(file), isPdf:true }; } const dataUrl = await compressBillImage(file); const T = await loadTesseract(); const { data } = await T.recognize(dataUrl, 'eng'); return { text: data.text, dataUrl, isPdf:false }; } function uploadPayReceipt() { const input = document.createElement('input'); input.type = 'file'; input.accept = 'image/*,application/pdf'; input.onchange = async () => { const file = input.files && input.files[0]; if (!file) return; const status = document.getElementById('lpReceiptStatus'); const isPdf = /pdf/i.test(file.type) || /\.pdf$/i.test(file.name); if (status) status.innerHTML = '⏳ Reading receipt…' + (isPdf ? '' : ' (scanning the image — first time may take ~15s to load the reader)'); try { const { text, dataUrl } = await extractReceiptFromFile(file); const r = parseReceiptText(text); window._pendingPayReceipt = dataUrl; if (r.amount) { const a=document.getElementById('lpAmount'); if (a) a.value = r.amount; } if (r.date) { const d=document.getElementById('lpDate'); if (d) d.value = r.date; } if (r.mode) { const md=document.getElementById('lpMode'); if (md){ md.value = r.mode; if (typeof onPayModeChange==='function') onPayModeChange(); } } if (r.bank) { const b=document.getElementById('lpBank'); if (b) b.value = r.bank; } const nt = document.getElementById('lpNote'); if (nt && !nt.value && (r.ref||r.payee)) nt.value = [(r.mode==='upi'?'UPI':r.mode==='bank'?'Bank':'') + (r.ref?(' ref '+r.ref):''), r.payee].filter(Boolean).join(' · ').trim(); const parts = []; if (r.amount) parts.push('₹'+r.amount.toLocaleString('en-IN')); if (r.date) parts.push(r.date); if (r.mode) parts.push(r.mode.toUpperCase()); if (r.bank) parts.push(r.bank); if (r.ref) parts.push('ref '+r.ref); if (status) status.innerHTML = (parts.length?'✅ Auto-filled: '+parts.join(' · '):'⚠ Couldn’t read the details — enter them manually') + '   📎 receipt attached'; } catch (e) { try { window._pendingPayReceipt = isPdf ? await fileToDataUrl(file) : await compressBillImage(file); } catch(_){} if (status) status.innerHTML = '⚠ ' + (e.message||'Could not read the receipt') + ' — 📎 receipt attached; fill amount/date manually.'; } }; input.click(); } async function viewPayReceipt(id) { const du = await docGet('pay-'+id); if (!du) { alert('Receipt image not found on this device.'); return; } document.getElementById('payReceiptView')?.remove(); const ov = document.createElement('div'); ov.id = 'payReceiptView'; ov.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.85);z-index:10002;display:flex;align-items:center;justify-content:center;padding:18px;'; ov.addEventListener('click', e => { if (e.target===ov) ov.remove(); }); if (/^data:application\/pdf/.test(du)) { const blob = await (await fetch(du)).blob(); ov.innerHTML = ``; } else ov.innerHTML = ``; document.body.appendChild(ov); } function showLabourPayEntry(labName, labId) { document.getElementById('labPayModal')?.remove(); window._pendingPayReceipt = null; // fresh form — no stale receipt const lab = (data.labours||[]).find(l=>l.id===labId||l.name===labName)||{}; window._payLabName = labName; const pendingAdv = (lab.advances||[]).filter(a=>a.status!=='cleared').reduce((s,a)=>s+Number(a.amount||0),0); const modal = document.createElement('div'); modal.id = 'labPayModal'; modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.4);backdrop-filter:blur(6px);z-index:9999;display:flex;align-items:center;justify-content:center;padding:16px;'; modal.addEventListener('click', e => { if(e.target===modal) modal.remove(); }); modal.innerHTML = `
👷 Pay Labour
${escHtml(labName)}${pendingAdv>0?'  ·  Advance pending: '+fmtINR(pendingAdv)+'':''}
Week / Period — pick to auto-fill amount
Amount Paid
Date
Purpose
Mode
${pendingAdv>0?`
`:''}
Note
`; document.body.appendChild(modal); } // Resolve the current pay period to a {from,to} date range function payRange() { const v = document.getElementById('lpPeriod')?.value || ''; if (v === 'custom') { const f = document.getElementById('lpRangeFrom')?.value || ''; const t = document.getElementById('lpRangeTo')?.value || ''; if (!f || !t) return null; return { from: f <= t ? f : t, to: f <= t ? t : f }; } if (/^\d{4}-\d{2}-\d{2}$/.test(v)) { const s = new Date(v); s.setHours(0,0,0,0); const e = new Date(s); e.setDate(s.getDate() + 6); return { from: dayKey(s), to: dayKey(e) }; } return null; } function onPayWeekChange() { const v = document.getElementById('lpPeriod')?.value || ''; const rangeWrap = document.getElementById('lpRangeWrap'); if (rangeWrap) rangeWrap.style.display = (v === 'custom') ? 'grid' : 'none'; recomputePayAmount(); } // Compute wages + bonus for the selected period (honouring the Sunday toggle), reveal bonus box function recomputePayAmount() { const bonusWrap = document.getElementById('lpBonusWrap'); const bonusLbl = document.getElementById('lpBonusLabel'); const hint = document.getElementById('lpAutoHint'); const r = payRange(); if (!r) { if (bonusWrap) bonusWrap.style.display = 'none'; window._payWk = null; if (hint) hint.textContent = ''; return; } const name = window._payLabName; const inclSun = document.getElementById('lpInclSun')?.checked !== false; const inRng = ds => ds >= r.from && ds <= r.to && (inclSun || new Date(ds).getDay() !== 0); const proj = window._hrpProject || ''; const wages = (data.attendance || []).filter(a => a.labourName === name && a.wage > 0 && inRng(a.date) && attMatchesProject(a, proj)).reduce((s, a) => s + Number(a.wage || 0), 0); const bonus = computeLabourBonus(name, inRng, proj ? (a => attMatchesProject(a, proj)) : null).total; window._payWk = { wages, bonus }; if (bonusWrap) bonusWrap.style.display = bonus > 0 ? '' : 'none'; if (bonusLbl) bonusLbl.textContent = `Add ⭐ bonus ₹${bonus.toLocaleString('en-IN')}`; applyPayAmount(); } // Set Amount Paid = period wages (+ bonus if ticked) function applyPayAmount() { const amtEl = document.getElementById('lpAmount'); const hint = document.getElementById('lpAutoHint'); const wk = window._payWk; if (!amtEl || !wk) return; const addBonus = document.getElementById('lpAddBonus')?.checked && wk.bonus > 0; const due = Math.round(wk.wages + (addBonus ? wk.bonus : 0)); amtEl.value = due; if (hint) hint.textContent = due > 0 ? `Auto-filled ₹${due.toLocaleString('en-IN')} — wages ₹${Math.round(wk.wages).toLocaleString('en-IN')}${addBonus ? ` + ⭐ bonus ₹${wk.bonus.toLocaleString('en-IN')}` : ''}` : 'No wages in that period'; } // Show bank sub-field only for Bank Transfer / Cheque function onPayModeChange() { const mode = document.getElementById('lpMode')?.value; const wrap = document.getElementById('lpBankWrap'); if (wrap) wrap.style.display = (mode === 'bank' || mode === 'cheque') ? '' : 'none'; } function showLabourPaymentsDetail(labName, labId) { document.getElementById('labPaysModal')?.remove(); const pays = (data.labourPayments || []).filter(p => p.labourName === labName).sort((a,b)=>(b.date||'').localeCompare(a.date||'')); const total = pays.reduce((s,p)=>s+Number(p.amount||0),0); const bonus = computeLabourBonus(labName).total; const modal = document.createElement('div'); modal.id = 'labPaysModal'; modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.4);backdrop-filter:blur(6px);z-index:9999;display:flex;align-items:center;justify-content:center;padding:16px;'; modal.addEventListener('click', e => { if (e.target === modal) modal.remove(); }); modal.innerHTML = `
📋 Payment History — ${escHtml(labName)}
Total paid: ${fmtINR(total)}${bonus>0?`  ·  ⭐ Bonus earned: ${fmtINR(bonus)}`:''}
${pays.length ? `${pays.map(p => ``).join('')}
DatePurposeModeAmount
${p.date} ${escHtml(p.purpose||'salary')}${p.note?`
${escHtml(p.note)}
`:''}
${escHtml(p.mode||'cash')}${p.bank?`
${escHtml(p.bank)}
`:''}
${fmtINR(p.amount)} ${p.receipt?``:''}
` : '
No payments recorded yet for this worker.
'}
`; document.body.appendChild(modal); } function editLabourPayment(id) { const p = (data.labourPayments || []).find(x => x.id === id); if (!p) return; document.getElementById('labPayEditModal')?.remove(); const purpose = p.purpose || 'salary'; const mode = p.mode || 'cash'; const showBank = mode==='bank'||mode==='cheque'||mode==='upi'; const banks = [...new Set([...(typeof BANK_LIST!=='undefined'?BANK_LIST:[]), ...((data.banks||[]).map(b=>b.name)), ...((data.labourPayments||[]).map(x=>x.bank).filter(Boolean))])]; const lbl = 'font-size:9.5px;font-weight:800;text-transform:uppercase;color:#6b7280;margin-bottom:3px;'; const inp = 'width:100%;height:38px;font-size:13px;border:1.5px solid #d1d5db;border-radius:10px;padding:0 10px;background:#fff;color:#111;outline:none;box-sizing:border-box;'; const opt = (v,l,cur) => ``; const modal = document.createElement('div'); modal.id = 'labPayEditModal'; modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.45);backdrop-filter:blur(6px);z-index:10001;display:flex;align-items:center;justify-content:center;padding:16px;'; modal.addEventListener('click', e => { if(e.target===modal) modal.remove(); }); modal.innerHTML = `
✎ Edit Payment
${escHtml(p.labourName)}
Amount Paid
Date
Purpose
Mode
Bank / Account
${banks.map(b=>``).join('')}
Note
`; document.body.appendChild(modal); } function confirmEditLabourPayment(id) { const p = (data.labourPayments || []).find(x => x.id === id); if (!p) return; const amt = Number(document.getElementById('epAmount')?.value); if (isNaN(amt) || amt < 0) { alert('Enter a valid amount'); return; } const newDate = document.getElementById('epDate')?.value || p.date; p.amount = amt; p.date = newDate; p.purpose = document.getElementById('epPurpose')?.value || p.purpose; p.mode = document.getElementById('epMode')?.value || p.mode; p.bank = (document.getElementById('epBank')?.value || '').trim(); p.note = (document.getElementById('epNote')?.value || '').trim(); const acct = (data.accounts || []).find(e => e.labourPaymentId === id); if (acct) { acct.amount = amt; acct.date = newDate; acct.mode = p.mode; acct.bank = p.bank || null; acct.category = 'labour-' + p.purpose; } recomputeLabourPaidFlags(p.labourName); document.getElementById('labPayEditModal')?.remove(); saveData(); renderAll(); showLabourPaymentsDetail(p.labourName, p.labourId || 0); if (typeof showToast === 'function') showToast('✅ Payment updated'); } function delLabourPayment(id, labName, labId) { const p = (data.labourPayments || []).find(x => x.id === id); if (!p) return; if (!confirm(`Delete payment of ${fmtINR(p.amount)} on ${p.date}?`)) return; if (p.receipt) docDel('pay-'+id).catch(()=>{}); data.labourPayments = (data.labourPayments || []).filter(x => x.id !== id); data.accounts = (data.accounts || []).filter(e => e.labourPaymentId !== id); recomputeLabourPaidFlags(labName); document.getElementById('labPaysModal')?.remove(); saveData(); renderAll(); showLabourPaymentsDetail(labName, labId || 0); if (typeof showToast === 'function') showToast('🗑 Payment deleted'); } // ===== GROUP PAY — pay every worker in the shown group/list at once ===== function gpToggleBonus(i) { const rows = window._gpRows || []; const r = rows[i]; if (!r) return; const bon = document.querySelector('.gpBon[data-i="' + i + '"]'); const amt = document.querySelector('.gpAmt[data-i="' + i + '"]'); if (!amt) return; const include = bon ? bon.checked : true; amt.value = include ? Math.round(r.bal) : Math.max(0, Math.round(r.bal - r.bonus)); } // Each ticked worker's target amount (respects their bonus tick) function gpEffBal(i) { const r = (window._gpRows || [])[i]; if (!r) return 0; const bon = document.querySelector('.gpBon[data-i="' + i + '"]'); const include = bon ? bon.checked : true; return include ? Math.round(r.bal) : Math.max(0, Math.round(r.bal - r.bonus)); } function gpResetAmounts() { const rows = window._gpRows || []; rows.forEach((r, i) => { const a = document.querySelector('.gpAmt[data-i="' + i + '"]'); if (a) a.value = gpEffBal(i); }); const c = document.getElementById('gpCommon'); if (c) c.value = ''; } // Split a common lump sum across ticked workers, weighted by their balance, rounded to ₹10 function gpSplitCommon() { const common = Math.round(Number((document.getElementById('gpCommon') || {}).value || 0)); if (!common) { alert('Enter a common amount to split.'); return; } const sel = []; document.querySelectorAll('.gpChk').forEach(chk => { if (chk.checked) { const i = Number(chk.dataset.i); sel.push({ i, w: gpEffBal(i) }); } }); if (!sel.length) { alert('Tick at least one worker to split across.'); return; } const totalW = sel.reduce((s, x) => s + x.w, 0); let allocated = 0; sel.forEach((x, k) => { let amt; if (k === sel.length - 1) { amt = common - allocated; // last worker absorbs the rounding remainder } else if (totalW > 0) { amt = Math.round((common * x.w / totalW) / 10) * 10; // proportional, rounded to ₹10 } else { amt = Math.round((common / sel.length) / 10) * 10; // equal split when no balances } allocated += (k === sel.length - 1) ? 0 : amt; const el = document.querySelector('.gpAmt[data-i="' + x.i + '"]'); if (el) el.value = Math.max(0, amt); }); } function openGroupPay() { const grp = window._hrpGroup || ''; const rows = (window._hrpLabRows || []).map(r => ({ name: r.name, labId: (data.labours.find(l => l.name === r.name) || {}).id, bal: Math.max(0, (r.totalWage || 0) - (r.paidWage || 0)), bonus: Math.round(r.bonusTotal || 0) })).filter(r => r.bal > 0); window._gpRows = rows; if (!rows.length) { (typeof showToast === 'function' ? showToast : alert)('No outstanding balances' + (grp ? (' in ' + grp) : '') + ' to pay'); return; } const banks = [...new Set([...(typeof BANK_LIST !== 'undefined' ? BANK_LIST : []), ...((data.banks||[]).map(b=>b.name).filter(Boolean)), 'Cash'])]; const total = rows.reduce((s,r)=>s+r.bal,0); const totalBonus = rows.reduce((s,r)=>s+r.bonus,0); // Projects the ticked gang is assigned to (for project-wise pay) const gpProjIds = new Set(); rows.forEach(r => { const lab = data.labours.find(l => l.name === r.name); if (lab) labProjectIds(lab).forEach(id => gpProjIds.add(String(id))); }); let gpProjPool = (data.projects||[]).filter(p => gpProjIds.has(String(p.id))); if (!gpProjPool.length) gpProjPool = (data.projects||[]); const gpPreProj = window._hrpProject || ''; const gpProjOpts = '' + gpProjPool.map(p => ``).join(''); openGenModal('💰 Pay Group' + (grp ? ' — ' + escHtml(grp) : ''), `
${rows.length} worker${rows.length===1?'':'s'} · total outstanding ${fmtINR(total)}${totalBonus>0?` · incl. bonus ${fmtINR(totalBonus)}`:''}
WorkerAmount ₹
${rows.map((r,i)=>`
${escHtml(r.name)} · bal ${fmtINR(r.bal)}${r.bonus>0?``:''}
`).join('')}
`, () => { const date = document.getElementById('gpDate').value || dayKey(new Date()); const purpose = document.getElementById('gpPurpose').value; const mode = document.getElementById('gpMode').value; const bankEl = document.getElementById('gpBank'); const bank = bankEl ? bankEl.value.trim() : ''; const remarksEl = document.getElementById('gpRemarks'); const remarks = remarksEl ? remarksEl.value.trim() : ''; // Tag the payment to the period currently shown, so it reconciles against the balance on screen let payPeriod = ''; if (window._hrpPeriod === 'week' && window._hrpWeek) payPeriod = window._hrpWeek; else if (window._hrpPeriod === 'custom' && window._hrpFrom) payPeriod = window._hrpFrom; const payTo = (document.getElementById('gpPayTo') || {}).value || 'each'; const collector = payTo === 'one' ? (rows[Number((document.getElementById('gpCollector') || {}).value || 0)] || rows[0]) : null; const gpProjId = document.getElementById('gpProject')?.value ? Number(document.getElementById('gpProject').value) : null; const gpProjObj = gpProjId ? (data.projects||[]).find(p => p.id === gpProjId) : null; const projNote = gpProjObj ? ' · ' + projNick(gpProjObj) : ''; let count = 0, sum = 0; document.querySelectorAll('.gpChk').forEach(chk => { if (!chk.checked) return; const i = Number(chk.dataset.i); const amtEl = document.querySelector('.gpAmt[data-i="' + i + '"]'); const amount = Math.round(Number(amtEl ? amtEl.value : 0) || 0); if (amount <= 0) return; const r = rows[i]; const payId = Date.now() + i; const bonChk = document.querySelector('.gpBon[data-i="' + i + '"]'); const bonusFlag = (r.bonus > 0) ? (bonChk && !bonChk.checked ? ' (wages only)' : ' (incl. bonus)') : ''; const collectNote = collector ? ' · collected by ' + collector.name : ''; const noteTxt = 'Group pay' + (grp ? ' — ' + grp : '') + projNote + bonusFlag + collectNote + (remarks ? ' · ' + remarks : ''); // Always credit each worker's own ledger so their balance clears data.labourPayments = data.labourPayments || []; data.labourPayments.push({ id: payId, labourId: r.labId, labourName: r.name, amount, date, purpose, mode, bank, period: payPeriod, projectId: gpProjId, note: noteTxt }); // Cash-out entry: one per worker when paying each; skipped here when a single collector receives the lump sum if (!collector) { data.accounts = data.accounts || []; data.accounts.push({ id: payId + 1, date, party: r.name, amount, status: 'paid', direction: 'out', category: 'labour-' + purpose, note: noteTxt, mode, bank: bank || null, period: payPeriod, projectId: gpProjId, labourId: r.labId, labourPaymentId: payId }); } if (typeof recomputeLabourPaidFlags === 'function') recomputeLabourPaidFlags(r.name); count++; sum += amount; }); if (!count) { alert('Select at least one worker with an amount.'); return; } // Single lump-sum cash-out to the collector (one entry for the whole gang) if (collector && sum > 0) { data.accounts = data.accounts || []; data.accounts.push({ id: Date.now() + 999, date, party: collector.name, amount: sum, status: 'paid', direction: 'out', category: 'labour-' + purpose, note: 'Group pay' + (grp ? ' — ' + grp : '') + projNote + ' · lump sum for ' + count + ' worker' + (count===1?'':'s') + (remarks ? ' · ' + remarks : ''), mode, bank: bank || null, period: payPeriod, projectId: gpProjId, labourId: collector.labId }); } saveData(); renderAll(); closeGenModal(); if (typeof showToast === 'function') showToast(collector ? ('Paid ' + fmtINR(sum) + ' to ' + collector.name + ' for ' + count + ' worker' + (count===1?'':'s')) : ('Paid ' + count + ' worker' + (count===1?'':'s') + ' · ' + fmtINR(sum))); }); if (gpPreProj) setTimeout(gpApplyProject, 30); } // Range predicate mirroring the payable dashboard's current period function inHrpRange(ds) { const pp = window._hrpPeriod || 'all'; if (pp === 'week') { const b = getWeekBounds(window._hrpWeek); if (!b) return true; return ds >= b.from && ds <= b.to; } if (pp === 'custom') { const f = window._hrpFrom, t = window._hrpTo; return (!f || ds >= f) && (!t || ds <= t); } return true; } // Does an attendance entry belong to a project? Tagged entries must match. // Untagged legacy entries are attributed only when unambiguous — the worker has exactly ONE // assigned project. A worker on several projects would otherwise have the same untagged days // counted (and paid) once per project. function attMatchesProject(a, projId) { const pj = String(projId || ''); if (!pj) return true; if (String(a.projectId ?? '') === pj) return true; if (a.projectId == null) { const lab = (data.labours||[]).find(l => l.name === a.labourName || (a.labourId != null && l.id === a.labourId)); const ids = lab ? labProjectIds(lab).map(String) : []; return ids.length === 1 && ids[0] === pj; } return false; } // Wages in the dashboard's current period that cannot be attributed to any single project: // no project tag on the entry AND the worker is assigned to several projects. function untaggedWageTotal(name) { const lab = (data.labours||[]).find(l => l.name === name); if (!lab || labProjectIds(lab).length <= 1) return 0; return (data.attendance||[]).filter(a => a.labourName === name && Number(a.wage) > 0 && a.projectId == null && inHrpRange(a.date)).reduce((s,a)=>s+Number(a.wage||0),0); } // Outstanding due for one worker on one project (wages + bonus if on − payments), in the dashboard's period function gpProjectWage(name, projId) { const wages = (data.attendance||[]).filter(a => a.labourName === name && Number(a.wage) > 0 && inHrpRange(a.date) && attMatchesProject(a, projId)).reduce((s,a)=>s+Number(a.wage||0), 0); const bonus = labBonusOn(name) ? computeLabourBonus(name, inHrpRange, a => attMatchesProject(a, projId)).total : 0; const paid = labourPaidTotal(name, inHrpRange, projId); return Math.max(0, wages + bonus - paid); } function gpApplyProject() { const projId = document.getElementById('gpProject')?.value || ''; const projObj = projId ? (data.projects||[]).find(p => p.id === Number(projId)) : null; const rows = window._gpRows || []; let projTotal = 0; rows.forEach((r,i) => { const amt = projId ? Math.round(gpProjectWage(r.name, projId)) : gpEffBal(i); const amtEl = document.querySelector('.gpAmt[data-i="' + i + '"]'); if (amtEl) amtEl.value = amt; const lbl = document.querySelector('.gpBalLbl[data-i="' + i + '"]'); if (lbl) lbl.textContent = projId ? (' · ' + escHtml(projNick(projObj)) + ' ' + fmtINR(amt)) : (' · bal ' + fmtINR(r.bal)); projTotal += amt; }); const sum = document.getElementById('gpSummaryLine'); if (sum) { const untagged = rows.reduce((s,r)=>s+untaggedWageTotal(r.name),0); if (projId) sum.innerHTML = rows.length + ' worker' + (rows.length===1?'':'s') + ' · ' + escHtml(projNick(projObj)) + ' due ' + fmtINR(projTotal) + '' + (untagged>0 ? ' · excl. ' + fmtINR(untagged) + ' untagged' : ''); else { const tot = rows.reduce((s,r)=>s+r.bal,0), tb = rows.reduce((s,r)=>s+r.bonus,0); sum.innerHTML = rows.length + ' worker' + (rows.length===1?'':'s') + ' · total outstanding ' + fmtINR(tot) + '' + (tb>0?' · incl. bonus '+fmtINR(tb)+'':''); } } } function confirmLabourPay(labName, labId) { const amount = Number(document.getElementById('lpAmount')?.value)||0; const date = document.getElementById('lpDate')?.value || dayKey(new Date()); const purpose = document.getElementById('lpPurpose')?.value || 'salary'; const mode = document.getElementById('lpMode')?.value || 'cash'; let period = document.getElementById('lpPeriod')?.value || ''; if (period === 'custom') { const r = payRange(); period = r ? r.from : ''; } // store a real date key const note = document.getElementById('lpNote')?.value || ''; const bank = document.getElementById('lpBank')?.value.trim() || ''; const deduct = document.getElementById('lpDeductAdv')?.checked || false; if (!amount) { alert('Enter payment amount'); return; } // Record the actual payment to the labour-payment ledger (this drives the PAID column). // Tag it to the project currently filtered in the payable dashboard so project views reconcile. const payProjId = window._hrpProject ? Number(window._hrpProject) : null; data.labourPayments = data.labourPayments || []; const payId = Date.now(); const hasReceipt = !!window._pendingPayReceipt; data.labourPayments.push({ id: payId, labourId: labId, labourName: labName, amount, date, purpose, mode, bank, period, projectId: payProjId, note, receipt: hasReceipt }); // Attach the uploaded receipt image/PDF (stored in IndexedDB, keyed to the payment) if (hasReceipt) { const _du = window._pendingPayReceipt; docSet('pay-'+payId, _du).catch(()=>{}); window._pendingPayReceipt = null; } // Also log a cash-out entry in Accounts data.accounts = data.accounts || []; data.accounts.push({ id: payId + 1, date, party: labName, amount, status: 'paid', direction: 'out', category: 'labour-' + purpose, note: note || (purpose + ' ' + (period||'')), mode, bank: bank || null, period, projectId: payProjId, labourId: labId, labourPaymentId: payId }); // Refresh the Paid/Pending flags on attendance days based on total paid so far recomputeLabourPaidFlags(labName); // Deduct advance if checked if (deduct) { const lab = (data.labours||[]).find(l=>l.id===labId||l.name===labName); if (lab) { (lab.advances||[]).filter(a=>a.status!=='cleared').forEach(a=>{ a.status='deducted'; a.deductedOn=date; }); } } document.getElementById('labPayModal')?.remove(); if (typeof showToast === 'function') showToast(`✅ Payment of ${fmtINR(amount)} recorded for ${labName}`); saveData(); renderAll(); } // ===== VENDOR PAYMENTS — pay a labour-supply / vendor, like the labour flow ===== function vendorOutstanding(vendorName) { const accts = (data.accounts||[]).filter(a => a.type==='vendor-payment' && a.party===vendorName && a.status!=='paid'); const v = (data.vendors||[]).find(x => x.name===vendorName); return (v ? Number(v.outstanding||0) : 0) + accts.reduce((s,a)=>s+Number(a.amount||0),0); } function showVendorPayEntry(vendorName) { document.getElementById('labPayModal')?.remove(); window._pendingPayReceipt = null; const outstanding = vendorOutstanding(vendorName); const banks = [...new Set([...(typeof BANK_LIST!=='undefined'?BANK_LIST:[]), ...((data.banks||[]).map(b=>b.name)), ...((data.vendorPayments||[]).map(p=>p.bank).filter(Boolean))])]; const lbl='font-size:9.5px;font-weight:800;text-transform:uppercase;color:#6b7280;margin-bottom:3px;'; const inp='width:100%;height:38px;font-size:13px;border:1.5px solid #d1d5db;border-radius:10px;padding:0 10px;background:#fff;color:#111;outline:none;box-sizing:border-box;'; const modal = document.createElement('div'); modal.id = 'labPayModal'; modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.4);backdrop-filter:blur(6px);z-index:9999;display:flex;align-items:center;justify-content:center;padding:16px;'; modal.addEventListener('click', e => { if(e.target===modal) modal.remove(); }); modal.innerHTML = `
🏪 Pay Vendor
${escHtml(vendorName)}${outstanding>0?'  ·  Outstanding: '+fmtINR(outstanding)+'':'  ·  Cleared'}
Amount Paid
${outstanding>0?'Full outstanding pre-filled':''}
Date
Mode
Note
`; document.body.appendChild(modal); } function confirmVendorPay(vendorName) { const amount = Number(document.getElementById('lpAmount')?.value)||0; if (!amount) { alert('Enter payment amount'); return; } const date = document.getElementById('lpDate')?.value || dayKey(new Date()); const mode = document.getElementById('lpMode')?.value || 'cash'; const bank = (document.getElementById('lpBank')?.value||'').trim(); const note = (document.getElementById('lpNote')?.value||'').trim(); const v = (data.vendors||[]).find(x => x.name===vendorName); data.accounts = data.accounts || []; // Settle pending vendor-payment ledger entries oldest-first (split the one that partially fits) let remaining = amount, k = 0; const pend = data.accounts.filter(a => a.type==='vendor-payment' && a.party===vendorName && a.status!=='paid').sort((a,b)=>(a.date||'').localeCompare(b.date||'')); for (const a of pend) { if (remaining <= 0.01) break; const amt = Number(a.amount||0); if (amt <= remaining + 0.01) { a.status = 'paid'; a.paidOn = date; remaining -= amt; } else { a.amount = amt - remaining; data.accounts.push({ id: Date.now()+ (++k), type:'vendor-payment', projectId:a.projectId||null, company:a.company||null, bank:bank||null, party:vendorName, amount:remaining, date, status:'paid', dueDate:'', note:'Part payment — '+(a.note||'') }); remaining = 0; } } // Then reduce the manual outstanding field if (remaining > 0.01 && v && Number(v.outstanding)>0) { const cut = Math.min(remaining, Number(v.outstanding)); v.outstanding = Number(v.outstanding) - cut; remaining -= cut; data.accounts.push({ id: Date.now()+(++k), type:'vendor-payment', projectId:null, company:v.company||null, bank:bank||null, party:vendorName, amount:cut, date, status:'paid', dueDate:'', note: note||'Vendor payment' }); } // Overpayment (advance) — still a paid ledger entry if (remaining > 0.01) data.accounts.push({ id: Date.now()+(++k), type:'vendor-payment', projectId:null, company:v?v.company:null, bank:bank||null, party:vendorName, amount:remaining, date, status:'paid', dueDate:'', note:'Advance / overpay' }); // History record (drives the Payments button + receipt) data.vendorPayments = data.vendorPayments || []; const payId = Date.now(); const hasReceipt = !!window._pendingPayReceipt; data.vendorPayments.push({ id: payId, vendorName, amount, date, mode, bank, note, receipt: hasReceipt }); if (hasReceipt) { docSet('vpay-'+payId, window._pendingPayReceipt).catch(()=>{}); window._pendingPayReceipt = null; } document.getElementById('labPayModal')?.remove(); saveData(); renderAll(); if (typeof showToast === 'function') showToast(`✅ Paid ${fmtINR(amount)} to ${vendorName}`); } function showVendorPaymentsDetail(vendorName) { document.getElementById('labPaysModal')?.remove(); const pays = (data.vendorPayments||[]).filter(p => p.vendorName===vendorName).sort((a,b)=>(b.date||'').localeCompare(a.date||'')); const total = pays.reduce((s,p)=>s+Number(p.amount||0),0); const outstanding = vendorOutstanding(vendorName); const modal = document.createElement('div'); modal.id = 'labPaysModal'; modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.4);backdrop-filter:blur(6px);z-index:9999;display:flex;align-items:center;justify-content:center;padding:16px;'; modal.addEventListener('click', e => { if (e.target === modal) modal.remove(); }); modal.innerHTML = `
📋 Payment History — ${escHtml(vendorName)}
Total paid: ${fmtINR(total)}${outstanding>0?`  ·  Outstanding: ${fmtINR(outstanding)}`:'  ·  Cleared'}
${pays.length ? `${pays.map(p => ``).join('')}
DateModeAmount
${p.date} ${escHtml(p.mode||'cash')}${p.bank?`
${escHtml(p.bank)}
`:''}${p.note?`
${escHtml(p.note)}
`:''}
${fmtINR(p.amount)} ${p.receipt?``:''}
` : '
No payments recorded yet for this vendor.
'}
`; document.body.appendChild(modal); } async function viewVendorReceipt(id) { const du = await docGet('vpay-'+id); if (!du) { alert('Receipt image not found on this device.'); return; } document.getElementById('payReceiptView')?.remove(); const ov = document.createElement('div'); ov.id = 'payReceiptView'; ov.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.85);z-index:10002;display:flex;align-items:center;justify-content:center;padding:18px;'; ov.addEventListener('click', e => { if (e.target===ov) ov.remove(); }); if (/^data:application\/pdf/.test(du)) { const blob = await (await fetch(du)).blob(); ov.innerHTML = ``; } else ov.innerHTML = ``; document.body.appendChild(ov); } function delVendorPayment(id, vendorName) { const p = (data.vendorPayments||[]).find(x=>x.id===id); if (!p) return; if (!confirm(`Delete payment of ${fmtINR(p.amount)} on ${p.date}? (This re-opens that amount as outstanding.)`)) return; if (p.receipt) docDel('vpay-'+id).catch(()=>{}); // Re-open the settled amount: flip the most recent paid entries back to pending, up to p.amount let restore = Number(p.amount||0); const paid = (data.accounts||[]).filter(a=>a.type==='vendor-payment' && a.party===vendorName && a.status==='paid').sort((a,b)=>(b.paidOn||b.date||'').localeCompare(a.paidOn||a.date||'')); for (const a of paid) { if (restore<=0.01) break; const amt=Number(a.amount||0); if (amt<=restore+0.01){ a.status='pending'; delete a.paidOn; restore-=amt; } } data.vendorPayments = (data.vendorPayments||[]).filter(x=>x.id!==id); saveData(); renderAll(); showVendorPaymentsDetail(vendorName); if (typeof showToast === 'function') showToast('🗑 Vendor payment deleted'); } // Weekly attendance bonus (₹50/present-day on full weeks) + monthly perfect-attendance bonus (₹1500) function computeLabourBonus(labName, inRangeFn, entryFilterFn) { const RATE = 50; const WEEK_DAYS = 6; // Mon–Sat are the working days const MONTH_DAYS = 30; // full-month bonus basis const att = (data.attendance || []).filter(a => a.labourName === labName && (!inRangeFn || inRangeFn(a.date)) && (!entryFilterFn || entryFilterFn(a))); const result = { weekly: 0, monthly: 0, total: 0, weeks: [], months: [] }; if (!att.length) return result; const isPresent = s => ['full','half','overtime','night'].includes(s); const isWeekday = ds => new Date(ds).getDay() !== 0; // Sunday (0) is not a working day const sundayKey = ds => { const d = new Date(ds); const s = new Date(d); s.setDate(d.getDate() - d.getDay()); return dayKey(s); }; const byWeek = {}, byMonth = {}; att.forEach(a => { (byWeek[sundayKey(a.date)] = byWeek[sundayKey(a.date)] || []).push(a); const mk = (a.date || '').slice(0, 7); (byMonth[mk] = byMonth[mk] || []).push(a); }); // WEEKLY: present on all 6 working days (Mon–Sat) → 6 × ₹50 = ₹300 flat. // Counts DISTINCT working days present, so split shifts or Sunday work never inflate it. Object.entries(byWeek).forEach(([wk, list]) => { const presentDays = new Set(list.filter(a => isWeekday(a.date) && isPresent(a.status)).map(a => a.date)); const absentDay = list.some(a => isWeekday(a.date) && a.status === 'absent' && !presentDays.has(a.date)); if (presentDays.size >= WEEK_DAYS && !absentDay) { const b = WEEK_DAYS * RATE; // ₹300 for the full week result.weekly += b; result.weeks.push({ wk, present: presentDays.size, bonus: b }); } }); // MONTHLY: no weekday absence + present on ~all working days of the month → 30 × ₹50 = ₹1500. Object.entries(byMonth).forEach(([mk, list]) => { const presentDays = new Set(list.filter(a => isWeekday(a.date) && isPresent(a.status)).map(a => a.date)); const absentDays = new Set(list.filter(a => isWeekday(a.date) && a.status === 'absent').map(a => a.date)); absentDays.forEach(d => { if (presentDays.has(d)) absentDays.delete(d); }); // present overrides a stray absent if (absentDays.size === 0 && presentDays.size >= 24) { const b = MONTH_DAYS * RATE; // ₹1500 for the perfect month result.monthly += b; result.months.push({ mk, present: presentDays.size, bonus: b }); } }); result.total = result.weekly + result.monthly; return result; } // Total actually paid to a labour (optionally filtered by an inRange(date) predicate). // A payment counts if its DATE is in range, or the WEEK it was tagged to (period) is in range — // so paying a past week's wages today still shows up when that week is filtered. function labourPaidTotal(labName, inRangeFn, projId) { const isDateKey = s => typeof s === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(s); return (data.labourPayments || []) .filter(p => p.labourName === labName) .filter(p => !inRangeFn || inRangeFn(p.date) || (isDateKey(p.period) && inRangeFn(p.period))) // Project view: count payments tagged to that project. Untagged (legacy / all-project) // payments are attributed only when the worker has exactly one assigned project. .filter(p => { if (!projId) return true; if (p.projectId != null) return String(p.projectId) === String(projId); const lab = (data.labours||[]).find(l => l.name === p.labourName); const ids = lab ? labProjectIds(lab).map(String) : []; return ids.length === 1 && ids[0] === String(projId); }) .reduce((s, p) => s + Number(p.amount || 0), 0); } // Mark attendance days Paid/Pending so Recent Attendance pills match the cumulative amount paid function recomputeLabourPaidFlags(labName) { const totalPaid = labourPaidTotal(labName); let cum = 0; (data.attendance || []) .filter(a => a.labourName === labName && a.wage > 0 && a.accountEntryId) .sort((a, b) => (a.date || '').localeCompare(b.date || '')) .forEach(a => { cum += Number(a.wage || 0); const acct = (data.accounts || []).find(e => e.id === a.accountEntryId); const isPaid = cum <= totalPaid + 0.01; if (acct) acct.status = isPaid ? 'paid' : 'pending'; a.paid = isPaid; }); } function renderLabourSection() { renderLabourRoster(); renderLabourAdvance(); renderVendorRoster(); renderSubcontractorRoster(); renderAssets(); renderAttendance(); renderDocuments(); if (typeof labourView !== 'undefined' && labourView === 'payable') renderHRPayable(); if (typeof labourView !== 'undefined' && labourView === 'payroll') renderPayrollDashboard(); if (typeof labourView !== 'undefined' && labourView === 'insights') renderLabourInsights(); if (typeof labourView !== 'undefined' && labourView === 'performance') renderPerformanceDashboard(); } // ----- Labour roster ----- function addLabour() { const name = document.getElementById('labName').value.trim(); const role = document.getElementById('labRole').value.trim(); const category = document.getElementById('labCategory').value.trim(); const group = document.getElementById('labGroup').value.trim(); const rate = Number(document.getElementById('labRate').value) || 0; const phone = document.getElementById('labPhone').value.trim(); const company = document.getElementById('labCompany').value.trim() || 'Unassigned'; const projSel = document.getElementById('labProject'); const projectIds = projSel && projSel.value ? [Number(projSel.value)] : []; if (!name) return; data.labours.push({ id: Date.now(), name, role, category, group, dailyRate: rate, phone, company, projectIds }); // Remember the rate against this role/category so it autofills next time data.roleRates = data.roleRates || {}; if (rate > 0) { if (role) data.roleRates[roleRateKey(role)] = rate; if (category) data.roleRates[roleRateKey(category)] = rate; } ['labName','labRole','labCategory','labGroup','labRate','labPhone','labCompany'].forEach(id => { const e = document.getElementById(id); if (e) e.value = ''; }); const lpSel = document.getElementById('labProject'); if (lpSel) lpSel.value = ''; saveData(); renderAll(); } // Returns a labour's assigned project IDs (migrates legacy single projectId) function labProjectIds(lab) { if (!lab) return []; if (Array.isArray(lab.projectIds)) return lab.projectIds; if (lab.projectId) return [lab.projectId]; return []; } function delLabour(id) { data.labours = data.labours.filter(x => x.id !== id); saveData(); renderAll(); } // ===== ROLE RATE CARD (fixed rate per role → autofill) ===== const roleRateKey = s => String(s ?? '').trim().toLowerCase(); function lookupRoleRate(role, category) { const rr = data.roleRates || {}; const byRole = role ? rr[roleRateKey(role)] : null; if (byRole != null) return byRole; const byCat = category ? rr[roleRateKey(category)] : null; return byCat != null ? byCat : null; } function autofillRoleRate() { const rateEl = document.getElementById('labRate'); if (!rateEl) return; if (rateEl.value && Number(rateEl.value) > 0) return; // don't overwrite a manual rate const role = document.getElementById('labRole')?.value || ''; const cat = document.getElementById('labCategory')?.value || ''; const r = lookupRoleRate(role, cat); if (r != null) rateEl.value = r; } function setRoleRate() { const role = document.getElementById('rrcRole')?.value.trim(); const rate = Number(document.getElementById('rrcRate')?.value) || 0; if (!role) { alert('Enter a role / category name'); return; } if (rate <= 0) { alert('Enter a valid rate'); return; } data.roleRates = data.roleRates || {}; data.roleRates[roleRateKey(role)] = rate; document.getElementById('rrcRole').value = ''; document.getElementById('rrcRate').value = ''; saveData(); renderLabourRoster(); } function updateRoleRate(key, val) { const rate = Number(val) || 0; data.roleRates = data.roleRates || {}; if (rate > 0) data.roleRates[key] = rate; else delete data.roleRates[key]; saveData(); } function delRoleRate(key) { if (data.roleRates) delete data.roleRates[key]; saveData(); renderLabourRoster(); } function renderRoleRateCard() { const el = document.getElementById('roleRateCardBody'); if (!el) return; data.roleRates = data.roleRates || {}; // Auto-seed from roster for roles that have no fixed rate yet (won't overwrite manual rates) (data.labours || []).forEach(l => { [l.role, l.category].forEach(r => { const k = roleRateKey(r); if (k && data.roleRates[k] == null && l.dailyRate > 0) data.roleRates[k] = l.dailyRate; }); }); const labelFor = k => { const lab = (data.labours||[]).find(l => roleRateKey(l.role)===k || roleRateKey(l.category)===k); return (lab && (roleRateKey(lab.role)===k ? lab.role : lab.category)) || k; }; const keys = Object.keys(data.roleRates).sort((a,b)=> (data.roleRates[b]||0)-(data.roleRates[a]||0)); if (!keys.length) { el.innerHTML = '
No rates set yet — add one below, e.g. Skilled 1 → ₹1350, Helper → ₹800.
'; return; } el.innerHTML = `
${keys.map(k => `
${escHtml(labelFor(k))}
`).join('')}
`; } // ===== EDIT LABOUR ===== function editLabour(id) { const lab = (data.labours||[]).find(l => l.id === id); if (!lab) return; document.getElementById('labEditModal')?.remove(); const presetRoles = ['Mason','Helper','Carpenter','Electrician','Plumber','Painter','Bar Bender','Welder','Fitter','Supervisor','Driver','Operator','Centering','Tile Layer','Plasterer','Lady Helper']; const presetCats = ['Skilled 1','Skilled 2','Semi-Skilled','Unskilled','Highly Skilled','Contract','Daily Wage','NMR']; const usedRoles = [...new Set((data.labours||[]).map(l=>(l.role||'').trim()).filter(Boolean))]; const usedCats = [...new Set((data.labours||[]).map(l=>(l.category||'').trim()).filter(Boolean))]; const usedCos = [...new Set((data.labours||[]).map(l=>(l.company||'').trim()).filter(Boolean))]; const roleOpts = [...new Set([...usedRoles, ...presetRoles])].map(r=>``).join(''); const catOpts = [...new Set([...usedCats, ...presetCats])].map(c=>``).join(''); const coOpts = usedCos.map(c=>``).join(''); const fld = (lbl, inner) => `
${lbl}
${inner}
`; const inp = (id2,val,extra='') => ``; const modal = document.createElement('div'); modal.id = 'labEditModal'; modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.4);backdrop-filter:blur(6px);z-index:9999;display:flex;align-items:center;justify-content:center;padding:16px;'; modal.addEventListener('click', e => { if (e.target===modal) modal.remove(); }); modal.innerHTML = `
✎ Edit Labour
${escHtml(lab.name)}
${fld('Name', inp('edLabName', lab.name))}
${fld('Role', inp('edLabRole', lab.role, 'list="edRoleList" onchange="autofillEditRate()"'))} ${fld('Category', inp('edLabCategory', lab.category, 'list="edCatList" onchange="autofillEditRate()"'))}
${fld('Daily Rate ₹', inp('edLabRate', lab.dailyRate, 'type="number" min="0"'))} ${fld('Phone', inp('edLabPhone', lab.phone))}
${fld('Company', inp('edLabCompany', lab.company, 'list="edCoList"'))} ${fld('Group (Gang / Mestri)', inp('edLabGroup', lab.group, 'list="edGroupList"'))} ${[...new Set((data.labours||[]).map(l=>(l.group||'').trim()).filter(Boolean))].map(g=>``).join('')} ${(data.projects||[]).length ? fld('Projects (assign one or more) — ' + (data.projects||[]).length + ' total, newest first', (() => { const assignedIds = labProjectIds(lab).map(String); const isOn = p => assignedIds.includes(String(p.id)); // assigned first, then the rest newest-first so fresh projects are immediately visible const ordered = [...(data.projects||[])].sort((a,b) => (isOn(b)-isOn(a)) || (Number(b.id)||0)-(Number(a.id)||0)); return `
${ordered.map(p=>``).join('')}
`; })()) : ''} ${roleOpts} ${catOpts} ${coOpts}
`; document.body.appendChild(modal); setTimeout(()=>document.getElementById('edLabName')?.focus(), 50); } function edLabProjFilter(q) { const needle = String(q||'').trim().toLowerCase(); document.querySelectorAll('.edLabProjLbl').forEach(l => { l.style.display = (!needle || (l.dataset.pname||'').includes(needle)) ? 'inline-flex' : 'none'; }); } function autofillEditRate() { const rateEl = document.getElementById('edLabRate'); if (!rateEl) return; if (rateEl.value && Number(rateEl.value) > 0) return; const r = lookupRoleRate(document.getElementById('edLabRole')?.value || '', document.getElementById('edLabCategory')?.value || ''); if (r != null) rateEl.value = r; } // Recompute a labour's UNPAID attendance wages (and linked pending account entries) at the current daily rate. // Settled (paid) days are left untouched so history stays accurate. function labWageMult(status) { return status === 'full' ? 1 : status === 'half' ? 0.5 : status === 'overtime' ? 1.5 : status === 'night' ? 2 : 0; } function recomputeLabourWages(lab) { if (!lab) return 0; let changed = 0; (data.attendance || []).filter(a => a.labourId === lab.id || a.labourName === lab.name).forEach(a => { const acct = a.accountEntryId ? (data.accounts || []).find(x => x.id === a.accountEntryId) : null; if (acct && acct.status === 'paid') return; // don't touch settled days const newWage = Math.round((lab.dailyRate || 0) * (a.mult != null ? Number(a.mult) : labWageMult(a.status))); if (a.wage !== newWage) { a.wage = newWage; if (acct) acct.amount = newWage; changed++; } }); return changed; } // On-demand: recalc a worker's unpaid attendance wages to their current roster rate function recalcLabourPayable(name) { const lab = (data.labours||[]).find(l => l.name === name); if (!lab) return; const n = recomputeLabourWages(lab); if (typeof recomputeLabourPaidFlags === 'function') recomputeLabourPaidFlags(lab.name); saveData(); renderAll(); if (typeof showToast === 'function') showToast(n > 0 ? (n + ' day' + (n===1?'':'s') + ' recalculated at ' + fmtINR(lab.dailyRate) + '/day') : ('Already correct at ' + fmtINR(lab.dailyRate) + '/day')); } // Per-worker bonus inclusion in the payable dashboard (default: included) function labBonusOn(name) { return !(data.labourBonusOff && data.labourBonusOff[name]); } function toggleLabBonus(name) { data.labourBonusOff = data.labourBonusOff || {}; if (data.labourBonusOff[name]) delete data.labourBonusOff[name]; else data.labourBonusOff[name] = true; saveData(); renderHRPayable(); } function setAllLabBonus(on) { data.labourBonusOff = data.labourBonusOff || {}; (data.labours || []).forEach(l => { if (on) delete data.labourBonusOff[l.name]; else data.labourBonusOff[l.name] = true; }); saveData(); renderHRPayable(); } function saveLabourEdit(id) { const lab = (data.labours||[]).find(l => l.id === id); if (!lab) return; const name = document.getElementById('edLabName').value.trim(); if (!name) { alert('Name cannot be empty'); return; } const oldRate = Number(lab.dailyRate) || 0; lab.name = name; lab.role = document.getElementById('edLabRole').value.trim(); lab.category = document.getElementById('edLabCategory').value.trim(); lab.dailyRate= Number(document.getElementById('edLabRate').value) || 0; lab.phone = document.getElementById('edLabPhone').value.trim(); lab.company = document.getElementById('edLabCompany').value.trim() || 'Unassigned'; { const g = document.getElementById('edLabGroup'); lab.group = g ? g.value.trim() : (lab.group||''); } lab.projectIds = Array.from(document.querySelectorAll('.edLabProjChk:checked')).map(c => Number(c.value)); delete lab.projectId; // Keep the rate card in sync with the edited rate data.roleRates = data.roleRates || {}; if (lab.dailyRate > 0) { if (lab.role) data.roleRates[roleRateKey(lab.role)] = lab.dailyRate; if (lab.category) data.roleRates[roleRateKey(lab.category)] = lab.dailyRate; } // Restate the worker's unpaid attendance wages at the current rate so payables always match rate × days const daysUpdated = recomputeLabourWages(lab); if (typeof recomputeLabourPaidFlags === 'function') recomputeLabourPaidFlags(lab.name); document.getElementById('labEditModal')?.remove(); saveData(); renderAll(); if (daysUpdated > 0 && typeof showToast === 'function') showToast('Saved · ' + daysUpdated + ' unpaid day' + (daysUpdated===1?'':'s') + ' recalculated at ' + fmtINR(lab.dailyRate)); } function addLabourAdvance() { const labId = Number(document.getElementById('advLabour')?.value)||0; const amount = Number(document.getElementById('advAmount')?.value)||0; const date = document.getElementById('advDate')?.value || dayKey(new Date()); const note = document.getElementById('advNote')?.value.trim()||''; if (!labId || !amount) { alert('Select a worker and enter amount'); return; } const lab = (data.labours||[]).find(l=>l.id===labId); if (!lab) return; lab.advances = lab.advances || []; lab.advances.push({ id:Date.now(), date, amount, note, mode:'cash', status:'given' }); ['advAmount','advNote'].forEach(id=>{ const e=document.getElementById(id); if(e) e.value=''; }); saveData(); renderLabourAdvance(); } function renderLabourAdvance() { const labours = data.labours || []; // Populate worker dropdown const sel = document.getElementById('advLabour'); if (sel) { sel.innerHTML = '' + labours.map(l=>``).join(''); } // Render advance list const el = document.getElementById('labourAdvanceList'); if (!el) return; // Collect all advances across all labours const allAdv = []; labours.forEach(l => { (l.advances||[]).forEach(adv => allAdv.push({...adv, labName:l.name, labId:l.id})); }); allAdv.sort((a,b)=>b.date.localeCompare(a.date)); const totalAdv = allAdv.reduce((s,a)=>s+a.amount,0); const meta = document.getElementById('labAdvMeta'); if (meta) meta.textContent = allAdv.length + ' advances · ' + fmtINR(totalAdv) + ' total'; if (!allAdv.length) { el.innerHTML = '
No advances recorded yet
'; return; } el.innerHTML = `
${allAdv.map(adv=>``).join('')}
WorkerDateAmountNoteModeStatus
${escHtml(adv.labName)} ${adv.date} ${fmtINR(adv.amount)} ${escHtml(adv.note||'—')} ${adv.mode||'cash'}
Total ${fmtINR(totalAdv)} Cleared: ${fmtINR(allAdv.filter(a=>a.status==='cleared').reduce((s,a)=>s+a.amount,0))}  ·  Pending: ${fmtINR(allAdv.filter(a=>a.status!=='cleared').reduce((s,a)=>s+a.amount,0))}
`; } function renderLabourRoster() { const tbody = document.getElementById('labourTableBody'); if (!tbody) return; data.roleRates = data.roleRates || {}; const list = data.labours || []; const meta = document.getElementById('labourCountMeta'); if (meta) meta.textContent = `${list.length} worker${list.length===1?'':'s'}`; // Role combobox: preset roles + any custom ones already typed const presetRoles = ['Mason','Helper','Carpenter','Electrician','Plumber','Painter','Bar Bender','Welder','Fitter','Supervisor','Driver','Operator','Centering','Tile Layer','Plasterer','Lady Helper']; const usedRoles = [...new Set((data.labours||[]).map(l => (l.role||'').trim()).filter(Boolean))]; fillDatalist('labRoleList', [...new Set([...usedRoles, ...presetRoles])]); // Category combobox: preset skill grades + any custom ones already typed const presetCats = ['Skilled 1','Skilled 2','Semi-Skilled','Unskilled','Highly Skilled','Contract','Daily Wage','NMR']; const usedCats = [...new Set((data.labours||[]).map(l => (l.category||'').trim()).filter(Boolean))]; fillDatalist('labCategoryList', [...new Set([...usedCats, ...presetCats])]); fillDatalist('labGroupList', [...new Set((data.labours||[]).map(l => (l.group||'').trim()).filter(Boolean))]); renderRoleRateCard(); // Project assignment dropdown in Add Labour const labProjSel = document.getElementById('labProject'); if (labProjSel) { const prev = labProjSel.value; labProjSel.innerHTML = '' + (data.projects || []).map(p => ``).join(''); if ((data.projects || []).some(p => String(p.id) === prev)) labProjSel.value = prev; } const projName = id => { const p = (data.projects||[]).find(x => x.id === id); return p ? projNick(p) : null; }; const projPills = l => { const ids = labProjectIds(l).map(projName).filter(Boolean); return ids.length ? ids.map(n=>`${escHtml(n)}`).join('') : ''; }; tbody.innerHTML = list.length ? list.map(l => ` ${escHtml(l.name)}${l.group?`
👥 ${escHtml(l.group)}
`:''} ${escHtml(l.role||'—')} ${l.category?`${escHtml(l.category)}`:''} ${projPills(l)} ${fmtINR(l.dailyRate)} ${escHtml(l.phone||'—')} ${escHtml(l.company||'Unassigned')} `).join('') : 'No labour added yet'; } // ----- Vendors roster ----- function addVendor() { const name = document.getElementById('vnName').value.trim(); const category = document.getElementById('vnCategory').value; const phone = document.getElementById('vnPhone').value.trim(); const gstin = document.getElementById('vnGstin').value.trim(); const company = document.getElementById('vnCompany').value.trim() || 'Unassigned'; if (!name) return; data.vendors.push({ id: Date.now(), name, category, phone, gstin, company }); ['vnName','vnPhone','vnGstin','vnCompany'].forEach(id => { const e = document.getElementById(id); if (e) e.value = ''; }); saveData(); renderAll(); } function delVendor(id) { data.vendors = data.vendors.filter(x => x.id !== id); saveData(); renderAll(); } // ===== LABOUR SUPPLY — DAILY MUSTER ===== const LSUP_CATS = ['Mason','Carpenter','Helper','Lady Helper','Bar Bender','Centering','Electrician','Plumber','Painter','Welder','Fitter','Operator','Driver','Mestri']; function lsupReadDraft() { const arr = []; document.querySelectorAll('.lsup-cat').forEach(catEl => { const i = catEl.dataset.i; arr.push({ cat: catEl.value, count: document.querySelector('.lsup-count[data-i="'+i+'"]').value, rate: document.querySelector('.lsup-rate[data-i="'+i+'"]').value }); }); window._lsupDraft = arr.length ? arr : [{ cat:'', count:'', rate:'' }]; } function renderLsupRows() { const host = document.getElementById('lsupRows'); if (!host) return; if (!Array.isArray(window._lsupDraft) || !window._lsupDraft.length) window._lsupDraft = [{ cat:'', count:'', rate:'' }]; host.innerHTML = window._lsupDraft.map((r,i) => { const line = (Number(r.count)||0) * (Number(r.rate)||0); return `
× ${fmtINR(line)}
`; }).join(''); fillDatalist('lsupCatList', [...new Set([...LSUP_CATS, ...Object.keys(data.labourSupplyRates||{})])]); lsupRecalc(); } function lsupRowChange(i) { const catEl = document.querySelector('.lsup-cat[data-i="'+i+'"]'); const countEl = document.querySelector('.lsup-count[data-i="'+i+'"]'); const rateEl = document.querySelector('.lsup-rate[data-i="'+i+'"]'); if (catEl && rateEl && catEl.value && !rateEl.value && data.labourSupplyRates && data.labourSupplyRates[catEl.value]) rateEl.value = data.labourSupplyRates[catEl.value]; const line = (Number(countEl.value)||0) * (Number(rateEl?rateEl.value:0)||0); const lineEl = document.getElementById('lsupLine'+i); if (lineEl) lineEl.textContent = fmtINR(line); lsupRecalc(); } function lsupRecalc() { let total = 0, nos = 0; document.querySelectorAll('.lsup-count').forEach(cEl => { const i = cEl.dataset.i; const rEl = document.querySelector('.lsup-rate[data-i="'+i+'"]'); total += (Number(cEl.value)||0) * (Number(rEl?rEl.value:0)||0); nos += (Number(cEl.value)||0); }); const t = document.getElementById('lsupTotal'); if (t) t.textContent = fmtINR(total); const m = document.getElementById('lsupHeadMeta'); if (m) m.textContent = nos > 0 ? (nos + ' present · ' + fmtINR(total)) : ''; } function lsupAddRow() { lsupReadDraft(); window._lsupDraft.push({ cat:'', count:'', rate:'' }); renderLsupRows(); } function lsupDelRow(i) { lsupReadDraft(); window._lsupDraft.splice(i,1); if (!window._lsupDraft.length) window._lsupDraft=[{cat:'',count:'',rate:''}]; renderLsupRows(); } function lsupAddDay() { lsupReadDraft(); const vName = document.getElementById('lsupVendor').value; if (!vName) { alert('Select a labour-supply vendor.'); return; } const date = document.getElementById('lsupDate').value || dayKey(new Date()); const projSel = document.getElementById('lsupProject'); const projectId = projSel && projSel.value ? Number(projSel.value) : null; const lines = window._lsupDraft.map(r => ({ cat:(r.cat||'').trim(), count:Number(r.count)||0, rate:Number(r.rate)||0 })).filter(l => l.cat && l.count > 0); if (!lines.length) { alert('Add at least one category with a head-count.'); return; } const total = lines.reduce((s,l)=>s+l.count*l.rate,0); data.labourSupplyRates = data.labourSupplyRates || {}; lines.forEach(l => { if (l.rate > 0) data.labourSupplyRates[l.cat] = l.rate; }); const proj = projectId ? (data.projects||[]).find(p=>p.id===projectId) : null; const entryId = Date.now(); const acctId = entryId + 1; data.accounts = data.accounts || []; data.accounts.push({ id: acctId, type:'vendor-payment', projectId, company: proj?proj.company:null, bank:null, party:vName, amount:total, date, status:'pending', dueDate:'', note:'Labour supply '+date+' — '+lines.map(l=>l.cat+' '+l.count).join(', ') }); data.labourSupply = data.labourSupply || []; data.labourSupply.push({ id:entryId, vendorName:vName, date, projectId, lines, total, accountEntryId:acctId }); // keep categories + rates for next day, clear the counts window._lsupDraft = lines.map(l => ({ cat:l.cat, count:'', rate:l.rate })); saveData(); renderAll(); if (typeof showToast === 'function') showToast('Labour supply day added · ' + fmtINR(total)); } function delLabourSupplyDay(id) { const e = (data.labourSupply||[]).find(x=>x.id===id); if (e && e.accountEntryId) data.accounts = (data.accounts||[]).filter(a => a.id !== e.accountEntryId); data.labourSupply = (data.labourSupply||[]).filter(x => x.id !== id); saveData(); renderAll(); } // Toggle whole-week muster mode: swaps the single-day date for a week + weekday picker function lsupToggleWeekly() { const on = document.getElementById('lsupWeekly')?.checked; const bar = document.getElementById('lsupWeekBar'); const dateEl = document.getElementById('lsupDate'); const dayBtn = document.getElementById('lsupAddDayBtn'); const weekBtn = document.getElementById('lsupAddWeekBtn'); if (bar) bar.style.display = on ? 'flex' : 'none'; if (dateEl) dateEl.style.display = on ? 'none' : ''; if (dayBtn) dayBtn.style.display = on ? 'none' : ''; if (weekBtn) weekBtn.style.display = on ? '' : 'none'; if (on) { const wk = document.getElementById('lsupWeek'); if (wk && !wk.options.length) { const o = []; const n = new Date(); n.setHours(0,0,0,0); const sun = new Date(n); sun.setDate(n.getDate() - n.getDay()); for (let i = 0; i < 12; i++) { const s = new Date(sun); s.setDate(sun.getDate() - i*7); const e = new Date(s); e.setDate(s.getDate() + 6); const w = getISOWeekNumber(s).week; o.push(``); } wk.innerHTML = o.join(''); } } } // Add the same muster for every ticked weekday of the chosen week (skips days already logged for that vendor) function lsupAddWeek() { lsupReadDraft(); const vName = document.getElementById('lsupVendor').value; if (!vName) { alert('Select a labour-supply vendor.'); return; } const sunKey = document.getElementById('lsupWeek')?.value; if (!sunKey) { alert('Pick a week.'); return; } const ticks = [...document.querySelectorAll('.lsupDayChk')].filter(c=>c.checked).map(c=>Number(c.dataset.dow)); if (!ticks.length) { alert('Tick at least one weekday.'); return; } const projSel = document.getElementById('lsupProject'); const projectId = projSel && projSel.value ? Number(projSel.value) : null; const proj = projectId ? (data.projects||[]).find(p=>p.id===projectId) : null; const lines = window._lsupDraft.map(r => ({ cat:(r.cat||'').trim(), count:Number(r.count)||0, rate:Number(r.rate)||0 })).filter(l => l.cat && l.count > 0); if (!lines.length) { alert('Add at least one category with a head-count.'); return; } const dayTotal = lines.reduce((s,l)=>s+l.count*l.rate,0); data.labourSupplyRates = data.labourSupplyRates || {}; lines.forEach(l => { if (l.rate > 0) data.labourSupplyRates[l.cat] = l.rate; }); data.accounts = data.accounts || []; data.labourSupply = data.labourSupply || []; const sun = new Date(sunKey + 'T00:00:00'); let added = 0, skipped = 0, grand = 0, k = 0; ticks.sort((a,b)=>a-b).forEach(dow => { const d = new Date(sun); d.setDate(sun.getDate() + dow); const date = dayKey(d); if ((data.labourSupply||[]).some(e => e.vendorName === vName && e.date === date)) { skipped++; return; } const entryId = Date.now() + k; const acctId = entryId + 500000; k++; data.accounts.push({ id: acctId, type:'vendor-payment', projectId, company: proj?proj.company:null, bank:null, party:vName, amount:dayTotal, date, status:'pending', dueDate:'', note:'Labour supply '+date+' — '+lines.map(l=>l.cat+' '+l.count).join(', ') }); data.labourSupply.push({ id:entryId, vendorName:vName, date, projectId, lines: lines.map(l=>({...l})), total:dayTotal, accountEntryId:acctId }); added++; grand += dayTotal; }); window._lsupDraft = lines.map(l => ({ cat:l.cat, count:'', rate:l.rate })); saveData(); renderAll(); if (typeof showToast === 'function') showToast('Added ' + added + ' day' + (added===1?'':'s') + ' · ' + fmtINR(grand) + (skipped?(' · '+skipped+' already logged, skipped'):'')); } function renderLabourSupply() { // Vendor + project selects const vSel = document.getElementById('lsupVendor'); if (vSel) { const prev = vSel.value; const lsVendors = (data.vendors||[]).filter(v => (v.category||'') === 'Labour Supply'); vSel.innerHTML = '' + (lsVendors.length ? lsVendors : (data.vendors||[])).map(v => ``).join(''); if (vSel.querySelector(`option[value="${prev}"]`)) vSel.value = prev; } const pSel = document.getElementById('lsupProject'); if (pSel) { const prev = pSel.value; pSel.innerHTML = '' + (data.projects||[]).map(p=>``).join(''); if ((data.projects||[]).some(p=>String(p.id)===prev)) pSel.value = prev; } const dEl = document.getElementById('lsupDate'); if (dEl && !dEl.value) dEl.value = dayKey(new Date()); renderLsupRows(); // Day book list const list = document.getElementById('lsupList'); if (!list) return; const entries = (data.labourSupply||[]).slice().sort((a,b)=>(b.date||'').localeCompare(a.date||'')); const meta = document.getElementById('lsupBookMeta'); const grand = entries.reduce((s,e)=>s+Number(e.total||0),0); if (meta) meta.textContent = entries.length + ' day' + (entries.length===1?'':'s') + ' · ' + fmtINR(grand); if (!entries.length) { list.innerHTML = '
No labour-supply days recorded yet.
'; return; } const projName = id => { const p=(data.projects||[]).find(x=>x.id===id); return p?projNick(p):''; }; list.innerHTML = `
${entries.map(e=>{ const nos = e.lines.reduce((s,l)=>s+Number(l.count||0),0); const brk = e.lines.map(l=>`${escHtml(l.cat)} ${l.count}×${fmtINR(l.rate)}`).join(', '); return ``; }).join('')}
DateVendorProjectMusterNosAmount
${e.date} ${escHtml(e.vendorName)} ${escHtml(projName(e.projectId)||'—')} ${brk} ${nos} ${fmtINR(e.total)}
TOTAL${fmtINR(grand)}
`; } function renderVendorRoster() { const tbody = document.getElementById('vendorTableBody'); if (!tbody) return; if (typeof renderLabourSupply === 'function') renderLabourSupply(); const list = data.vendors || []; const meta = document.getElementById('vendorCountMeta'); if (meta) meta.textContent = `${list.length} vendor${list.length===1?'':'s'}`; tbody.innerHTML = list.length ? list.map(v => ` ${escHtml(v.name)} ${escHtml(v.category||'Other')} ${escHtml(v.phone||'—')} ${escHtml(v.gstin||'—')} ${escHtml(v.company||'Unassigned')} `).join('') : 'No vendors added yet'; } // ----- Subcontractors roster ----- function addSubcontractor() { const name = document.getElementById('scName').value.trim(); const trade = document.getElementById('scTrade').value.trim(); const rateBasis = document.getElementById('scRateBasis').value.trim(); const phone = document.getElementById('scPhone').value.trim(); const company = document.getElementById('scCompany').value.trim() || 'Unassigned'; if (!name) return; data.subcontractors.push({ id: Date.now(), name, trade, rateBasis, phone, company }); ['scName','scTrade','scRateBasis','scPhone','scCompany'].forEach(id => { const e = document.getElementById(id); if (e) e.value = ''; }); saveData(); renderAll(); } function delSubcontractor(id) { data.subcontractors = data.subcontractors.filter(x => x.id !== id); saveData(); renderAll(); } function renderSubcontractorRoster() { const tbody = document.getElementById('subcontractorTableBody'); if (!tbody) return; const list = data.subcontractors || []; const meta = document.getElementById('subcontractorCountMeta'); if (meta) meta.textContent = `${list.length} subcontractor${list.length===1?'':'s'}`; tbody.innerHTML = list.length ? list.map(s => ` ${escHtml(s.name)} ${escHtml(s.trade||'—')} ${escHtml(s.rateBasis||'—')} ${escHtml(s.phone||'—')} ${escHtml(s.company||'Unassigned')} `).join('') : 'No subcontractors added yet'; } // ----- Assets & Equipment ----- function addAsset() { const name = document.getElementById('astName').value.trim(); const type = document.getElementById('astType').value.trim(); const status = document.getElementById('astStatus').value; const projectId = document.getElementById('astProject').value ? Number(document.getElementById('astProject').value) : null; const rate = Number(document.getElementById('astRate').value) || 0; const company = document.getElementById('astCompany').value.trim() || 'Unassigned'; if (!name) return; data.assets.push({ id: Date.now(), name, type, status, projectId, rate, company }); ['astName','astType','astRate','astCompany'].forEach(id => { const e = document.getElementById(id); if (e) e.value = ''; }); saveData(); renderAll(); } function delAsset(id) { data.assets = data.assets.filter(x => x.id !== id); saveData(); renderAll(); } function cycleAssetStatus(id) { const a = data.assets.find(x => x.id === id); if (!a) return; const order = ['available', 'in-use', 'maintenance']; a.status = order[(order.indexOf(a.status) + 1) % order.length]; saveData(); renderAll(); } function renderAssets() { const projSel = document.getElementById('astProject'); 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; } const tbody = document.getElementById('assetTableBody'); if (!tbody) return; const list = data.assets || []; const meta = document.getElementById('assetCountMeta'); if (meta) meta.textContent = `${list.length} asset${list.length===1?'':'s'}`; const statusLabel = { available: 'Available', 'in-use': 'In Use', maintenance: 'Maintenance' }; const statusPill = { available: 'pill-green', 'in-use': 'pill-sky', maintenance: 'pill-amber' }; tbody.innerHTML = list.length ? list.map(a => { const proj = a.projectId ? data.projects.find(p => p.id === a.projectId) : null; return ` ${escHtml(a.name)} ${escHtml(a.type||'—')} ${statusLabel[a.status]||a.status} ${proj?escHtml(projNick(proj)):'—'} ${fmtINR(a.rate||0)} ${escHtml(a.company||'Unassigned')} `; }).join('') : 'No assets or equipment added yet'; } // ----- Attendance ----- function populateAttendanceSelects() { const labSel = document.getElementById('attLabour'); if (labSel) { // Gang + Category filter dropdowns const gangSel = document.getElementById('attGang'); if (gangSel) { const prevG = gangSel.value; const gangs = [...new Set((data.labours||[]).map(l=>(l.group||'').trim()).filter(Boolean))].sort((a,b)=>a.localeCompare(b)); gangSel.innerHTML = '' + gangs.map(g=>``).join(''); if (gangs.includes(prevG)) gangSel.value = prevG; } const catSel = document.getElementById('attCategory'); if (catSel) { const prevC = catSel.value; const cats = [...new Set((data.labours||[]).map(l=>(l.category||'').trim()).filter(Boolean))].sort((a,b)=>a.localeCompare(b)); catSel.innerHTML = '' + cats.map(c=>``).join(''); if (cats.includes(prevC)) catSel.value = prevC; } const prev = labSel.value; if (typeof attFilterLabour === 'function') attFilterLabour(); if ((data.labours || []).some(l => String(l.id) === prev)) labSel.value = prev; } const projSel = document.getElementById('attProject'); 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; } // If a worker is already chosen, keep the project list restricted to their assigned projects if (labSel && labSel.value && typeof onQuickLabourChange === 'function') onQuickLabourChange(); const dateEl = document.getElementById('attDate'); if (dateEl && !dateEl.value) dateEl.value = dayKey(new Date()); const bulkWk = document.getElementById('bulkWeekDate'); if (bulkWk && !bulkWk.value) { bulkWk.value = dayKey(new Date()); updateBulkWeekLabel(); } } // ===== WEEK VIEW ATTENDANCE ENTRY ===== let attWeekAnchor = new Date(); let attCatFilter = ''; function setAttCatFilter(v) { attCatFilter = v || ''; renderAttendanceWeek(); } function setAttMode(mode) { document.querySelectorAll('#attModeSwitch .seg-btn').forEach(b => b.classList.toggle('active', b.dataset.attmode === mode)); const single = document.getElementById('attModeSingle'); const week = document.getElementById('attModeWeek'); if (single) single.style.display = mode === 'week' ? 'none' : ''; if (week) week.style.display = mode === 'week' ? '' : 'none'; if (mode === 'week') renderAttendanceWeek(); } function attWeekDays() { // Sunday → Saturday of the week containing attWeekAnchor const a = new Date(attWeekAnchor); a.setHours(0,0,0,0); const sun = new Date(a); sun.setDate(a.getDate() - a.getDay()); // a.getDay(): 0=Sun return Array.from({length:7}, (_,i) => { const d = new Date(sun); d.setDate(sun.getDate()+i); return d; }); } function attWeekShift(n, reset) { if (reset) attWeekAnchor = new Date(); else attWeekAnchor.setDate(attWeekAnchor.getDate() + n*7); renderAttendanceWeek(); } function toggleAttSunday(checked) { data.attIncludeSunday = checked; saveData(); renderAttendanceWeek(); } // Returns a numeric multiplier (1=full, 0.5=half, 0=absent, 1.5/2=OT) or null for blank/invalid function interpretAttVal(raw) { const s = String(raw ?? '').trim().toLowerCase(); if (s === '') return null; if (s === 'x' || s === 'p' || s === 'present' || s === '✓') return 1; if (s === 'a' || s === 'absent') return 0; if (s === 'h' || s === 'half') return 0.5; const n = parseFloat(s.replace(/[^\d.]/g,'')); return isNaN(n) ? null : n; } function attCellClass(val) { if (val == null) return ''; if (val <= 0) return 'att-absent'; if (val < 1) return 'att-half'; if (val > 1) return 'att-ot'; return 'att-full'; } function attRecalcRow(input) { const val = interpretAttVal(input.value); input.classList.remove('att-full','att-half','att-absent','att-ot'); const cls = attCellClass(val); if (cls) input.classList.add(cls); const row = input.closest('tr'); if (!row) return; let total = 0; row.querySelectorAll('.att-cell').forEach(c => { const v = interpretAttVal(c.value); if (v != null && v > 0) total += v; }); const tot = row.querySelector('.att-total'); if (tot) tot.textContent = total ? (Math.round(total*10)/10) : '—'; } function renderAttendanceWeek() { const grid = document.getElementById('attWeekGrid'); if (!grid) return; // project dropdown const projSel = document.getElementById('attWeekProject'); 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; } // Sunday toggle (persisted) const includeSun = data.attIncludeSunday !== false; const sunChk = document.getElementById('attIncludeSunday'); if (sunChk) sunChk.checked = includeSun; let days = attWeekDays(); if (!includeSun) days = days.filter(d => d.getDay() !== 0); // drop Sunday const dowNames = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat']; const label = document.getElementById('attWeekLabel'); if (label) { const opt = {day:'numeric', month:'short'}; label.textContent = days[0].toLocaleDateString('en-IN',opt) + ' – ' + days[days.length-1].toLocaleDateString('en-IN',{...opt, year:'numeric'}); } // Category filter dropdown (persisted in attCatFilter) const catSel = document.getElementById('attCatFilter'); if (catSel) { const cats = [...new Set((data.labours || []).map(l => (l.category || '').trim()).filter(Boolean))].sort(); catSel.innerHTML = '' + cats.map(c => ``).join(''); catSel.value = (typeof attCatFilter !== 'undefined' ? attCatFilter : '') || ''; } let labours = data.labours || []; const projFilter = document.getElementById('attWeekProject')?.value || ''; if (projFilter) labours = labours.filter(l => labProjectIds(l).map(String).includes(projFilter)); if (typeof attCatFilter !== 'undefined' && attCatFilter) labours = labours.filter(l => (l.category || '') === attCatFilter); if (!labours.length) { grid.innerHTML = `${(data.labours||[]).length ? 'No labour match this project/category filter.' : 'No labour in roster yet — add workers in the Labour tab first.'}`; return; } // The currently selected project "bucket" (null = No project / unassigned) const selProj = projFilter ? Number(projFilter) : null; // multiplier value of an attendance entry const entryVal = (a) => { const lab = labours.find(l => l.id === a.labourId) || data.labours.find(l => l.name === a.labourName) || {}; if (a.status === 'absent') return 0; if (lab.dailyRate > 0) return Math.round((a.wage / lab.dailyRate) * 2) / 2; return a.status === 'half' ? 0.5 : a.status === 'overtime' ? 1.5 : a.status === 'night' ? 2 : 1; }; // index all attendance entries per labour+date (array) const cellEntries = {}; (data.attendance || []).forEach(a => { const k = (a.labourId ?? a.labourName) + '|' + a.date; (cellEntries[k] = cellEntries[k] || []).push(a); }); const head = ` Labour ${days.map(d => `${dowNames[d.getDay()]}${d.getDate()}/${d.getMonth()+1}`).join('')} Total `; const body = '' + labours.map(lab => { let rowSel = 0, rowAll = 0; const cells = days.map(d => { const key = dayKey(d); const entries = (cellEntries[(lab.id) + '|' + key] || cellEntries[(lab.name) + '|' + key] || []); const mine = entries.filter(a => (a.projectId ?? null) === selProj); const other = entries.filter(a => (a.projectId ?? null) !== selProj); const mineVal = mine.reduce((s,a)=>s+entryVal(a),0); const otherVal = other.reduce((s,a)=>s+entryVal(a),0); if (mineVal > 0) rowSel += mineVal; rowAll += mineVal + otherVal; const disp = mine.length ? String(mineVal) : ''; const cls = mine.length ? attCellClass(mineVal) : ''; const badge = otherVal > 0 ? `+${Math.round(otherVal*10)/10}` : ''; return `${badge}`; }).join(''); const meta = [lab.role, lab.category].filter(Boolean).join(' · '); const totalCell = rowAll > rowSel ? `${rowSel ? (Math.round(rowSel*10)/10) : '0'}Σ${Math.round(rowAll*10)/10}` : (rowSel ? (Math.round(rowSel*10)/10) : '—'); return ` ${escHtml(lab.name)}${meta?` ${escHtml(meta)}`:''} ${cells} ${totalCell} `; }).join('') + ''; grid.innerHTML = head + body; const msg = document.getElementById('attWeekMsg'); if (msg) msg.textContent = selProj ? '' : ''; } function saveAttendanceWeek() { const projId = document.getElementById('attWeekProject').value ? Number(document.getElementById('attWeekProject').value) : null; const proj = projId ? data.projects.find(p => p.id === projId) : null; const grid = document.getElementById('attWeekGrid'); if (!grid) return; data.attendance = Array.isArray(data.attendance) ? data.attendance : []; data.accounts = Array.isArray(data.accounts) ? data.accounts : []; let saved = 0, idTick = Date.now(); grid.querySelectorAll('.att-cell').forEach(cell => { const val = interpretAttVal(cell.value); if (val == null) return; // blank → skip const lab = data.labours.find(l => l.id === Number(cell.dataset.lid)); if (!lab) return; const date = cell.dataset.day; // upsert ONLY this project bucket: remove existing entries for labour+date+THIS project, // leaving other projects' entries for the same day untouched (split-day support). const sameBucket = a => (a.labourId === lab.id || a.labourName === lab.name) && a.date === date && (a.projectId ?? null) === projId; data.attendance.filter(sameBucket).forEach(a => { if (a.accountEntryId) data.accounts = data.accounts.filter(e => e.id !== a.accountEntryId); }); data.attendance = data.attendance.filter(a => !sameBucket(a)); const status = val <= 0 ? 'absent' : val < 1 ? 'half' : val === 1 ? 'full' : val <= 1.5 ? 'overtime' : 'night'; const wage = val > 0 ? Math.round((lab.dailyRate || 0) * val) : 0; const att = { id: idTick++, labourId: lab.id, labourName: lab.name, projectId: projId, date, status, wage, accountEntryId: null }; if (wage > 0) { const acctId = idTick++; const dayLabel = val === 1 ? 'full day' : val === 0.5 ? 'half day' : val + ' day'; 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 ${dayLabel} — ${date}${proj ? ' ('+projNick(proj)+')' : ''}` }); att.accountEntryId = acctId; } data.attendance.push(att); saved++; }); const msg = document.getElementById('attWeekMsg'); if (msg) msg.textContent = saved ? `✅ Saved ${saved} entries for the week` : '⚠️ No cells filled in'; if (typeof showToast === 'function') { showToast(saved ? `✅ Week saved — ${saved} ${saved===1?'entry':'entries'} recorded` : '⚠️ Nothing to save — fill in some cells first'); } saveData(); renderAll(); } // ===== BULK ATTENDANCE UPLOAD ===== let bulkAttRows = []; // pending rows to confirm let bulkAttCoverKeys = []; // name|date cells the imported file is authoritative over (incl. blanks) // Sun → Sat week for an arbitrary anchor date function weekSunToSat(anchor) { const a = new Date(anchor); a.setHours(0,0,0,0); const sun = new Date(a); sun.setDate(a.getDate() - a.getDay()); return Array.from({length:7}, (_,i) => { const d = new Date(sun); d.setDate(sun.getDate()+i); return d; }); } function bulkWeekAnchor() { const el = document.getElementById('bulkWeekDate'); const v = el && el.value ? new Date(el.value) : new Date(); return isNaN(v) ? new Date() : v; } function bulkWeekShift(n) { const el = document.getElementById('bulkWeekDate'); const base = bulkWeekAnchor(); base.setDate(base.getDate() + n*7); if (el) el.value = dayKey(base); updateBulkWeekLabel(); } function updateBulkWeekLabel() { const el = document.getElementById('bulkWeekDate'); if (el && !el.value) el.value = dayKey(new Date()); const days = weekSunToSat(bulkWeekAnchor()); if (el) el.value = dayKey(days[0]); // snap field to the Sunday (week start) const lbl = document.getElementById('bulkWeekLabel'); if (lbl) { const opt = {day:'numeric', month:'short'}; lbl.textContent = days[0].toLocaleDateString('en-IN',opt) + ' (Sun) – ' + days[6].toLocaleDateString('en-IN',{...opt, year:'numeric'}) + ' (Sat)'; } } function downloadAttendanceSample() { // Generate weekly format CSV (worker rows × date cols) for the SELECTED week (Sun → Sat) const weekDates = weekSunToSat(bulkWeekAnchor()); const fmt = d => `${d.getDate()}/${d.getMonth()+1}/${d.getFullYear()}`; const fmtKey = d => dayKey(d); const { week } = getISOWeekNumber(weekDates[0]); const workers = (data.labours||[]).length ? (data.labours||[]) : [ {name:'ROSHAN', dailyRate:1350}, {name:'VEERAKUMAR', dailyRate:1300}, {name:'RAJA', dailyRate:1100}, {name:'Albert', dailyRate:800}, {name:'SENTHIL', dailyRate:800}, {name:'Aasirvatham',dailyRate:800}, {name:'Muthupandi', dailyRate:800}, {name:'Jeyaram', dailyRate:800}, {name:'Veerasingam',dailyRate:800} ]; // 7-day sample patterns (Sun → Sat) const sampleAtt = [ [0,1,1,1,1,1,1], [0,0,1,1.5,1.5,1,1], [1,1,1.5,1.5,1.5,1.5,1], [0,0,1,1,1,1,1], [0,0,1,1.5,1.5,1,1], [0,0,1,1,1,1,1], [1,1,1.5,1.5,1.5,1.5,1], [1,1,1.5,1,0,0,1], [0,0,0,1,1,1,1] ]; const dateHeaders = weekDates.map(fmt); const header1 = ['', ...dateHeaders, 'TOTAL', 'SALARY', 'BONUS', 'PAYABLE', 'ADVANCE']; const rows = [ [`WEEK ${week} – ${weekDates[0].toLocaleDateString('en-IN',{day:'numeric',month:'short',year:'numeric'})} to ${weekDates[6].toLocaleDateString('en-IN',{day:'numeric',month:'short',year:'numeric'})}`], [], header1 ]; let bonusTot=0, payTot=0, advTot=0; workers.forEach((w, wi) => { const att = sampleAtt[wi] || Array(7).fill(1); const total = att.reduce((s,v)=>s+Number(v||0),0); const rate = w.dailyRate || 600; const bonus = Math.round(total * 50); const payable = Math.round(total * rate + bonus); const adv = wi === 0 ? 1000 : 0; bonusTot += bonus; payTot += payable; if(adv) advTot += adv; rows.push([w.name, ...att.map(v=>v), total, rate, bonus, payable, adv||'']); }); rows.push(['', ...Array(7).fill(''), '', '', bonusTot, payTot, advTot||'']); const csv = 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 a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'Attendance_Week' + week + '_' + fmtKey(weekDates[0]) + '.csv'; a.click(); } function importBulkAttendance(event) { const msgEl = document.getElementById('bulkAttMsg'); const file = event.target && event.target.files && event.target.files[0]; const reset = () => { if (event.target) event.target.value = ''; }; if (!file) { reset(); return; } if (msgEl) msgEl.textContent = '⏳ Reading ' + file.name + '…'; const name = file.name.toLowerCase(); const reader = new FileReader(); if (name.endsWith('.csv')) { reader.onload = e => { try { parseBulkAttCSV(String(e.target.result || '')); } catch (err) { if (msgEl) msgEl.textContent = '❌ ' + err.message; } reset(); }; reader.onerror = () => { if (msgEl) msgEl.textContent = '❌ Could not read file.'; reset(); }; reader.readAsText(file); } else if (name.endsWith('.xlsx') || name.endsWith('.xls') || name.endsWith('.numbers')) { if (typeof XLSX === 'undefined') { if (msgEl) msgEl.textContent = '❌ Spreadsheet reader not loaded — try the CSV format.'; reset(); return; } reader.onload = e => { try { const wb = XLSX.read(new Uint8Array(e.target.result), { type: 'array' }); const ws = wb.Sheets[wb.SheetNames[0]]; parseBulkAttCSV(XLSX.utils.sheet_to_csv(ws)); } catch (err) { if (msgEl) msgEl.textContent = '❌ Could not read this file — export it as CSV or Excel and retry. (' + err.message + ')'; } reset(); }; reader.onerror = () => { if (msgEl) msgEl.textContent = '❌ Could not read file.'; reset(); }; reader.readAsArrayBuffer(file); } else { if (msgEl) msgEl.textContent = '❌ Please upload a .csv, .xlsx or .numbers file.'; reset(); } } function parseBulkAttCSV(csvText) { const msgEl = document.getElementById('bulkAttMsg'); const lines = csvText.trim().split(/\r?\n/).filter(l => l.trim()); if (lines.length < 2) { if (msgEl) msgEl.textContent = '\u274c File is empty.'; return; } const parseRow = line => { const cols=[]; let cur='', inQ=false; for (const ch of line) { if (ch==='"') inQ=!inQ; else if (ch===','&&!inQ){cols.push(cur.trim());cur='';} else cur+=ch; } cols.push(cur.trim()); return cols.map(c=>c.replace(/^["']|["']$/g,'').trim()); }; // Find header row (skip week-label row if present) let hdrIdx = 0; for (let i=0; i= 6 && (c[1]&&(/\d/.test(c[1]) || c[1].toLowerCase().includes('date')))) { hdrIdx = i; break; } } const headers = parseRow(lines[hdrIdx]).map(h=>h.toLowerCase()); // Detect WEEKLY format: col0=name, col1..N = dates const isDateLike = h => /^\d{1,2}[\/\-]\d{1,2}[\/\-]\d{2,4}$/.test(h) || /^\d{4}[\/\-]\d{1,2}[\/\-]\d{1,2}$/.test(h); const dateCols = []; headers.slice(1).forEach((h,i) => { if (isDateLike(h)) dateCols.push({h, col:i+1}); }); const parseDate = raw => { if (!raw) return dayKey(new Date()); const s = raw.trim().replace(/\./g,'/'); if (s.includes('/') || s.includes('-')) { const sep = s.includes('/')?'/':'-'; const p = s.split(sep); if (p.length===3) { if (p[0].length===4) return p[0]+'-'+p[1].padStart(2,'0')+'-'+p[2].padStart(2,'0'); return (p[2].length===2?'20'+p[2]:p[2])+'-'+p[1].padStart(2,'0')+'-'+p[0].padStart(2,'0'); } } return dayKey(new Date()); }; const salCol = headers.findIndex(h=>h.includes('salary')||h.includes('rate')); const rows = []; const coverKeys = []; if (dateCols.length >= 2) { // ─── WEEKLY format (screenshot style) ─────────────────── const dateKeys = dateCols.map(x=>({col:x.col, key:parseDate(x.h)})); for (let i=hdrIdx+1; il.name.toLowerCase()===name.toLowerCase()); const rateFromFile = salCol>=0 ? Number((cols[salCol]||'').replace(/[₹,\s]/g,'')||0) : 0; const dailyRate = rateFromFile || labour?.dailyRate || 0; dateKeys.forEach(({col, key}) => { coverKeys.push(name.toLowerCase()+'|'+key); // file is authoritative for this cell (even if blank) const raw = (cols[col]||'').replace(/[₹,]/g,'').trim(); const val = interpretAttVal(raw); // blank → null (skip), x→1, a→0, 1.5 → 1.5 if (val == null) return; // blank cell = no entry (matches Week View rule) const status = val<=0 ? 'absent' : val<1 ? 'half' : 'full'; // Wage: val is the multiplier (1.5 = 1.5× rate, 2 = 2× rate) const wage = val > 0 ? Math.round(dailyRate * val) : 0; rows.push({dateStr:key, name, proj:'', projId:null, status, dailyRate, wage, isKnown:!!labour, attVal:val}); }); } } else { // ─── SIMPLE format (Date / Name / Project / Status / Rate) ─ const colDate = headers.findIndex(h=>h.includes('date')); const colName = headers.findIndex(h=>h.includes('labour')||h.includes('name')||h.includes('worker')); const colProj = headers.findIndex(h=>h.includes('project')); const colStatus = headers.findIndex(h=>h.includes('status')); const colRate = headers.findIndex(h=>h.includes('rate')||h.includes('wage')||h.includes('salary')); if (colName<0) { if (msgEl) msgEl.textContent='\u274c Could not find Labour Name column.'; return; } const colPaid = headers.findIndex(h=>h.includes('pay status')||h.includes('paid')); // A column headed "Wage" holds the actual day wage (already multiplied); // "Rate"/"Salary" holds the daily rate that still needs the shift multiplier. const isWageCol = colRate>=0 && (headers[colRate]||'').includes('wage'); const ST_MULT = { full:1, half:0.5, overtime:1.5, night:2, absent:0 }; for (let i=hdrIdx+1; i=0 ? parseDate(cols[colDate]||'') : dayKey(new Date()); const proj = colProj>=0 ? cols[colProj]?.trim()||'' : ''; const rateRaw = colRate>=0 ? Number((cols[colRate]||'').replace(/[₹,\s]/g,'')||0) : 0; const labour = (data.labours||[]).find(l=>l.name.toLowerCase()===name.toLowerCase()); const mult = ST_MULT[status] ?? 1; let dailyRate, wage; if (status === 'absent') { dailyRate = labour?.dailyRate || 0; wage = 0; } else if (isWageCol && rateRaw) { wage = rateRaw; dailyRate = labour?.dailyRate || Math.round(rateRaw / (mult || 1)); } else { dailyRate = rateRaw || labour?.dailyRate || 0; wage = Math.round(dailyRate * mult); } const paidFlag = colPaid>=0 ? /^paid/i.test((cols[colPaid]||'').trim()) : false; const projObj = proj?(data.projects||[]).find(p=>projNick(p).toLowerCase()===proj.toLowerCase()||(p.name||'').toLowerCase()===proj.toLowerCase()):null; coverKeys.push(name.toLowerCase()+'|'+dateStr); rows.push({dateStr, name, proj:projObj?projNick(projObj):proj, projId:projObj?.id||null, status, dailyRate, wage:Math.round(wage), paid:paidFlag, isKnown:!!labour}); } } if (!rows.length) { if (msgEl) msgEl.textContent='\u274c No valid rows found.'; return; } bulkAttRows = rows; bulkAttCoverKeys = [...new Set(coverKeys)]; if (msgEl) msgEl.textContent = '\u2705 ' + rows.length + ' entries parsed (' + rows.filter(r=>r.status!=='absent').length + ' with wages) \u2014 preview below'; showBulkAttPreview(bulkAttRows); } function showBulkAttPreview(rows) { const preview = document.getElementById('bulkAttPreview'); const table = document.getElementById('bulkAttTable'); const title = document.getElementById('bulkAttPreviewTitle'); if (!preview || !table) return; const selCount = rows.filter(r => r._sel !== false).length; if (title) title.textContent = `Preview — ${selCount} of ${rows.length} selected`; const statusColor = s => s === 'absent' ? '#c0291c' : s === 'half' ? '#9a5f00' : s === 'overtime' || s === 'night' ? '#5b5bd6' : '#1a7a3a'; const allChecked = rows.length && rows.every(r => r._sel !== false); table.innerHTML = ` DateLabour NameProjectStatusDaily RateWageIn Roster ${rows.map((r, i) => ` ${r.dateStr} ${escHtml(r.name)} ${escHtml(r.proj)||'—'} ${r.status.charAt(0).toUpperCase()+r.status.slice(1)} ${fmtINR(r.dailyRate)} ${fmtINR(r.wage)} ${r.isKnown ? '✅' : '⚠️ New'} `).join('')}`; preview.style.display = ''; const msgEl = document.getElementById('bulkAttMsg'); const withWages = rows.filter(r => r._sel !== false && r.status !== 'absent').length; if (msgEl) msgEl.textContent = `✅ ${rows.length} parsed · ${selCount} selected (${withWages} with wages)`; } function toggleBulkAttRow(i, checked) { if (bulkAttRows[i]) bulkAttRows[i]._sel = checked; showBulkAttPreview(bulkAttRows); } function toggleAllBulkAtt(checked) { bulkAttRows.forEach(r => r._sel = checked); showBulkAttPreview(bulkAttRows); } function removeBulkAttRow(i) { bulkAttRows.splice(i, 1); if (!bulkAttRows.length) { document.getElementById('bulkAttPreview').style.display = 'none'; const msgEl = document.getElementById('bulkAttMsg'); if (msgEl) msgEl.textContent = 'All rows removed.'; return; } showBulkAttPreview(bulkAttRows); } function removeSelectedBulkAttRows() { // Drop the rows that are currently ticked (selected) bulkAttRows = bulkAttRows.filter(r => r._sel === false); if (!bulkAttRows.length) { document.getElementById('bulkAttPreview').style.display = 'none'; const msgEl = document.getElementById('bulkAttMsg'); if (msgEl) msgEl.textContent = 'All selected rows removed — nothing left to import.'; return; } // Re-tick remaining rows so they're ready to import bulkAttRows.forEach(r => r._sel = true); showBulkAttPreview(bulkAttRows); } function confirmBulkAttendance() { if (!bulkAttRows.length) return; data.attendance = Array.isArray(data.attendance) ? data.attendance : []; data.accounts = Array.isArray(data.accounts) ? data.accounts : []; let added = 0, replaced = 0, deselected = 0; // Only clear/replace cells that belong to SELECTED rows or are blank-but-covered. // Deselected cells are left untouched (user opted out of changing them). const selectedKeys = new Set(bulkAttRows.filter(r => r._sel !== false).map(r => String(r.name).toLowerCase()+'|'+r.dateStr)); const importedNonBlank = new Set(bulkAttRows.map(r => String(r.name).toLowerCase()+'|'+r.dateStr)); // Authoritative cells = selected non-blank cells + covered cells that were blank in the file const clearKeys = new Set(selectedKeys); (bulkAttCoverKeys || []).forEach(k => { if (!importedNonBlank.has(k)) clearKeys.add(k); }); // blank cells → clear stale data // Remove existing attendance (and linked account entries) for the authoritative cells data.attendance = data.attendance.filter(a => { const k = (a.labourName||'').toLowerCase()+'|'+a.date; if (clearKeys.has(k)) { if (a.accountEntryId) data.accounts = data.accounts.filter(e => e.id !== a.accountEntryId); replaced++; return false; } return true; }); bulkAttRows.forEach(r => { if (r._sel === false) { deselected++; return; } // skip unticked rows const entryId = Date.now() + added; const labMatch = (data.labours || []).find(l => l.name.toLowerCase() === String(r.name).toLowerCase()); const rec = { id: entryId, date: r.dateStr, labourId: labMatch ? labMatch.id : null, labourName: r.name, projectId: r.projId, status: r.status, wage: r.wage, accountEntryId: null }; // Log wage to Accounts if not absent (same type the other attendance flows use) if (r.status !== 'absent' && r.wage > 0) { const accEntry = { id: entryId + 10000, type: 'vendor-payment', projectId: r.projId, company: null, bank: null, party: r.name, amount: r.wage, date: r.dateStr, status: r.paid ? 'paid' : 'pending', dueDate: '', note: `Wage — ${r.name} (${r.status})` }; data.accounts.push(accEntry); rec.accountEntryId = accEntry.id; } data.attendance.push(rec); added++; }); bulkAttRows = []; bulkAttCoverKeys = []; document.getElementById('bulkAttPreview').style.display = 'none'; const msgEl = document.getElementById('bulkAttMsg'); if (msgEl) msgEl.textContent = `✅ Imported ${added} entries${replaced ? ' · ' + replaced + ' existing replaced' : ''}${deselected ? ' · ' + deselected + ' deselected' : ''}`; if (typeof showToast === 'function') showToast(`✅ Imported ${added} ${added===1?'entry':'entries'}${replaced ? ` · ${replaced} replaced` : ''}`); saveData(); renderAll(); } // ===== MARK GANG ATTENDANCE — several workers, one project & day ===== function openMarkGang(dateArg) { const gang = document.getElementById('attGang')?.value || ''; const cat = document.getElementById('attCategory')?.value || ''; let workers = (data.labours || []); if (gang) workers = workers.filter(l => (l.group||'') === gang); if (cat) workers = workers.filter(l => (l.category||'') === cat); if (!workers.length) { alert('No workers match. Pick a Gang (or Category) first, or add workers to a group.'); return; } const date = dateArg || document.getElementById('attDate')?.value || dayKey(new Date()); window._mgDate = date; const curProj = document.getElementById('attProject')?.value || ''; const projOpts = (data.projects||[]).map(p => ``).join(''); window._mgDayLines = {}; openGenModal('👥 Mark Gang Attendance' + (gang ? ' — ' + escHtml(gang) : ''), `
Workers present (whole range)
${workers.map(l => ``).join('')}
Per-day project & shift
`, () => { markGangAttendance(); }); setTimeout(mgRenderDays, 30); } const MG_SHIFT_OPTS = [['full','Day'],['half','Half'],['overtime','OT'],['night','Night'],['custom','Custom']]; const MG_MULT = { full:1, half:0.5, overtime:1.5, night:2 }; const MG_SHIFT_LABEL = { full:'full day', half:'half day', overtime:'overtime', night:'night shift', custom:'shift' }; function mgReadDayLines() { const model = {}; document.querySelectorAll('.mgDayRow').forEach(r => { const dk = r.dataset.date; (model[dk] = model[dk] || []).push({ proj: r.querySelector('.mgDayProj').value, shift: r.querySelector('.mgDayShift').value, mult: r.querySelector('.mgDayMult').value }); }); // keep any dates in the model that aren't currently rendered (out of range) so toggling range preserves them window._mgDayLines = Object.assign({}, window._mgDayLines || {}, model); // overwrite rendered dates fully Object.keys(model).forEach(dk => window._mgDayLines[dk] = model[dk]); } function mgProjPool() { const checkedLabs = [...document.querySelectorAll('.mgChk')].filter(c => c.checked).map(c => Number(c.dataset.id)); const allowed = new Set(); checkedLabs.forEach(id => { const lab = data.labours.find(l => l.id === id); if (lab) labProjectIds(lab).forEach(pid => allowed.add(String(pid))); }); let pool = (data.projects||[]).filter(p => allowed.has(String(p.id))); if (!pool.length) pool = (data.projects||[]); return pool; } function mgRenderDays(useModel) { const host = document.getElementById('mgDayList'); if (!host) return; let from = document.getElementById('mgDate')?.value || dayKey(new Date()); let to = document.getElementById('mgDateTo')?.value || from; if (to < from) { const t = from; from = to; to = t; } const skipSun = document.getElementById('mgSkipSun')?.checked; if (!useModel) mgReadDayLines(); window._mgDayLines = window._mgDayLines || {}; const projPool = mgProjPool(); // Rebuild the "set all" project dropdown to match the pool const topSel = document.getElementById('mgProject'); if (topSel) { const cur = topSel.value; topSel.innerHTML = '' + projPool.map(p=>``).join(''); if (projPool.some(p=>String(p.id)===cur)) topSel.value = cur; } const topProj = topSel ? topSel.value : ''; const topStatus = document.getElementById('mgStatusAll')?.value || 'full'; const projOptsFor = (val) => { let opts = '' + projPool.map(p=>``).join(''); // keep a previously-saved project selectable even if it's outside the gang's assigned pool if (val && !projPool.some(p => String(p.id) === String(val))) { const p = (data.projects||[]).find(x => String(x.id) === String(val)); if (p) opts += ``; } return opts; }; const checkedIds = [...document.querySelectorAll('.mgChk')].filter(c=>c.checked).map(c=>Number(c.dataset.id)); const checkedNames = new Set(checkedIds.map(id => ((data.labours||[]).find(l=>l.id===id)||{}).name).filter(Boolean)); const dates = []; let d = new Date(from + 'T00:00:00'); const end = new Date(to + 'T00:00:00'); while (d <= end) { if (!(skipSun && d.getDay() === 0)) dates.push(new Date(d)); d.setDate(d.getDate() + 1); } host.innerHTML = dates.map(dt => { const dk = dayKey(dt); // Attendance already saved for these workers on this day → pre-fill and flag it. // Fresh days default to the last project these workers were marked on. const prevRecs = (data.attendance||[]).filter(a => a.date === dk && a.status !== 'absent' && (checkedNames.has(a.labourName) || checkedIds.includes(a.labourId))); if (!window._mgDayLines[dk] || !window._mgDayLines[dk].length) window._mgDayLines[dk] = mgLinesFromExisting(prevRecs) || [{ proj: topProj || mgLastUsedProj(dk, checkedNames, checkedIds), shift: topStatus, mult: (MG_MULT[topStatus] ?? 1) }]; const lines = window._mgDayLines[dk]; const isSun = dt.getDay() === 0; const linesHtml = lines.map((ln, idx) => { let projVal = ln.proj; if (projVal && !(data.projects||[]).some(p => String(p.id) === String(projVal))) projVal = ''; const shiftVal = ln.shift || 'full'; const multVal = (ln.mult !== undefined && ln.mult !== '') ? ln.mult : (MG_MULT[shiftVal] ?? 1); return `
${lines.length>1?``:''}
`; }).join(''); return `
${dt.toLocaleDateString('en-IN',{day:'2-digit',month:'short'})} ${dt.toLocaleDateString('en-IN',{weekday:'short'})}${prevRecs.length?`✓ marked · ${prevRecs.length}`:''}
${linesHtml}
`; }).join(''); const hint = document.getElementById('mgRangeHint'); if (hint) hint.textContent = dates.length + ' day' + (dates.length===1?'':'s') + ' · add multiple shifts per day (different project + multiplier), then Save once.'; } function mgShiftChange(el) { const row = el.closest('.mgDayRow'); if (!row) return; const multEl = row.querySelector('.mgDayMult'); if (el.value !== 'custom' && MG_MULT[el.value] != null && multEl) multEl.value = MG_MULT[el.value]; } // Resolve which project an attendance record belongs to: the record's own tag → // the linked wage ledger entry's tag → the worker's single assigned project → '' (unknown). function mgResolveProj(a) { if (a.projectId != null) return String(a.projectId); if (a.accountEntryId) { const acct = (data.accounts||[]).find(e => e.id === a.accountEntryId); if (acct && acct.projectId != null) return String(acct.projectId); } const lab = (data.labours||[]).find(l => l.id === a.labourId || l.name === a.labourName); const ids = lab ? labProjectIds(lab) : []; return ids.length === 1 ? String(ids[0]) : ''; } // Most recent project these workers were marked on before the given day — // used to carry the last-entered project forward onto fresh (unmarked) days. function mgLastUsedProj(beforeDk, checkedNames, checkedIds) { let best = null, bestProj = ''; (data.attendance||[]).forEach(a => { if (!a.date || a.date >= beforeDk) return; if (!(checkedNames.has(a.labourName) || checkedIds.includes(a.labourId))) return; const r = mgResolveProj(a); if (!r) return; if (!best || a.date > best) { best = a.date; bestProj = r; } }); return bestProj; } // Turn attendance records already saved for a day into pre-filled modal lines — // one line per distinct project + shift combination found. function mgLinesFromExisting(recs) { if (!recs || !recs.length) return null; const seen = new Set(); const lines = []; recs.forEach(a => { const proj = mgResolveProj(a); const known = MG_MULT[a.status] != null; let mult = known ? MG_MULT[a.status] : 1; if (!known) { const lab = (data.labours||[]).find(l => l.id === a.labourId || l.name === a.labourName); if (lab && Number(lab.dailyRate) > 0 && Number(a.wage) > 0) mult = Math.round(Number(a.wage)/Number(lab.dailyRate)*4)/4; } const key = proj + '|' + (known ? a.status : 'custom') + '|' + mult; if (!seen.has(key)) { seen.add(key); lines.push({ proj, shift: known ? a.status : 'custom', mult }); } }); return lines.length ? lines : null; } function mgAddLine(dk) { mgReadDayLines(); const topProj = document.getElementById('mgProject')?.value || ''; window._mgDayLines[dk] = window._mgDayLines[dk] || []; window._mgDayLines[dk].push({ proj: topProj, shift: 'night', mult: 2 }); mgRenderDays(true); } function mgRemoveLine(dk, idx) { mgReadDayLines(); if (window._mgDayLines[dk]) { window._mgDayLines[dk].splice(idx, 1); if (!window._mgDayLines[dk].length) window._mgDayLines[dk] = [{ proj:'', shift:'full', mult:1 }]; } mgRenderDays(true); } function mgSetAllDayProjects(val) { mgReadDayLines(); Object.keys(window._mgDayLines).forEach(dk => window._mgDayLines[dk].forEach(ln => { ln.proj = val; })); mgRenderDays(true); } function mgSetAllDayStatus(val) { mgReadDayLines(); Object.keys(window._mgDayLines).forEach(dk => window._mgDayLines[dk].forEach(ln => { ln.shift = val; if (val !== 'custom' && MG_MULT[val] != null) ln.mult = MG_MULT[val]; })); mgRenderDays(true); } // Week quick-pick → fills From/To with the week's Sunday–Saturday and re-renders the days function mgApplyWeek(v) { if (!v) return; const p = v.split('|'); const f = document.getElementById('mgDate'), t = document.getElementById('mgDateTo'); if (f) f.value = p[0]; if (t) t.value = p[1]; mgRenderDays(); } function mgClearWeek() { const w = document.getElementById('mgWeek'); if (w) w.value = ''; } function mgShiftDay(n) { const el = document.getElementById('mgDate'); if (!el) return; const d = new Date((el.value || dayKey(new Date())) + 'T00:00:00'); d.setDate(d.getDate() + n); el.value = dayKey(d); const toEl = document.getElementById('mgDateTo'); if (toEl && (!toEl.value || toEl.value < el.value)) toEl.value = el.value; mgClearWeek(); mgRenderDays(); } function markGangAttendance() { const checked = [...document.querySelectorAll('.mgChk')].filter(c => c.checked); if (!checked.length) { alert('Tick at least one worker.'); return; } const rows = [...document.querySelectorAll('.mgDayRow')]; if (!rows.length) { alert('No days in range.'); return; } let count = 0, skipped = 0, tagged = 0, base = Date.now(), k = 0; rows.forEach(row => { const iso = row.dataset.date; const shift = row.querySelector('.mgDayShift').value || 'full'; const mult = Number(row.querySelector('.mgDayMult').value) || 0; const projId = row.querySelector('.mgDayProj').value ? Number(row.querySelector('.mgDayProj').value) : null; const proj = projId ? data.projects.find(p => p.id === projId) : null; const stLabel = (MG_SHIFT_LABEL[shift] || 'shift') + (mult ? ' ×'+mult : ''); checked.forEach(chk => { const labId = Number(chk.dataset.id); const lab = data.labours.find(l => l.id === labId); if (!lab) return; const wage = Math.round((lab.dailyRate || 0) * mult); // avoid an identical duplicate (same worker, day, project, wage) — still allows a 2nd different shift if ((data.attendance||[]).some(a => a.labourId === lab.id && a.date === iso && (a.projectId||null) === (projId||null) && Number(a.wage) === wage)) { skipped++; return; } // same worker/day/wage saved earlier WITHOUT a project tag → adopt this project on the // existing entry (and its ledger entry) instead of creating a double wage. Only adopt // when the record's own resolved project is unknown or matches this line. if (projId) { const untagged = (data.attendance||[]).find(a => a.labourId === lab.id && a.date === iso && a.projectId == null && Number(a.wage) === wage && (() => { const r = mgResolveProj(a); return !r || r === String(projId); })()); if (untagged) { untagged.projectId = projId; const acct = untagged.accountEntryId ? (data.accounts||[]).find(e => e.id === untagged.accountEntryId) : null; if (acct) { acct.projectId = projId; if (proj && !acct.company) acct.company = proj.company || acct.company; } tagged++; return; } } // this worker's wage for the day is already recorded against a different project // (pre-filled multi-project day) — skip rather than double the wage if ((data.attendance||[]).some(a => a.labourId === lab.id && a.date === iso && Number(a.wage) === wage && mgResolveProj(a) !== '' && mgResolveProj(a) !== String(projId || ''))) { skipped++; return; } const att = { id: base + k, labourId: lab.id, labourName: lab.name, projectId: projId, date: iso, status: shift, mult, wage, accountEntryId: null }; if (wage > 0) { const acctId = base + k + 900000; 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: iso, status:'pending', dueDate:'', note:`Attendance ${stLabel} — ${iso}${proj?' ('+projNick(proj)+')':''}` }); att.accountEntryId = acctId; } data.attendance.push(att); count++; k++; }); }); if (!count && !skipped && !tagged) { alert('Nothing to mark.'); return; } saveData(); renderAll(); closeGenModal(); if (typeof showToast === 'function') showToast('Marked ' + count + ' entr' + (count===1?'y':'ies') + (tagged?(' · '+tagged+' existing tagged to project'):'') + (skipped?(' · '+skipped+' duplicate skipped'):'')); } // Shortlist the attendance labour dropdown by selected gang + category function attFilterLabour() { const labSel = document.getElementById('attLabour'); if (!labSel) return; const gang = document.getElementById('attGang')?.value || ''; const cat = document.getElementById('attCategory')?.value || ''; const prev = labSel.value; let list = (data.labours || []); if (gang) list = list.filter(l => (l.group||'') === gang); if (cat) list = list.filter(l => (l.category||'') === cat); labSel.innerHTML = '' + list.map(l => ``).join(''); if (list.some(l => String(l.id) === prev)) labSel.value = prev; else if (list.length === 1) { labSel.value = String(list[0].id); } onQuickLabourChange(); } function onQuickLabourChange() { const labId = Number(document.getElementById('attLabour')?.value); const lab = data.labours.find(l => l.id === labId); const projSel = document.getElementById('attProject'); if (!projSel) return; if (!lab) { projSel.innerHTML = '' + (data.projects||[]).map(p=>``).join(''); return; } const pids = labProjectIds(lab).map(String); const assigned = (data.projects||[]).filter(p => pids.includes(String(p.id))); if (assigned.length) { projSel.innerHTML = '' + assigned.map(p=>``).join(''); projSel.value = String(assigned[0].id); // default to their first assigned project } else { // No projects assigned to this worker — allow any, but flag it projSel.innerHTML = '' + (data.projects||[]).map(p=>``).join(''); } } // ===== LABOUR REPORTS (Attendance + Payable) with filters → Excel / PDF ===== function showLabourReportModal() { document.getElementById('labReportModal')?.remove(); const today = dayKey(new Date()); const monthAgo = new Date(); monthAgo.setDate(monthAgo.getDate() - 30); const projOpts = (data.projects||[]).map(p=>``).join(''); const cats = [...new Set((data.labours||[]).map(l=>(l.category||'').trim()).filter(Boolean))].sort(); const catOpts = cats.map(c=>``).join(''); const workerOpts = (data.labours||[]).map(l=>``).join(''); const sel = (id,opts,first,attrs) => ``; // Week quick-pick (Sun → Sat), current week first const weekOpts = (() => { const o = []; const n = new Date(); n.setHours(0,0,0,0); const sun = new Date(n); sun.setDate(n.getDate() - n.getDay()); for (let i = 0; i < 12; i++) { const s = new Date(sun); s.setDate(sun.getDate() - i*7); const e = new Date(s); e.setDate(s.getDate() + 6); const wk = getISOWeekNumber(s).week; o.push(``); } return o.join(''); })(); const fld = (lbl,inner) => `
${lbl}
${inner}
`; const modal = document.createElement('div'); modal.id = 'labReportModal'; modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.4);backdrop-filter:blur(6px);z-index:9999;display:flex;align-items:center;justify-content:center;padding:16px;'; modal.addEventListener('click', e => { if (e.target===modal) modal.remove(); }); modal.innerHTML = `
📊 Labour Report
Attendance & payable — filter, then export to Excel or PDF
${fld('Report', ``)} ${fld('Project', sel('lrProject', projOpts, 'All projects'))}
${fld('Week (quick pick)', sel('lrWeek', weekOpts, 'Custom range — set From / To below', 'onchange="lrApplyWeek(this.value)"'))}
${fld('From', ``)} ${fld('To', ``)}
${fld('Category', sel('lrCategory', catOpts, 'All categories', 'onchange="lrCategoryChanged()"'))} ${fld('Worker', sel('lrWorker', workerOpts, 'All workers'))}
Columns — untick to leave out (register uses Role & Wage ₹)
${[['role','Role'],['category','Category'],['project','Project'],['status','Status'],['wage','Wage ₹'],['pay','Pay Status']].map(([k,l])=>` `).join('')}
`; document.body.appendChild(modal); } // Attendance-sheet column preferences (persisted so your choice sticks) function lrColPrefs() { try { return JSON.parse(localStorage.getItem('mydas_lr_cols') || '{}'); } catch(e) { return {}; } } function lrColOn(k) { return lrColPrefs()[k] !== false; } // Week quick-pick → fills From/To function lrApplyWeek(v) { if (!v) return; const parts = v.split('|'); const fe = document.getElementById('lrFrom'), te = document.getElementById('lrTo'); if (fe) fe.value = parts[0]; if (te) te.value = parts[1]; } // Category pick → narrow the Worker dropdown to that category's workers function lrCategoryChanged() { const cat = document.getElementById('lrCategory')?.value || ''; const wSel = document.getElementById('lrWorker'); if (!wSel) return; const norm = s => String(s||'').trim().toLowerCase(); const list = (data.labours||[]).filter(l => !cat || norm(l.category) === norm(cat)); const prev = wSel.value; wSel.innerHTML = '' + list.map(l => ``).join(''); if (list.some(l => l.name === prev)) wSel.value = prev; } function gatherLabourReport() { const f = { type: document.getElementById('lrType')?.value || 'both', projectId: document.getElementById('lrProject')?.value ? Number(document.getElementById('lrProject').value) : null, from: document.getElementById('lrFrom')?.value || '', to: document.getElementById('lrTo')?.value || '', category: document.getElementById('lrCategory')?.value || '', worker: document.getElementById('lrWorker')?.value || '', cols: {} }; document.querySelectorAll('.lrCol').forEach(c => { f.cols[c.dataset.col] = c.checked; }); try { localStorage.setItem('mydas_lr_cols', JSON.stringify(f.cols)); } catch(e) {} const inRange = d => (!f.from || d >= f.from) && (!f.to || d <= f.to); const labOf = name => (data.labours||[]).find(l => l.name === name); const projName = id => { const p = (data.projects||[]).find(x=>x.id===id); return p ? projNick(p) : ''; }; const stLabel = { full:'Full Day', half:'Half Day', overtime:'Overtime', night:'Night Shift', absent:'Absent' }; // Compare categories ignoring case & stray spaces — the dropdown shows trimmed // values, but stored worker categories may have extra whitespace / different case const normCat = s => String(s||'').trim().toLowerCase(); // Attendance rows const attRows = (data.attendance||[]).filter(a => { if (!inRange(a.date)) return false; if (f.worker && a.labourName !== f.worker) return false; const lab = labOf(a.labourName); if (f.category && (!lab || normCat(lab.category) !== normCat(f.category))) return false; if (f.projectId != null) { const entryProj = a.projectId ?? null; const assigned = lab ? labProjectIds(lab) : []; if (entryProj !== f.projectId && !assigned.includes(f.projectId)) return false; } return true; }).sort((a,b)=> (a.date||'').localeCompare(b.date||'') || a.labourName.localeCompare(b.labourName)); const attData = attRows.map(a => { const lab = labOf(a.labourName) || {}; const acct = a.accountEntryId ? (data.accounts||[]).find(e=>e.id===a.accountEntryId) : null; const pj = a.projectId ? projName(a.projectId) : (labProjectIds(lab).map(projName).filter(Boolean).join(', ')); return { date:a.date, worker:a.labourName, role:lab.role||'', category:lab.category||'', project:pj, status:stLabel[a.status]||a.status, wage:Math.round(a.wage||0), pay: acct ? (acct.status==='pending'?'Pending':'Paid') : '—' }; }); // Payable summary per worker const inRng = d => inRange(d); const wmap = {}; attRows.forEach(a => { if (!wmap[a.labourName]) { const lab = labOf(a.labourName)||{}; wmap[a.labourName] = { worker:a.labourName, role:lab.role||'', category:lab.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; }); const payData = Object.values(wmap).sort((a,b)=>a.worker.localeCompare(b.worker)); // Attendance REGISTER (muster-roll grid): workers as rows, one column per day, // cell = day multiplier (1 / 0.5 / 1.5 / 2, summed for split days), 'A' = absent, blank = unmarked let regData = null; if (f.type === 'register') { const from = f.from || (attRows.length ? attRows[0].date : dayKey(new Date())); const to = f.to || dayKey(new Date()); const dates = []; let dd = new Date(from + 'T00:00:00'); const end = new Date(to + 'T00:00:00'); while (dd <= end && dates.length <= 62) { dates.push(dayKey(dd)); dd.setDate(dd.getDate() + 1); } if (dates.length > 62) regData = { tooLong: true }; else { const MULT = { full:1, half:0.5, overtime:1.5, night:2 }; const rmap = {}; attRows.forEach(a => { if (!rmap[a.labourName]) { const lab = labOf(a.labourName)||{}; rmap[a.labourName] = { worker:a.labourName, role:lab.role||'', cells:{}, wages:0 }; } const r = rmap[a.labourName]; if (a.status === 'absent') { if (r.cells[a.date] === undefined) r.cells[a.date] = 'A'; } else { const m = (a.mult != null && a.mult !== '') ? Number(a.mult) : (MULT[a.status] ?? 1); r.cells[a.date] = (typeof r.cells[a.date] === 'number' ? r.cells[a.date] : 0) + m; r.wages += Math.round(a.wage || 0); } }); const rows = Object.values(rmap).sort((a,b)=>a.worker.localeCompare(b.worker)).map(r => { const cells = dates.map(dk => { const v = r.cells[dk]; return v === undefined ? '' : v === 'A' ? 'A' : Math.round(v*100)/100; }); const totalMult = Math.round(cells.reduce((s,v)=>s + (typeof v === 'number' ? v : 0), 0) * 100) / 100; return { worker:r.worker, role:r.role, cells, totalMult, wages:r.wages }; }); regData = { dates, rows }; } } return { f, attData, payData, regData, totals: { wages: payData.reduce((s,r)=>s+r.wages,0), bonus: payData.reduce((s,r)=>s+r.bonus,0), total: payData.reduce((s,r)=>s+r.total,0), paid: payData.reduce((s,r)=>s+r.paid,0), balance: payData.reduce((s,r)=>s+r.balance,0) } }; } function reportSubtitle(f) { const projName = id => { const p = (data.projects||[]).find(x=>x.id===id); return p ? projNick(p) : ''; }; const parts = []; parts.push(`${f.from||'start'} → ${f.to||'today'}`); if (f.projectId != null) parts.push('Project: ' + projName(f.projectId)); if (f.category) parts.push('Category: ' + f.category); if (f.worker) parts.push('Worker: ' + f.worker); return parts.join(' · '); } function exportLabourReport(fmt) { const R = gatherLabourReport(); if (R.f.type === 'register' && R.regData && R.regData.tooLong) { alert('Register grid supports up to 62 days — narrow the date range (use the week quick-pick).'); return; } if (!R.attData.length && !R.payData.length) { alert('No data for the selected filters.'); return; } const stamp = dayKey(new Date()); const regDayLbl = dk => dk.slice(8) + '/' + dk.slice(5,7); if (fmt === 'excel') { if (typeof XLSX === 'undefined') { alert('Excel engine not loaded.'); return; } const wb = XLSX.utils.book_new(); if (R.f.type === 'register') { const on = k => !R.f.cols || R.f.cols[k] !== false; // Role & Wage columns follow the column picker const aoa = [['Worker', ...(on('role')?['Role']:[]), ...R.regData.dates.map(regDayLbl), 'Days ×', ...(on('wage')?['Wages ₹']:[])], ...R.regData.rows.map(r=>[r.worker, ...(on('role')?[r.role]:[]), ...r.cells, r.totalMult, ...(on('wage')?[r.wages]:[])]), ['TOTAL', ...(on('role')?['']:[]), ...R.regData.dates.map(()=>''), Math.round(R.regData.rows.reduce((s,r)=>s+r.totalMult,0)*100)/100, ...(on('wage')?[R.regData.rows.reduce((s,r)=>s+r.wages,0)]:[])]]; XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(aoa), 'Register'); } if (R.f.type !== 'payable' && R.f.type !== 'register') { const on = k => !R.f.cols || R.f.cols[k] !== false; const defs = [['role','Role',r=>r.role],['category','Category',r=>r.category],['project','Project',r=>r.project],['status','Status',r=>r.status],['wage','Wage ₹',r=>r.wage],['pay','Pay Status',r=>r.pay]].filter(d=>on(d[0])); const aoa = [['Date','Worker', ...defs.map(d=>d[1])], ...R.attData.map(r=>[r.date, r.worker, ...defs.map(d=>d[2](r))])]; XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(aoa), 'Attendance'); } if (R.f.type !== 'attendance' && R.f.type !== 'register') { const aoa = [['Worker','Role','Category','Wages ₹','Bonus ₹','Total ₹','Paid ₹','Balance ₹'], ...R.payData.map(r=>[r.worker,r.role,r.category,r.wages,r.bonus,r.total,r.paid,r.balance]), ['TOTAL','','',R.totals.wages,R.totals.bonus,R.totals.total,R.totals.paid,R.totals.balance]]; XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(aoa), 'Payable Summary'); } XLSX.writeFile(wb, `Labour_Report_${stamp}.xlsx`); if (typeof showToast === 'function') showToast('⬇ Excel report downloaded'); document.getElementById('labReportModal')?.remove(); } else { const esc = s => String(s??'').replace(/[&<>]/g,c=>({'&':'&','<':'<','>':'>'}[c])); const inr = n => '₹' + Math.round(n||0).toLocaleString('en-IN'); let html = `Labour Report ${stamp}

👷 Labour Report

${esc(reportSubtitle(R.f))}  ·  generated ${stamp}
`; if (R.f.type === 'register' && R.regData && R.regData.rows.length) { const on = k => !R.f.cols || R.f.cols[k] !== false; // Role & Wage columns follow the column picker const wd = dk => ['S','M','T','W','T','F','S'][new Date(dk + 'T00:00:00').getDay()]; html += `

Attendance Register (${R.regData.rows.length} workers · ${R.regData.dates.length} days)

${on('role')?'':''}${R.regData.dates.map(dk=>``).join('')}${on('wage')?'':''}` + R.regData.rows.map(r=>`${on('role')?``:''}${r.cells.map(c=>``).join('')}${on('wage')?``:''}`).join('') + `${on('wage')?``:''}
WorkerRole${regDayLbl(dk)}
${wd(dk)}
Days ×Wages
${esc(r.worker)}${esc(r.role)}${c===''?'·':c}${r.totalMult}${inr(r.wages)}
TOTAL${Math.round(R.regData.rows.reduce((s,r)=>s+r.totalMult,0)*100)/100}${inr(R.regData.rows.reduce((s,r)=>s+r.wages,0))}
1 = full day · 0.5 = half · 1.5 = overtime · 2 = night · A = absent · “·” = not marked
`; } if (R.f.type !== 'payable' && R.f.type !== 'register' && R.attData.length) { const on = k => !R.f.cols || R.f.cols[k] !== false; const defs = [['role','Role',r=>esc(r.role),''],['category','Category',r=>esc(r.category),''],['project','Project',r=>esc(r.project),''],['status','Status',r=>esc(r.status),''],['wage','Wage',r=>inr(r.wage),' class="num"'],['pay','Pay',r=>esc(r.pay),'']].filter(d=>on(d[0])); html += `

Attendance Log (${R.attData.length})

${defs.map(d=>`${d[1]}`).join('')}` + R.attData.map(r=>`${defs.map(d=>`${d[2](r)}`).join('')}`).join('') + `
DateWorker
${esc(r.date)}${esc(r.worker)}
`; } if (R.f.type !== 'attendance' && R.f.type !== 'register' && R.payData.length) { html += `

Payable Summary (${R.payData.length} workers)

` + R.payData.map(r=>``).join('') + `
WorkerRoleCategoryWagesBonusTotalPaidBalance
${esc(r.worker)}${esc(r.role)}${esc(r.category)}${inr(r.wages)}${inr(r.bonus)}${inr(r.total)}${inr(r.paid)}${inr(r.balance)}
TOTAL${inr(R.totals.wages)}${inr(R.totals.bonus)}${inr(R.totals.total)}${inr(R.totals.paid)}${inr(R.totals.balance)}
`; } html += `
`; 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 taskViewDay(iso) { anchorDate = new Date(iso + 'T00:00:00'); currentPeriod = 'day'; const dp = document.getElementById('anchorDate'); if (dp) dp.valueAsDate = anchorDate; if (typeof applyTabPeriodBar === 'function') applyTabPeriodBar('tasks'); renderAll(); } function taskAddDayShift(n) { const el = document.getElementById('taskDate'); let d = (el && el.value) ? new Date(el.value + 'T00:00:00') : new Date(); d.setDate(d.getDate() + n); const iso = dayKey(d); if (el) el.value = iso; taskViewDay(iso); } function taskAddToday() { const iso = dayKey(new Date()); const el = document.getElementById('taskDate'); if (el) el.value = iso; taskViewDay(iso); } // ---- Apple Calendar (.ics) export for the shown day ---- function _icsEsc(s) { return String(s == null ? '' : s).replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\r?\n/g, '\\n'); } function _icsFold(line) { let out = ''; while (line.length > 73) { out += line.slice(0, 73) + '\r\n '; line = line.slice(73); } return out + line; } function exportTasksDayICS() { const de = document.getElementById('taskDate'); const iso = (de && de.value) ? de.value : dayKey(new Date()); const dayTasks = data.tasks.filter(t => dayKey(t.date) === iso && !t.skipped); if (!dayTasks.length) { (typeof showToast === 'function' ? showToast : alert)('No tasks on ' + iso + ' to export'); return; } const dtStamp = new Date().toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z'; const ymd = iso.replace(/-/g, ''); const nextYmd = dayKey(new Date(new Date(iso + 'T00:00:00').getTime() + 864e5)).replace(/-/g, ''); const events = dayTasks.map((t, i) => { const uid = 'mydas-' + (t.id || i) + '-' + ymd + '@betchael.com'; const proj = t.projectId ? (data.projects || []).find(p => String(p.id) === String(t.projectId)) : null; const dsc = []; if (t.category) dsc.push('Category: ' + t.category); if (proj) dsc.push('Project: ' + projNick(proj)); if (t.important) dsc.push('Important'); if (t.urgent) dsc.push('Urgent'); dsc.push('From MYDAS ERP'); const summ = _icsEsc((t.completed ? '\u2713 ' : '') + t.text); const lines = ['BEGIN:VEVENT', 'UID:' + uid, 'DTSTAMP:' + dtStamp]; if (t.reminderTime && /^\d{1,2}:\d{2}/.test(t.reminderTime)) { const [hh, mm] = t.reminderTime.split(':'); const h2 = hh.padStart(2, '0'), m2 = mm.padStart(2, '0'); const dur = Number(t.durationMin) || 30; const endD = new Date(new Date(iso + 'T' + h2 + ':' + m2 + ':00').getTime() + dur * 60000); const eYmd = dayKey(endD).replace(/-/g, ''); const eh = String(endD.getHours()).padStart(2, '0'), em = String(endD.getMinutes()).padStart(2, '0'); lines.push('DTSTART:' + ymd + 'T' + h2 + m2 + '00'); lines.push('DTEND:' + eYmd + 'T' + eh + em + '00'); lines.push('BEGIN:VALARM', 'ACTION:DISPLAY', 'DESCRIPTION:' + summ, 'TRIGGER:PT0S', 'END:VALARM'); } else { lines.push('DTSTART;VALUE=DATE:' + ymd); lines.push('DTEND;VALUE=DATE:' + nextYmd); } lines.push('SUMMARY:' + summ); lines.push('DESCRIPTION:' + _icsEsc(dsc.join('\n'))); if (t.completed) lines.push('STATUS:CONFIRMED'); lines.push('END:VEVENT'); return lines.map(_icsFold).join('\r\n'); }); const cal = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//MYDAS ERP//Tasks//EN', 'CALSCALE:GREGORIAN', 'METHOD:PUBLISH', _icsFold('X-WR-CALNAME:MYDAS Tasks ' + iso)].concat(events, ['END:VCALENDAR']).join('\r\n'); const blob = new Blob([cal], { type: 'text/calendar;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'MYDAS-tasks-' + iso + '.ics'; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(() => URL.revokeObjectURL(url), 1500); if (typeof showToast === 'function') showToast(dayTasks.length + ' task(s) exported \u2014 open the .ics to add to Apple Calendar'); } // Quick "link to project + requirement" workflow from a task row const PROJECT_REQ_TYPES = ['Order Material','Follow-up','Site Visit','Payment','Labour Arrangement','Machinery / Equipment','Inspection','Documentation','Client Update','Other']; function linkTaskProject(id) { const t = data.tasks.find(x => x.id === id); if (!t) return; const projs = data.projects || []; if (!projs.length) { (typeof showToast==='function'?showToast:alert)('No projects yet — create one in Projects first'); return; } const todayIso = dayKey(new Date()); const tomorrowIso = dayKey(new Date(Date.now() + 864e5)); const plus2Iso = dayKey(new Date(Date.now() + 2*864e5)); openGenModal('🔗 Project & Requirement', `
Linking: "${escHtml(t.text)}"
`, () => { const val = id => { const e = document.getElementById(id); return e ? e.value : ''; }; const pid = val('paProject'); const proj = projs.find(p => String(p.id) === String(pid)); const nick = proj ? projNick(proj) : ''; const req = val('paReq'); const detail = (val('paDetail') || '').trim(); const when = val('paDate') || tomorrowIso; const time = val('paTime') || ''; const doLink = document.getElementById('paLink')?.checked; const doCreate = document.getElementById('paCreate')?.checked; const doFollow = document.getElementById('paFollow')?.checked; if (doLink) t.projectId = pid || null; [req, 'Follow-up'].forEach(c => { if (c) { data.taskCategories = Array.isArray(data.taskCategories) ? data.taskCategories : [...DEFAULT_TASK_CATEGORIES]; if (!data.taskCategories.includes(c)) data.taskCategories.push(c); } }); if (doCreate) { data.tasks.push({ id: Date.now(), text: `${req}${detail ? ': ' + detail : ''} — ${nick}`, completed:false, date: when, freq:'once', reminderTime: time, remindedOn:'', timeSpent:0, urgent: req==='Order Material', important:false, category: req, projectId: pid }); } if (doFollow) { const fdate = dayKey(new Date(new Date(when + 'T00:00:00').getTime() + 2*864e5)); data.tasks.push({ id: Date.now()+1, text: `Follow up: ${detail || req} — ${nick}`, completed:false, date: fdate, freq:'once', reminderTime:'', remindedOn:'', timeSpent:0, urgent:false, important:false, category:'Follow-up', projectId: pid }); } saveData(); renderAll(); closeGenModal(); if (typeof showToast === 'function') showToast('Linked to ' + nick + (doCreate ? ' · task created' : '')); }); } // A task can link to one project (projectId) or many (projectIds). This returns the full list. function taskProjectIds(t) { if (!t) return []; if (Array.isArray(t.projectIds) && t.projectIds.length) return t.projectIds; return (t.projectId != null && t.projectId !== '') ? [t.projectId] : []; } // Multi-project picker — popup with tick boxes function updateTaskProjBtn() { const b = document.getElementById('taskProjMultiBtn'); if (!b) return; const n = (window._taskProjSel || []).length; b.textContent = n ? `▣ ${n} project${n>1?'s':''}` : '☑ Multi'; b.style.color = n ? 'var(--accent)' : ''; const sel = document.getElementById('taskProject'); if (sel) { sel.disabled = n > 0; sel.style.opacity = n > 0 ? '0.5' : ''; } } function openTaskProjectPicker() { document.getElementById('taskProjPickModal')?.remove(); const sel = new Set((window._taskProjSel || []).map(Number)); const projs = (data.projects || []); const m = document.createElement('div'); m.id = 'taskProjPickModal'; m.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.45);backdrop-filter:blur(5px);z-index:10001;display:flex;align-items:center;justify-content:center;padding:16px;'; m.addEventListener('click', e => { if (e.target === m) m.remove(); }); const rows = projs.length ? projs.map(p => ` `).join('') : '
No projects yet — add one in Projects.
'; m.innerHTML = `
🏗 Select Projects
Tick every project this task belongs to.
${rows}
`; document.body.appendChild(m); } function applyTaskProjectPicker() { const ids = Array.from(document.querySelectorAll('#taskProjPickModal .tpp-chk:checked')).map(c => Number(c.value)); window._taskProjSel = ids; document.getElementById('taskProjPickModal')?.remove(); updateTaskProjBtn(); } 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 projEl = document.getElementById('taskProject'); 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() : ''; let projectId = null, projectIds = null; if (Array.isArray(window._taskProjSel) && window._taskProjSel.length) { projectIds = window._taskProjSel.slice(); projectId = projectIds[0]; } else if (projEl) { projectId = projEl.value || null; } 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, projectId, ...(projectIds && projectIds.length > 1 ? { projectIds } : {}) }); i.value = ''; if (catEl) catEl.value = ''; if (dEl) dEl.value = dayKey(new Date()); if (rEl) rEl.value = ''; // keep taskProject selection so several tasks can be added to the same project 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('')}
${(function(){ window._routineDays = Array.isArray(t.days)?t.days.slice():[]; const show=(t.freq==='weekly'||t.freq==='biweekly'); const btns=ROUTINE_DOW.map(d=>``).join(''); return `
${btns}
`; })()}`, () => { 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; t.days = (t.freq==='weekly'||t.freq==='biweekly') ? (window._routineDays||[]).slice().sort((a,b)=>a-b) : []; 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 = ''; } { const pEl = document.getElementById('genTaskProject'); t.projectId = pEl && pEl.value ? pEl.value : null; } if (rv !== t.reminderTime) { t.reminderTime = rv; t.remindedOn = ''; if (rv) ensureNotificationPermission(); } { const durEl = document.getElementById('genDur'); const dm = durEl ? parseInt(durEl.value,10) : NaN; t.durationMin = (!isNaN(dm) && dm>0) ? dm : undefined; } saveData(); renderAll(); closeGenModal(); } }); } function delTask(id) { const t=data.tasks.find(x=>x.id===id); if(t&&t.rkey){ data.routineSkips=data.routineSkips||{}; data.routineSkips[t.rkey]=true; } 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', project: '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); if (taskFilters.project !== 'all') out = out.filter(t => taskProjectIds(t).map(String).includes(String(taskFilters.project))); return out; } // Populate the task project dropdowns (add-row + filter) from data.projects function populateTaskProjectSelects() { const projs = data.projects || []; const opts = projs.map(p => ``).join(''); const addSel = document.getElementById('taskProject'); if (addSel) { const cur = addSel.value; addSel.innerHTML = '' + opts; addSel.value = cur; } const filSel = document.getElementById('taskProjectFilter'); if (filSel) { filSel.innerHTML = '' + opts; filSel.value = taskFilters.project || 'all'; } } 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() { if (typeof populateTaskProjectSelects === 'function') populateTaskProjectSelects(); // Sync the entry-day stepper label to the add-row date (function(){ const de = document.getElementById('taskDate'); const lbl = document.getElementById('taskDayLabel'); const todayBtn = document.getElementById('taskDayTodayBtn'); const iso = (de && de.value) ? de.value : dayKey(new Date()); const d = new Date(iso + 'T00:00:00'); const todayIso = dayKey(new Date()); if (lbl) lbl.textContent = (iso===todayIso?'Today · ':'') + d.toLocaleDateString('en-IN',{weekday:'short',day:'numeric',month:'short',year:'numeric'}); if (todayBtn) todayBtn.style.display = (iso===todayIso) ? 'none' : ''; })(); 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 (currentPeriod === 'month') { renderTasksMonthCalendar(el, list, 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 _pids = taskProjectIds(t); const projBadge = _pids.length ? _pids.slice(0,2).map(pid => { const pr = (data.projects||[]).find(p => String(p.id) === String(pid)); return pr ? `🏗 ${escHtml(projNick(pr))}` : ''; }).join('') + (_pids.length > 2 ? `+${_pids.length-2}` : '') : ''; const si = taskStatusInfo(t); return `
${t.completed?'✓':''}
${escHtml(t.text)}${t.carryCount ? ` ↷${t.carryCount}` : ''}
${(reminderBadge || timeSpentBadge || t.category || projBadge) ? `
${projBadge}${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 taskCalOpenDay(iso) { anchorDate = new Date(iso + 'T00:00:00'); currentPeriod = 'day'; const dp = document.getElementById('anchorDate'); if (dp) dp.valueAsDate = anchorDate; if (typeof applyTabPeriodBar === 'function') applyTabPeriodBar('tasks'); renderAll(); } function renderTasksMonthCalendar(el, list, range) { const first = new Date(range.start); const year = first.getFullYear(), month = first.getMonth(); const daysInMonth = new Date(year, month + 1, 0).getDate(); // Monday-first offset for the 1st of the month const firstDow = first.getDay(); // 0=Sun..6=Sat const lead = (firstDow === 0) ? 6 : firstDow - 1; const todayK = dayKey(new Date()); // Group filtered tasks by date const byDate = {}; list.forEach(t => { const k = dayKey(t.date); (byDate[k] = byDate[k] || []).push(t); }); const dowNames = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun']; let cells = dowNames.map(n => `
${n}
`).join(''); for (let i = 0; i < lead; i++) cells += `
`; for (let day = 1; day <= daysInMonth; day++) { const dObj = new Date(year, month, day); const k = dayKey(dObj); const tasks = (byDate[k] || []).sort((a,b) => (a.reminderTime||'99:99').localeCompare(b.reminderTime||'99:99')); const doneN = tasks.filter(t => t.completed).length; const isToday = k === todayK; const shown = tasks.slice(0, 3); const extra = tasks.length - shown.length; const chips = shown.map(t => { const cls = `cal-chip${t.completed?' done':''}${t.urgent?' urgent':''}`; const tm = t.reminderTime ? fmtTime12(t.reminderTime).replace(':00','') + ' ' : ''; return `
${tm}${escHtml(t.text)}
`; }).join(''); const moreLink = extra > 0 ? `
+${extra} more
` : ''; cells += `
${day}${tasks.length ? `${doneN}/${tasks.length}` : ''}
${chips}${moreLink}
`; } el.innerHTML = `
${cells}
`; } 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'); const timelineEl = document.getElementById('taskTimelineView'); if (listEl) listEl.style.display = view === 'list' ? '' : 'none'; if (matrixEl) matrixEl.style.display = view === 'matrix' ? '' : 'none'; if (timelineEl) timelineEl.style.display = view === 'timeline' ? '' : 'none'; window._taskView = view; if (view === 'matrix') renderTaskMatrix(); if (view === 'timeline') renderTaskTimeline(); } function fmtTime12(hhmm) { if (!hhmm) return ''; const [h, m] = hhmm.split(':').map(Number); const ap = h < 12 ? 'AM' : 'PM'; let h12 = h % 12; if (h12 === 0) h12 = 12; return h12 + ':' + String(m).padStart(2, '0') + ' ' + ap; } function fmtHour12(h) { const ap = h < 12 ? 'AM' : 'PM'; let h12 = h % 12; if (h12 === 0) h12 = 12; return h12 + ' ' + ap; } function taskColor(t) { const key = (t.category || t.routine || '').toString(); if (!key) return t.urgent ? C.rose : C.accent; let h = 0; for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) >>> 0; const pal = (C.series && C.series.length) ? C.series : ['#e8a020','#2f8f5b','#d8533d','#3a5a8c','#9b59b6','#16a085','#e67e22','#7f8c8d']; return pal[h % pal.length]; } function renderTaskTimeline() { const el = document.getElementById('taskTimelineList'); if (!el) return; const range = getPeriodRange(); const rangeList = data.tasks.filter(t => inRange(t.date, range) && !t.skipped); const list = applyTaskFilters(rangeList); const meta = document.getElementById('timelineMeta'); if (meta) meta.textContent = `${list.length} task${list.length!==1?'s':''} · ${list.filter(t=>t.completed).length} done`; // Columns (days) to show let cols; if (currentPeriod === 'day') cols = [new Date(anchorDate)]; else if (currentPeriod === 'week') cols = range.days.slice(0, 7); else { cols = []; const s = new Date(); s.setHours(0,0,0,0); for (let i=0;i<7;i++){ const d=new Date(s); d.setDate(s.getDate()+i); cols.push(d); } } cols.forEach(d => d.setHours(0,0,0,0)); const byDate = {}; list.forEach(t => { const k = dayKey(t.date); (byDate[k] = byDate[k] || []).push(t); }); // Determine hour window from timed tasks in the visible columns let minH = 8, maxH = 18; cols.forEach(d => (byDate[dayKey(d)] || []).forEach(t => { if (!t.reminderTime) return; const h = parseInt(t.reminderTime.split(':')[0], 10); const dm = t.durationMin || 45; const endH = h + Math.ceil((parseInt(t.reminderTime.split(':')[1],10) + dm) / 60); if (h < minH) minH = h; if (endH > maxH) maxH = endH; })); const startHour = Math.max(0, Math.min(6, minH)); const endHour = Math.min(24, Math.max(19, maxH)); const HOUR_H = 56; const gridH = (endHour - startHour) * HOUR_H; const todayK = dayKey(new Date()); // Hour gutter + lines let hourLabels = ''; let hourLines = ''; for (let h = startHour; h <= endHour; h++) { const top = (h - startHour) * HOUR_H; hourLabels += `
${h===24?'':fmtHour12(h%24)}
`; if (h < endHour) hourLines += `
`; } const blockFor = (t) => { const [hh, mm] = t.reminderTime.split(':').map(Number); const top = (hh - startHour) * HOUR_H + (mm / 60) * HOUR_H; const dur = t.durationMin || 45; const height = Math.max(20, (dur / 60) * HOUR_H - 2); const col = taskColor(t); const endMin = hh * 60 + mm + dur; const endStr = fmtTime12(String(Math.floor(endMin/60)%24).padStart(2,'0') + ':' + String(endMin%60).padStart(2,'0')); const recur = (t.freq && t.freq !== 'once') ? ' ↻' : ''; return `
${t.completed?'✓':''}
${escHtml(t.text)}${recur}
${height>30?`
${fmtTime12(t.reminderTime)} – ${endStr}
`:''}
`; }; const headHtml = cols.map(d => { const k = dayKey(d); const isT = k === todayK; return `
${d.toLocaleDateString('en-IN',{weekday:'short'})} – ${d.getDate()} ${d.toLocaleDateString('en-IN',{month:'short'})}${isT?'Today':''}
`; }).join(''); const alldayHtml = cols.map(d => { const untimed = (byDate[dayKey(d)] || []).filter(t => !t.reminderTime); const shown = untimed.slice(0, 3); const extra = untimed.length - shown.length; const chips = shown.map(t => { const col = taskColor(t); return `
${escHtml(t.text)}
`; }).join(''); const more = extra > 0 ? `
+${extra} more
` : ''; return `
${chips || ''}${more}
`; }).join(''); const colsHtml = cols.map(d => { const timed = (byDate[dayKey(d)] || []).filter(t => t.reminderTime).sort((a,b)=>a.reminderTime.localeCompare(b.reminderTime)); return `
${hourLines}${timed.map(blockFor).join('')}
`; }).join(''); el.innerHTML = `
${headHtml}
all-day
${alldayHtml}
${hourLabels}
${colsHtml}
`; } 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; let rmNag = 0; const reminderNagTimeouts = {}; 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; rmNag = t.reminderNag || 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)); document.querySelectorAll('#rmNagGroup .filter-btn').forEach(b => b.classList.toggle('active', Number(b.dataset.rmnag) === rmNag)); 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 rmSetNag(mins) { rmNag = mins; document.querySelectorAll('#rmNagGroup .filter-btn').forEach(b => b.classList.toggle('active', Number(b.dataset.rmnag) === 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 : ''}${t.reminderNag ? ' · Nags every ' + t.reminderNag + ' min until done' : ''}`; } 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.reminderNag = rmNag; 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.reminderNag; delete t.remindedOn; if (reminderNagTimeouts[t.id]) { clearTimeout(reminderNagTimeouts[t.id]); delete reminderNagTimeouts[t.id]; } 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 clearNag(taskId) { if (reminderNagTimeouts[taskId]) { clearTimeout(reminderNagTimeouts[taskId]); delete reminderNagTimeouts[taskId]; } } function scheduleNag(taskId) { const t = data.tasks.find(x => x.id === taskId); if (!t) return; const mins = t.reminderNag || 0; if (mins <= 0) return; clearNag(taskId); reminderNagTimeouts[taskId] = setTimeout(() => { const tt = data.tasks.find(x => x.id === taskId); if (tt && !tt.completed && !tt.skipped) showReminderPopup(tt.id, tt.text, tt.reminderTime || '—'); }, mins * 60 * 1000); } function stopNagging() { if (reminderPopupTaskId) { const t = data.tasks.find(x => x.id === reminderPopupTaskId); if (t) { t.reminderNag = 0; saveData(); } clearNag(reminderPopupTaskId); } const popup = document.getElementById('reminderPopup'); if (popup) popup.style.display = 'none'; reminderPopupTaskId = null; } function showReminderPopup(taskId, title, time) { reminderPopupTaskId = taskId; const popup = document.getElementById('reminderPopup'); const titleEl = document.getElementById('reminderPopupTitle'); const timeEl = document.getElementById('reminderPopupTime'); const stopBtn = document.getElementById('reminderStopBtn'); if (!popup) return; const t = data.tasks.find(x => x.id === taskId); const nag = t ? (t.reminderNag || 0) : 0; if (titleEl) titleEl.textContent = title; if (timeEl) timeEl.textContent = nag > 0 ? `Scheduled for ${time} · repeats every ${nag} min until done` : `Scheduled for ${time} today`; if (stopBtn) stopBtn.style.display = nag > 0 ? '' : 'none'; popup.style.display = ''; popup.style.animation = 'reminderSlideIn 0.4s cubic-bezier(0.34,1.56,0.64,1) both'; fireNotification('🔔 Reminder', title); if (nag > 0) scheduleNag(taskId); } function dismissReminderPopup(markDone) { if (markDone && reminderPopupTaskId) { const t = data.tasks.find(x => x.id === reminderPopupTaskId); if (t) { t.completed = true; saveData(); renderAll(); } clearNag(reminderPopupTaskId); } 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); if (reminderPopupTaskId) clearNag(reminderPopupTaskId); 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(); } } let habitViewDate = new Date(); function habitDateKey() { return habitViewDate.toLocaleDateString(); } function isHabitToday() { return habitViewDate.toDateString() === new Date().toDateString(); } function setHabitDate(val) { if (!val) return; const d = new Date(val + 'T00:00:00'); const today = new Date(); today.setHours(0,0,0,0); if (d > today) return; // no future marking habitViewDate = d; renderHabits(); } function shiftHabitDate(n) { const d = new Date(habitViewDate); d.setDate(d.getDate() + n); const today = new Date(); today.setHours(0,0,0,0); d.setHours(0,0,0,0); if (d > today) return; habitViewDate = d; renderHabits(); } function goHabitToday() { habitViewDate = new Date(); renderHabits(); } function toggleHabit(id) { const h = data.habits.find(x=>x.id===id); if (h) { const key = habitDateKey(); if (!Array.isArray(h.completedDates)) h.completedDates = []; const idx = h.completedDates.indexOf(key); if (idx === -1) h.completedDates.push(key); 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; window._routineDays = Array.isArray(h.days) ? h.days.slice() : []; const dayBtns = ROUTINE_DOW.map(d => ``).join(''); const showDays = (h.freq==='weekly' || h.freq==='biweekly'); openGenModal('Edit Habit', `
${dayBtns}
`, () => { const n = document.getElementById('genName').value.trim(); if (n) { h.name = n; h.freq = document.getElementById('genFreq').value; h.days = (h.freq==='weekly'||h.freq==='biweekly') ? (window._routineDays||[]).slice().sort((a,b)=>a-b) : []; saveData(); renderAll(); closeGenModal(); } }); } function renderHabits() { const el = document.getElementById('habitList'); if (!el) return; const today = habitDateKey(); const onToday = isHabitToday(); // Sync the date bar controls const dp = document.getElementById('habitDatePicker'); if (dp) { const y=habitViewDate.getFullYear(), m=String(habitViewDate.getMonth()+1).padStart(2,'0'), d=String(habitViewDate.getDate()).padStart(2,'0'); dp.value = `${y}-${m}-${d}`; dp.max = (()=>{const t=new Date();return `${t.getFullYear()}-${String(t.getMonth()+1).padStart(2,'0')}-${String(t.getDate()).padStart(2,'0')}`;})(); } const nextBtn = document.getElementById('habitNextBtn'); if (nextBtn) nextBtn.disabled = onToday; const todayBtn = document.getElementById('habitTodayBtn'); if (todayBtn) todayBtn.style.display = onToday ? 'none' : ''; const dLabel = document.getElementById('habitDateLabel'); if (dLabel) dLabel.textContent = onToday ? 'Marking today' : ('Marking ' + habitViewDate.toLocaleDateString('en-IN',{weekday:'short',day:'numeric',month:'short'})); 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(); if (typeof syncProjModeFromSector === 'function') syncProjModeFromSector(); } 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) { alert('Enter a project name.'); return; } { 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(); if (typeof showToast === 'function') showToast('✅ Project "' + (nickname || n) + '" added'); } } // ===== 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) { // remove the project's scanned bills too (metadata + stored images) (data.projectBills||[]).filter(b => b.projectId === id).forEach(b => docDel('bill-' + b.id).catch(()=>{})); data.projectBills = (data.projectBills||[]).filter(b => b.projectId !== 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;} // ===== PROJECT BILLS — scanned documents per project ===== // Images are far too big for localStorage, so the scan itself lives in IndexedDB // (key 'bill-') while lightweight metadata lives in data.projectBills. function docsDB() { return window._docsDBP || (window._docsDBP = new Promise((res, rej) => { const rq = indexedDB.open('mydas_docs', 1); rq.onupgradeneeded = () => rq.result.createObjectStore('docs'); rq.onsuccess = () => res(rq.result); rq.onerror = () => rej(rq.error); })); } async function docGet(key) { const db = await docsDB(); return new Promise((res, rej) => { const r = db.transaction('docs').objectStore('docs').get(key); r.onsuccess = () => res(r.result || null); r.onerror = () => rej(r.error); }); } async function docSet(key, val) { const db = await docsDB(); return new Promise((res, rej) => { const t = db.transaction('docs', 'readwrite'); t.objectStore('docs').put(val, key); t.oncomplete = () => res(); t.onerror = () => rej(t.error); }); } async function docDel(key) { const db = await docsDB(); return new Promise((res, rej) => { const t = db.transaction('docs', 'readwrite'); t.objectStore('docs').delete(key); t.oncomplete = () => res(); t.onerror = () => rej(t.error); }); } // Downscale camera photos to ~1600px JPEG so hundreds of scans fit comfortably function compressBillImage(file) { return new Promise((res, rej) => { const img = new Image(); img.onload = () => { const scale = Math.min(1, 1600 / Math.max(img.width, img.height)); const c = document.createElement('canvas'); c.width = Math.round(img.width * scale); c.height = Math.round(img.height * scale); c.getContext('2d').drawImage(img, 0, 0, c.width, c.height); URL.revokeObjectURL(img.src); res(c.toDataURL('image/jpeg', 0.72)); }; img.onerror = () => { URL.revokeObjectURL(img.src); rej(new Error('Could not read image')); }; img.src = URL.createObjectURL(file); }); } // Scan opens a source chooser: camera, printer/flatbed scanner (guided — browsers // cannot drive scanners directly), or an existing file. function scanProjectBill(projectId, useCamera) { if (!useCamera) { billFilePick(projectId, false); return; } openScanChooser(projectId); } function openScanChooser(projectId) { document.getElementById('scanChooser')?.remove(); const m = document.createElement('div'); m.id = 'scanChooser'; m.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.45);backdrop-filter:blur(5px);z-index:10000;display:flex;align-items:center;justify-content:center;padding:16px;'; m.addEventListener('click', e => { if (e.target === m) m.remove(); }); const btn = 'display:flex;align-items:center;gap:12px;width:100%;padding:13px 16px;border:1.5px solid var(--border);border-radius:13px;background:var(--bg-surface);color:var(--text-primary);cursor:pointer;font-size:13.5px;font-weight:700;text-align:left;margin-bottom:8px;'; m.innerHTML = `
📄 Add bill scan
`; document.body.appendChild(m); } function billCameraScan(projectId) { const isMobile = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent) || (navigator.maxTouchPoints > 1 && /Mac/i.test(navigator.platform)); if (isMobile) { billFilePick(projectId, true); return; } if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) { openBillCamera(projectId); return; } if (typeof showToast === 'function') showToast('No camera available in this browser — pick the scan as a file instead'); billFilePick(projectId, true); } function printerScanHelp(projectId) { document.getElementById('printerScanModal')?.remove(); const m = document.createElement('div'); m.id = 'printerScanModal'; m.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.45);backdrop-filter:blur(5px);z-index:10000;display:flex;align-items:center;justify-content:center;padding:16px;'; m.addEventListener('click', e => { if (e.target === m) m.remove(); }); m.innerHTML = `
🖨 Scan with your printer / scanner
Web apps can't run a scanner directly, so it's a two-step job:
1. Place the bill in the scanner and scan it using your printer's app (HP Smart, Epson ScanSmart, Canon…) or your computer's scan tool — on a Mac: Image Capture / Preview, on Windows: Windows Scan. Save it as JPG, PNG or PDF.
2. Click below and pick that saved file — it attaches to the project like any scan.
`; document.body.appendChild(m); } async function openBillCamera(projectId) { document.getElementById('billCamModal')?.remove(); let stream; try { stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment', width: { ideal: 2048 }, height: { ideal: 1536 } }, audio: false }); } catch (e) { // no camera / permission denied → file picker, but say why if (typeof showToast === 'function') showToast('⚠ Camera unavailable (' + (e && e.name === 'NotAllowedError' ? 'permission denied — allow camera access in browser settings' : (e && e.name) || 'no camera') + ') — opening file picker'); billFilePick(projectId, true); return; } const overlay = document.createElement('div'); overlay.id = 'billCamModal'; overlay.style.cssText = 'position:fixed;inset:0;background:#000;z-index:10000;display:flex;flex-direction:column;'; overlay.innerHTML = `
📷 Scan bill — hold the bill flat, fill the frame, tap the shutter
`; document.body.appendChild(overlay); const video = overlay.querySelector('#billCamVideo'); const shot = overlay.querySelector('#billCamShot'); shot.disabled = true; shot.style.opacity = '0.35'; video.srcObject = stream; video.play().catch(()=>{}); const arm = () => { if (video.videoWidth) { shot.disabled = false; shot.style.opacity = '1'; } }; video.addEventListener('loadedmetadata', arm); video.addEventListener('playing', arm); setTimeout(arm, 1200); // belt-and-braces for browsers that skip the events const stop = () => { try { stream.getTracks().forEach(t => t.stop()); } catch(e){} overlay.remove(); }; overlay.querySelector('#billCamCancel').onclick = stop; overlay.querySelector('#billCamUpload').onclick = () => { stop(); billFilePick(projectId, false); }; shot.onclick = () => { if (!video.videoWidth) { if (typeof showToast === 'function') showToast('Camera is still starting — try again in a second'); return; } let c = document.createElement('canvas'); c.width = video.videoWidth; c.height = video.videoHeight; c.getContext('2d').drawImage(video, 0, 0); stop(); const scale = Math.min(1, 1600 / Math.max(c.width, c.height)); if (scale < 1) { const o = document.createElement('canvas'); o.width = Math.round(c.width * scale); o.height = Math.round(c.height * scale); o.getContext('2d').drawImage(c, 0, 0, o.width, o.height); c = o; } openBillSaveModal(projectId, 'Scan ' + dayKey(new Date()), false, c.toDataURL('image/jpeg', 0.8)); }; } function billFilePick(projectId, useCamera) { const input = document.createElement('input'); input.type = 'file'; // camera capture must be image-only — mixing PDF into accept makes phones // open the file browser instead of the camera input.accept = useCamera ? 'image/*' : 'image/*,application/pdf'; if (useCamera) input.setAttribute('capture', 'environment'); input.onchange = async () => { const file = input.files && input.files[0]; if (!file) return; try { const isPdf = /pdf/i.test(file.type) || /\.pdf$/i.test(file.name); let dataUrl; if (isPdf) { if (file.size > 8 * 1024 * 1024) { alert('PDF is too large (max 8 MB) — scan it as a photo instead.'); return; } dataUrl = await new Promise((res, rej) => { const r = new FileReader(); r.onload = () => res(r.result); r.onerror = () => rej(new Error('read failed')); r.readAsDataURL(file); }); } else { dataUrl = await compressBillImage(file); } openBillSaveModal(projectId, file.name, isPdf, dataUrl); } catch (e) { alert('Could not read the file: ' + e.message); } }; input.click(); } function openBillSaveModal(projectId, fileName, isPdf, dataUrl) { const proj = (data.projects||[]).find(p => p.id === projectId); window._pendingBillData = dataUrl; openGenModal('📄 Save Bill — ' + (proj ? escHtml(projNick(proj)) : ''), `
${isPdf ? '
📄
' + escHtml(fileName) + '
' : ``}
`, async () => { const du = window._pendingBillData; if (!du) { closeGenModal(); return; } const id = Date.now(); const bill = { id, projectId, name: (document.getElementById('pbName')?.value || fileName).trim() || fileName, amount: Number(document.getElementById('pbAmount')?.value) || 0, date: document.getElementById('pbDate')?.value || dayKey(new Date()), note: (document.getElementById('pbNote')?.value || '').trim(), pdf: !!isPdf, size: Math.round(du.length * 3 / 4) }; try { await docSet('bill-' + id, du); } catch (e) { alert('Could not store the scan: ' + (e && e.message || e)); return; } window._pendingBillData = null; data.projectBills = Array.isArray(data.projectBills) ? data.projectBills : []; data.projectBills.push(bill); saveData(); renderAll(); closeGenModal(); if (typeof showToast === 'function') showToast('📄 Bill saved to ' + (proj ? projNick(proj) : 'project')); }); } function billsFor(projectId) { return (data.projectBills||[]).filter(b => b.projectId === projectId).sort((a,b)=>(b.date||'').localeCompare(a.date||'')); } function billCountBadge(projectId) { const n = (data.projectBills||[]).filter(b => b.projectId === projectId).length; return n ? ' · ' + n : ''; } function openProjectBills(projectId) { document.getElementById('projBillsModal')?.remove(); const proj = (data.projects||[]).find(p => p.id === projectId); const bills = billsFor(projectId); const total = bills.reduce((s,b)=>s+Number(b.amount||0),0); const modal = document.createElement('div'); modal.id = 'projBillsModal'; modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.5);backdrop-filter:blur(6px);z-index:9999;display:flex;align-items:center;justify-content:center;padding:16px;'; modal.addEventListener('click', e => { if (e.target === modal) modal.remove(); }); modal.innerHTML = `
📄 Bills — ${proj ? escHtml(projNick(proj)) : ''}
${bills.length} document${bills.length===1?'':'s'}${total>0?' · total '+fmtINR(total):''}
${bills.length ? bills.map(b => `
${b.pdf?'📄':'🖼'}
${escHtml(b.name)}
${b.date||''}${b.amount?' · '+fmtINR(b.amount):''}${b.note?' · '+escHtml(b.note):''}
`).join('') : '
No bills yet — 📷 Scan or 📤 Upload the first one.
'}
`; document.body.appendChild(modal); bills.filter(b => !b.pdf).forEach(b => docGet('bill-' + b.id).then(du => { const t = document.getElementById('pbThumb' + b.id); if (t && du) t.innerHTML = ''; }).catch(()=>{})); } async function viewProjectBill(id) { const b = (data.projectBills||[]).find(x => x.id === id); if (!b) return; const du = await docGet('bill-' + id); if (!du) { alert('Scan not found on this device (scans are stored per browser).'); return; } document.getElementById('billViewModal')?.remove(); const overlay = document.createElement('div'); overlay.id = 'billViewModal'; overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.85);z-index:10000;display:flex;align-items:center;justify-content:center;padding:18px;'; overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); }); if (b.pdf) { const blob = await (await fetch(du)).blob(); overlay.innerHTML = ``; } else { overlay.innerHTML = ``; } document.body.appendChild(overlay); } async function downloadProjectBill(id) { const b = (data.projectBills||[]).find(x => x.id === id); if (!b) return; const du = await docGet('bill-' + id); if (!du) { alert('Scan not found on this device.'); return; } const blob = await (await fetch(du)).blob(); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = (b.name || 'bill').replace(/[\\/:*?"<>|]/g, '_') + (b.pdf ? '.pdf' : '.jpg'); a.click(); } function delProjectBill(id, projectId) { const b = (data.projectBills||[]).find(x => x.id === id); if (!b) return; if (!confirm('Delete "' + b.name + '"?')) return; data.projectBills = (data.projectBills||[]).filter(x => x.id !== id); docDel('bill-' + id).catch(()=>{}); saveData(); renderAll(); openProjectBills(projectId); } 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(''); } // ===== PROJECT SECTOR / WORKFLOW MODE ===== // Highlights the active mode button and shows/hides government-only fields (no value change) function applyProjModeUI(mode) { document.querySelectorAll('#projModeGroup .filter-btn').forEach(b => b.classList.toggle('active', b.dataset.pmode === mode)); const gov = (mode === 'government'); ['projFileNoCell','projEstimateNoCell'].forEach(idc => { const el = document.getElementById(idc); if (el) el.style.display = gov ? '' : 'none'; }); window._projMode = mode; } // Button click: also fills the Sector value to match the chosen mode function setProjMode(mode) { const sec = document.getElementById('projCategory'); if (sec) { if (mode === 'government') sec.value = 'Government'; else if (mode === 'private') sec.value = 'Private'; else sec.value = ''; } if (typeof refreshProjCatLists === 'function') refreshProjCatLists(); applyProjModeUI(mode); if (sec && mode === 'other') sec.focus(); } // Infer mode from whatever is typed in the Sector field (keeps buttons in sync) function syncProjModeFromSector() { const sec = document.getElementById('projCategory'); const v = (sec ? sec.value : '').trim().toLowerCase(); let mode = 'government'; if (v.includes('priv')) mode = 'private'; else if (v && !v.includes('gov')) mode = 'other'; applyProjModeUI(mode); } 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(); } const ROUTINE_DOW = [{i:1,l:'Mon'},{i:2,l:'Tue'},{i:3,l:'Wed'},{i:4,l:'Thu'},{i:5,l:'Fri'},{i:6,l:'Sat'},{i:0,l:'Sun'}]; const DOW_LABEL = {0:'Sun',1:'Mon',2:'Tue',3:'Wed',4:'Thu',5:'Fri',6:'Sat'}; function genFreqChange() { const f = document.getElementById('genFreq')?.value; const wrap = document.getElementById('genDaysWrap'); if (wrap) wrap.style.display = (f === 'weekly' || f === 'biweekly') ? '' : 'none'; } function toggleRoutineDay(i, btn) { window._routineDays = window._routineDays || []; const at = window._routineDays.indexOf(i); if (at >= 0) { window._routineDays.splice(at, 1); btn.classList.remove('active'); } else { window._routineDays.push(i); btn.classList.add('active'); } } function editRoutine(id) { const r = data.routines.find(x=>x.id===id); if (!r) return; window._routineDays = Array.isArray(r.days) ? r.days.slice() : []; const dayBtns = ROUTINE_DOW.map(d => `` ).join(''); const showDays = (r.freq === 'weekly' || r.freq === 'biweekly'); openGenModal('Edit Routine', `
${dayBtns}
`, () => { const n = document.getElementById('genName').value.trim(); if (n) { r.name = n; r.freq = document.getElementById('genFreq').value; r.days = (r.freq==='weekly' || r.freq==='biweekly') ? (window._routineDays||[]).slice().sort((a,b)=>a-b) : []; syncRoutineTasks(); saveData(); renderAll(); 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(); renderAll(); } } // Routine items may be plain strings (legacy) or objects {text, time, dur} function rItemText(it) { return typeof it === 'string' ? it : ((it && it.text) || ''); } function rItemTime(it) { return (it && typeof it === 'object') ? (it.time || '') : ''; } function rItemDur(it) { return (it && typeof it === 'object') ? (it.dur || '') : ''; } function rItemEnsureObj(r, idx) { if (typeof r.items[idx] === 'string') r.items[idx] = { text: r.items[idx] }; return r.items[idx]; } // When a step's time/duration changes, restamp any already-created task instances function restampRoutineItem(r, idx) { const it = r.items[idx]; const prefix = 'R' + r.id + '-' + idx + '-'; (data.tasks || []).forEach(t => { if (t.rkey && t.rkey.indexOf(prefix) === 0) { t.reminderTime = rItemTime(it) || ''; t.durationMin = rItemDur(it) ? parseInt(rItemDur(it), 10) : undefined; } }); } function setRoutineItemTime(id, idx, val) { const r = data.routines.find(x=>x.id===id); if (!r) return; const it = rItemEnsureObj(r, idx); it.time = val || ''; restampRoutineItem(r, idx); saveData(); renderAll(); } function setRoutineItemDur(id, idx, val) { const r = data.routines.find(x=>x.id===id); if (!r) return; const it = rItemEnsureObj(r, idx); const n = parseInt(val,10); it.dur = (!isNaN(n)&&n>0)?n:''; restampRoutineItem(r, idx); saveData(); renderAll(); } // Deploy all routine items into the task list with the routine's frequency // ===== ROUTINE RECURRENCE ENGINE ===== // Decides whether a routine "fires" on a given calendar date. function routineMatchesDate(r, dateObj) { const freq = r.freq || 'daily'; if (freq === 'daily') return true; const anchor = new Date(r.id); anchor.setHours(0,0,0,0); if (freq === 'monthly') { return dateObj.getDate() === anchor.getDate(); } // weekly / biweekly: use chosen days, else fall back to the routine's creation weekday const days = (Array.isArray(r.days) && r.days.length) ? r.days : [ anchor.getDay() ]; if (!days.includes(dateObj.getDay())) return false; if (freq === 'biweekly') { const d0 = new Date(dateObj); d0.setHours(0,0,0,0); const weeks = Math.floor((d0 - anchor) / (7*24*3600*1000)); return (((weeks % 2) + 2) % 2) === 0; } return true; } // Materialises routine items as real tasks for every matching day in the // currently-viewed window (plus today), so routines recur onto future days. function syncRoutineTasks() { if (!Array.isArray(data.routines) || !data.routines.length) return; if (!Array.isArray(data.tasks)) data.tasks = []; data.routineSkips = data.routineSkips || {}; let range; try { range = getPeriodRange(); } catch(e) { range = null; } let start = range ? new Date(range.start) : new Date(); let end = range ? new Date(range.end) : new Date(); start.setHours(0,0,0,0); end.setHours(0,0,0,0); const today = new Date(); today.setHours(0,0,0,0); if (today < start) start = new Date(today); if (today > end) end = new Date(today); // Safety cap so we never create an unbounded number of tasks const capEnd = new Date(start); capEnd.setDate(capEnd.getDate() + 92); if (end > capEnd) end = capEnd; const existing = new Set(data.tasks.map(t => t.rkey).filter(Boolean)); let added = false; for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) { const dk = dayKey(d); data.routines.forEach(r => { if (!r.items || !r.items.length) return; if (!routineMatchesDate(r, d)) return; r.items.forEach((it, i) => { const rkey = 'R' + r.id + '-' + i + '-' + dk; if (existing.has(rkey) || data.routineSkips[rkey]) return; const durN = rItemDur(it) ? parseInt(rItemDur(it),10) : undefined; data.tasks.push({ id: Date.now() + Math.floor(Math.random()*100000), text: rItemText(it), completed:false, date:dk, freq:r.freq, routine:r.name, routineId:r.id, rkey, reminderTime: rItemTime(it) || '', durationMin: durN }); existing.add(rkey); added = true; }); }); } if (added) saveData(); } function deployRoutine(id) { const r = data.routines.find(x=>x.id===id); if (!r || !r.items.length) return; const dk = dayKey(new Date()); data.routineSkips = data.routineSkips || {}; r.items.forEach((it,i) => { const rkey = 'R' + r.id + '-' + i + '-' + dk; if (data.routineSkips[rkey]) delete data.routineSkips[rkey]; if (data.tasks.some(t => t.rkey === rkey)) return; const durN = rItemDur(it) ? parseInt(rItemDur(it),10) : undefined; data.tasks.push({ id:Date.now()+i, text: rItemText(it), completed:false, date:dk, freq:r.freq, routine:r.name, routineId:r.id, rkey, reminderTime: rItemTime(it) || '', durationMin: durN }); }); saveData(); renderAll(); 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(); } // ===== REVIEW (Productivity overview of tasks + habits + routines) ===== function habitAppliesOn(h, d) { const f = h.freq || 'daily'; if (f === 'daily') return true; if (f === 'weekly' || f === 'biweekly') { if (Array.isArray(h.days) && h.days.length) return h.days.includes(d.getDay()); return true; } return true; // monthly / other → count as applicable } function reviewKpi(label, val, sub, color) { return `
${label}
${val}
${sub}
`; } function reviewBar(pct, color) { return `
`; } // ===== DAILY JOURNAL — one reflective entry per day: notes, mood, gratitude, highlight ===== const JOURNAL_MOODS = [ { k:'great', e:'😄', l:'Great', c:'#22c55e' }, { k:'good', e:'🙂', l:'Good', c:'#8bc34a' }, { k:'okay', e:'😐', l:'Okay', c:'#f59e0b' }, { k:'low', e:'😔', l:'Low', c:'#fb923c' }, { k:'bad', e:'😣', l:'Rough', c:'#ef4444' }, ]; function journalGet(dk){ return (data.journal||[]).find(j=>j.date===dk); } function renderJournal() { const el = document.getElementById('journalContent'); if (!el) return; const dk = window._journalDate || dayKey(new Date()); const entry = journalGet(dk) || { date:dk, mood:'', note:'', gratitude:'', highlight:'', tomorrow:'' }; const dObj = new Date(dk + 'T00:00:00'); const isToday = dk === dayKey(new Date()); const entries = (data.journal||[]).slice().sort((a,b)=>(b.date||'').localeCompare(a.date||'')); const streak = (() => { let s=0; const d=new Date(); d.setHours(0,0,0,0); for(;;){ const k=dayKey(d); if(journalGet(k) && (journalGet(k).note||journalGet(k).mood)){ s++; d.setDate(d.getDate()-1);} else break; } return s; })(); const moodBtns = JOURNAL_MOODS.map(m=>` `).join(''); const ta=(id,val,ph,rows)=>``; const pastList = entries.filter(j=>j.date!==dk && (j.note||j.mood||j.gratitude||j.highlight)).slice(0,30).map(j=>{ const m = JOURNAL_MOODS.find(x=>x.k===j.mood); const dd = new Date(j.date+'T00:00:00'); const snippet = (j.highlight||j.note||j.gratitude||'').slice(0,90); return `
${m?m.e:'📝'}
${dd.toLocaleDateString('en-IN',{weekday:'short',day:'numeric',month:'short'})}
${escHtml(snippet)||'—'}
`; }).join('') || '
No past entries yet — your reflections will appear here.
'; el.innerHTML = `
📔 ${dObj.toLocaleDateString('en-IN',{weekday:'long',day:'numeric',month:'long',year:'numeric'})}
${isToday?'Today':''}${streak>0?`${isToday?' · ':''}🔥 ${streak}-day streak`:''}
${!isToday?``:''}
How was your day?
${moodBtns}
📝 Journal — what happened today?
${ta('jNote', entry.note, 'Write freely about your day…', 6)}
🙏 Grateful for
${ta('jGrat', entry.gratitude, 'Three things you are thankful for…', 3)}
⭐ Win of the day
${ta('jHigh', entry.highlight, 'Your highlight / best moment…', 3)}
🎯 One focus for tomorrow
${ta('jTom', entry.tomorrow, 'What is the single most important thing tomorrow?', 2)}
${journalGet(dk)?``:''}
🗓 Past Entries
${entries.filter(j=>j.note||j.mood).length} logged
${pastList}
`; } function journalShiftDay(delta) { const cur = window._journalDate || dayKey(new Date()); const d = new Date(cur + 'T00:00:00'); d.setDate(d.getDate() + delta); const todayK = dayKey(new Date()); const nk = dayKey(d); if (nk > todayK) return; // don't go into the future window._journalDate = nk; renderJournal(); } function journalSetMood(k) { const dk = window._journalDate || dayKey(new Date()); let e = journalGet(dk); if (!e) { e = { date:dk, mood:'', note:'', gratitude:'', highlight:'', tomorrow:'' }; data.journal.push(e); } e.mood = (e.mood === k ? '' : k); saveData(); renderJournal(); } function journalSave() { const dk = window._journalDate || dayKey(new Date()); const g = id => { const el = document.getElementById(id); return el ? el.value.trim() : ''; }; const note = g('jNote'), gratitude = g('jGrat'), highlight = g('jHigh'), tomorrow = g('jTom'); let e = journalGet(dk); if (!e) { e = { date:dk, mood:'' }; data.journal.push(e); } e.note = note; e.gratitude = gratitude; e.highlight = highlight; e.tomorrow = tomorrow; e.updated = Date.now(); // drop fully-empty entries so they don't count if (!note && !gratitude && !highlight && !tomorrow && !e.mood) data.journal = data.journal.filter(x=>x.date!==dk); saveData(); const n = document.getElementById('jSaveNote'); if (n) { n.textContent = '✓ Saved'; setTimeout(()=>{ if(n) n.textContent=''; }, 2000); } if (typeof showToast === 'function') showToast('📔 Journal entry saved'); renderJournal(); } function journalDelete(dk) { if (!confirm('Delete this journal entry?')) return; data.journal = (data.journal||[]).filter(x=>x.date!==dk); saveData(); renderJournal(); if (typeof showToast === 'function') showToast('🗑 Entry deleted'); } // ===== CLOUD STORAGE — folders, subfolders & files (metadata in data, blobs in IndexedDB) ===== function storFmtBytes(n){ n=Number(n)||0; if(n<1024)return n+' B'; if(n<1048576)return (n/1024).toFixed(1)+' KB'; if(n<1073741824)return (n/1048576).toFixed(1)+' MB'; return (n/1073741824).toFixed(2)+' GB'; } function storFileIcon(name,type){ const e=(String(name).split('.').pop()||'').toLowerCase(); type=String(type||''); if(/pdf/.test(type)||e==='pdf')return '📄'; if(/image/.test(type)||['png','jpg','jpeg','gif','webp','svg','heic','bmp'].includes(e))return '🖼️'; if(['xls','xlsx','csv'].includes(e))return '📊'; if(['doc','docx','txt','rtf'].includes(e))return '📝'; if(['ppt','pptx'].includes(e))return '📽️'; if(['zip','rar','7z','tar','gz'].includes(e))return '🗜️'; if(/audio/.test(type)||['mp3','wav','m4a'].includes(e))return '🎵'; if(/video/.test(type)||['mp4','mov','avi','mkv'].includes(e))return '🎬'; return '📎'; } function storDescendantFolders(fid){ const out=[]; const stack=[fid]; while(stack.length){ const cur=stack.pop(); (data.storageFolders||[]).filter(f=>f.parentId===cur).forEach(f=>{ out.push(f.id); stack.push(f.id); }); } return out; } function storCrumbs(){ const crumbs=[{id:null,name:'Cloud Storage'}]; let cur=window._storageCwd??null; const chain=[]; let guard=0; while(cur!=null && guard++<50){ const f=(data.storageFolders||[]).find(x=>x.id===cur); if(!f)break; chain.unshift(f); cur=(f.parentId!=null)?f.parentId:null; } chain.forEach(f=>crumbs.push({id:f.id,name:f.name})); return crumbs; } function storNewFolder(){ const name=prompt('New folder name:'); if(!name||!name.trim())return; data.storageFolders.push({ id:Date.now()+Math.floor(Math.random()*1000), name:name.trim(), parentId:(window._storageCwd??null), ts:Date.now() }); saveData(); renderStorage(); } function storRenameFolder(id){ const f=(data.storageFolders||[]).find(x=>x.id===id); if(!f)return; const n=prompt('Rename folder:',f.name); if(n&&n.trim()){ f.name=n.trim(); saveData(); renderStorage(); } } async function storDeleteFolder(id){ const f=(data.storageFolders||[]).find(x=>x.id===id); if(!f)return; if(!confirm(`Delete folder "${f.name}" and everything inside it?`))return; const all=[id,...storDescendantFolders(id)]; const files=(data.storageFiles||[]).filter(x=>all.includes(x.folderId)); for(const fl of files){ try{ await docDel(fl.key); }catch(e){} } data.storageFiles=(data.storageFiles||[]).filter(x=>!all.includes(x.folderId)); data.storageFolders=(data.storageFolders||[]).filter(x=>!all.includes(x.id)); saveData(); renderStorage(); if(typeof showToast==='function') showToast('🗑 Folder deleted'); } function storOpen(id){ window._storageCwd = (id==null?null:id); renderStorage(); } function storUp(){ const cur=window._storageCwd; if(cur==null)return; const f=(data.storageFolders||[]).find(x=>x.id===cur); window._storageCwd=f?(f.parentId??null):null; renderStorage(); } function storUpload(){ const inp=document.getElementById('storageFileInput'); if(inp) inp.click(); } async function storFilesChosen(input){ const files=Array.from(input.files||[]); if(!files.length)return; const cwd=(window._storageCwd??null); let done=0; for(const fobj of files){ try{ const dataUrl=await new Promise((res,rej)=>{ const r=new FileReader(); r.onload=()=>res(r.result); r.onerror=()=>rej(r.error); r.readAsDataURL(fobj); }); const id=Date.now()+Math.floor(Math.random()*100000); const key='stor-'+id; await docSet(key,dataUrl); data.storageFiles.push({ id, name:fobj.name, folderId:cwd, size:fobj.size, type:fobj.type||'', key, ts:Date.now() }); done++; }catch(e){ console.error('storage upload failed',e); } } input.value=''; saveData(); renderStorage(); if(typeof showToast==='function') showToast(`☁️ ${done} file${done===1?'':'s'} uploaded`); } async function storDownloadFile(id){ const f=(data.storageFiles||[]).find(x=>x.id===id); if(!f)return; try{ const u=await docGet(f.key); if(!u){ alert('File data not found'); return; } const a=document.createElement('a'); a.href=u; a.download=f.name; document.body.appendChild(a); a.click(); a.remove(); }catch(e){ alert('Could not download file'); } } async function storViewFile(id){ const f=(data.storageFiles||[]).find(x=>x.id===id); if(!f)return; try{ const u=await docGet(f.key); if(!u)return; const blob=await (await fetch(u)).blob(); const url=URL.createObjectURL(blob); window.open(url,'_blank'); setTimeout(()=>URL.revokeObjectURL(url),60000); } catch(e){ storDownloadFile(id); } } function storDeleteFile(id){ const f=(data.storageFiles||[]).find(x=>x.id===id); if(!f)return; if(!confirm(`Delete "${f.name}"?`))return; docDel(f.key).catch(()=>{}); data.storageFiles=(data.storageFiles||[]).filter(x=>x.id!==id); saveData(); renderStorage(); } function renderStorage(){ const el=document.getElementById('storagePageContent'); if(!el)return; if(!document.getElementById('storStyles')){ const s=document.createElement('style'); s.id='storStyles'; s.textContent=`.stor-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:10px;} .stor-item{display:flex;align-items:center;gap:10px;padding:11px 12px;border:1px solid var(--border);border-radius:13px;background:var(--bg-card);transition:.12s;} .stor-item:hover{background:var(--bg-hover);border-color:var(--border-light);} .stor-crumb{cursor:pointer;color:var(--accent);font-weight:700;}`; document.head.appendChild(s); } const cwd=(window._storageCwd??null); const folders=(data.storageFolders||[]).filter(f=>(f.parentId??null)===cwd).sort((a,b)=>String(a.name).localeCompare(String(b.name))); const files=(data.storageFiles||[]).filter(f=>(f.folderId??null)===cwd).sort((a,b)=>(b.ts||0)-(a.ts||0)); const totFiles=(data.storageFiles||[]).length, totSize=(data.storageFiles||[]).reduce((s,f)=>s+(Number(f.size)||0),0); const crumbs=storCrumbs(); const crumbHtml=crumbs.map((c,i)=> i${escHtml(c.name)}` : `${escHtml(c.name)}` ).join(''); const folderCards=folders.map(f=>{ const kids=(data.storageFolders||[]).filter(x=>x.parentId===f.id).length, nf=(data.storageFiles||[]).filter(x=>x.folderId===f.id).length; return `
📁
${escHtml(f.name)}
${kids?`${kids} folder${kids>1?'s':''} · `:''}${nf} file${nf===1?'':'s'}
`; }).join(''); const fileCards=files.map(f=>`
${storFileIcon(f.name,f.type)}
${escHtml(f.name)}
${storFmtBytes(f.size)}
`).join(''); el.innerHTML=`
☁️ Cloud Storage
${totFiles} file${totFiles===1?'':'s'} · ${storFmtBytes(totSize)} · saved on this device
${cwd!=null?``:''}
${crumbHtml}
${folders.length?`
Folders
${folderCards}
`:''} ${files.length?`
Files
${fileCards}
`:''} ${(!folders.length&&!files.length)?`
📂
This folder is empty
Create a folder or upload files to get started.
`:''}
`; } function renderReview() { const kpiEl = document.getElementById('reviewKpis'); if (!kpiEl) return; const range = getPeriodRange(); const todayK = dayKey(new Date()); const pLabel = ({day:'today',week:'this week',month:'this month',all:'all time'})[currentPeriod] || 'this period'; // ---- Tasks ---- const periodTasks = data.tasks.filter(t => inRange(t.date, range) && !t.skipped); const totalT = periodTasks.length; const doneT = periodTasks.filter(t => t.completed).length; const pendingT = totalT - doneT; const overdueAll = data.tasks.filter(t => !t.completed && !t.skipped && dayKey(t.date) < todayK).length; const compRate = totalT ? Math.round(doneT / totalT * 100) : 0; // ---- Habits ---- const habits = data.habits || []; const avgStreak = habits.length ? Math.round(habits.reduce((s,h)=>s+(h.streak||0),0)/habits.length) : 0; const doneTodayH = habits.filter(h => (h.completedDates||[]).includes(new Date().toLocaleDateString())).length; // ---- Routines ---- const routines = data.routines || []; const totalSteps = routines.reduce((s,r)=> s + (r.items?r.items.length:0), 0); kpiEl.innerHTML = [ reviewKpi('Task Completion', compRate + '%', `${doneT}/${totalT} done ${pLabel}`, 'accent'), reviewKpi('Pending Tasks', pendingT, overdueAll ? `${overdueAll} overdue overall` : 'nothing overdue', overdueAll ? 'rose' : 'sky'), reviewKpi('Habits Today', `${doneTodayH}/${habits.length}`, `avg streak ${avgStreak} 🔥`, 'amber'), reviewKpi('Routines', routines.length, `${totalSteps} step${totalSteps!==1?'s':''} total`, 'violet'), ].join(''); // ---- Tasks panel ---- const tMeta = document.getElementById('reviewTaskMeta'); if (tMeta) tMeta.textContent = pLabel; const byCat = {}; periodTasks.forEach(t => { const c = t.category || 'Uncategorised'; byCat[c] = byCat[c] || {n:0,d:0}; byCat[c].n++; if (t.completed) byCat[c].d++; }); const cats = Object.keys(byCat).sort((a,b)=>byCat[b].n-byCat[a].n).slice(0,6); const overdueList = data.tasks.filter(t => !t.completed && !t.skipped && dayKey(t.date) < todayK).sort((a,b)=>dayKey(a.date).localeCompare(dayKey(b.date))).slice(0,5); const tEl = document.getElementById('reviewTasks'); if (tEl) tEl.innerHTML = `
${doneT} done ${pendingT} pending ${overdueAll?`${overdueAll} overdue`:''}
${reviewBar(compRate,'accent')}
By category
${cats.length ? cats.map(c => { const o=byCat[c]; const pct=o.n?Math.round(o.d/o.n*100):0; return `
${escHtml(c)}${o.d}/${o.n}
${reviewBar(pct,'accent')}
`; }).join('') : '
No tasks in this period
'} ${overdueList.length ? `
⚠ Overdue
${overdueList.map(t=>`
${escHtml(t.text)} ${new Date(dayKey(t.date)+'T00:00:00').toLocaleDateString('en-IN',{day:'numeric',month:'short'})}
`).join('')}` : ''}`; // ---- Habits panel ---- const hMeta = document.getElementById('reviewHabitMeta'); if (hMeta) hMeta.textContent = `${habits.length} habit${habits.length!==1?'s':''}`; const hEl = document.getElementById('reviewHabits'); if (hEl) hEl.innerHTML = habits.length ? habits.map(h => { const cd = h.completedDates || []; let applicable = 0, hit = 0; (range.days||[]).forEach(d => { if (habitAppliesOn(h,d)) { applicable++; if (cd.includes(d.toLocaleDateString())) hit++; } }); const rate = applicable ? Math.round(hit/applicable*100) : 0; return `
${escHtml(h.name)} ${freqLabel[h.freq||'daily']} ${h.streak||0}🔥 · ${hit}/${applicable} (${rate}%)
${reviewBar(rate,'amber')}
`; }).join('') : '
No habits yet
'; // ---- Routines panel ---- const rMeta = document.getElementById('reviewRoutineMeta'); if (rMeta) rMeta.textContent = `${routines.length} routine${routines.length!==1?'s':''}`; const rEl = document.getElementById('reviewRoutines'); if (rEl) rEl.innerHTML = routines.length ? routines.map(r => { const items = r.items || []; const timed = items.filter(it => rItemTime(it)).length; // next occurrence within 14 days let nextTxt = ''; for (let i=0;i<14;i++){ const d=new Date(); d.setHours(0,0,0,0); d.setDate(d.getDate()+i); if (routineMatchesDate(r,d)){ nextTxt = i===0?'Today':(i===1?'Tomorrow':d.toLocaleDateString('en-IN',{weekday:'short',day:'numeric',month:'short'})); break; } } const dayChips = ((r.freq==='weekly'||r.freq==='biweekly') && Array.isArray(r.days) && r.days.length) ? r.days.slice().sort((a,b)=>a-b).map(d=>`${DOW_LABEL[d]}`).join('') : ''; return `
${escHtml(r.name)} ${freqLabel[r.freq]||r.freq} ${dayChips} ${items.length} step${items.length!==1?'s':''} · ${timed} timed ${nextTxt?`Next: ${nextTxt}`:''}
${items.map(it=>`${rItemTime(it)?`${fmtTime12(rItemTime(it))} `:''}${escHtml(rItemText(it))}`).join('') || 'No steps'}
`; }).join('') : '
No routines yet
'; } 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.freq==='weekly'||r.freq==='biweekly') && Array.isArray(r.days) && r.days.length) ? `${r.days.slice().sort((a,b)=>a-b).map(d=>`${DOW_LABEL[d]}`).join('')}` : ''} ${r.items.length} item${r.items.length!==1?'s':''}
${r.items.map((it,idx)=>`
· ${escHtml(rItemText(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 = ''; } // Party dropdown adapts to Payment In (clients) vs outflow (vendors / labour / subcontractors) function onAcctTypeChange() { const typeEl = document.getElementById('acctType'); const partyEl = document.getElementById('acctParty'); const dl = document.getElementById('acctPartyList'); if (!typeEl || !partyEl || !dl) return; const dir = (ACCOUNT_TYPES[typeEl.value] || {}).direction || 'out'; let names, ph; if (dir === 'in') { names = [ ...(data.projects || []).map(p => p.client), ...(data.clients || []).map(c => c.name || c), ...(data.accounts || []).filter(e => (ACCOUNT_TYPES[e.type]||{}).direction === 'in').map(e => e.party) ]; ph = 'Client'; } else { names = [ ...(data.vendors || []).map(v => v.name), ...(data.labours || []).map(l => l.name), ...(data.subcontractors || []).map(s => s.name) ]; ph = 'Vendor / Labour / Payee'; } const uniq = [...new Set(names.filter(Boolean))].sort((a, b) => a.localeCompare(b)); dl.innerHTML = uniq.map(n => ``).join(''); partyEl.placeholder = ph; } 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(); if (typeof onAcctTypeChange === 'function') onAcctTypeChange(); 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; } // Party dropdown for Expected OUT — depends on the selected type if (document.getElementById('expOutParty')) expOutTypeChanged(true); } // Rebuild the second dropdown based on the OUT type (vendor / material / subcontractor / debt / labour / other) function expOutTypeChanged(keepValue) { const type = document.getElementById('expOutType')?.value || 'vendor'; const sel = document.getElementById('expOutParty'); const txt = document.getElementById('expOutPartyText'); if (!sel || !txt) return; const prev = keepValue ? sel.value : ''; let opts = [], placeholder = 'Select…', freeText = false; const byName = (a,b)=>(a||'').localeCompare(b||''); if (type === 'vendor') { placeholder = 'Select vendor…'; opts = (data.vendors||[]).slice().sort((a,b)=>byName(a.name,b.name)).map(v => [v.name, v.name + (v.category?' · '+v.category:'')]); } else if (type === 'material') { placeholder = 'Material supplier…'; const mv = (data.vendors||[]).filter(v => /material|hardware|cement|steel|sand|aggregate|supply/i.test((v.category||'')+' '+(v.name||''))); opts = (mv.length?mv:(data.vendors||[])).slice().sort((a,b)=>byName(a.name,b.name)).map(v=>[v.name, v.name+(v.category?' · '+v.category:'')]); freeText = true; // also allow typing a new supplier } else if (type === 'subcontractor') { placeholder = 'Select subcontractor…'; opts = (data.subcontractors||[]).slice().sort((a,b)=>byName(a.name,b.name)).map(s => [s.name, s.name + (s.trade?' · '+s.trade:'')]); } else if (type === 'debt') { placeholder = 'Select loan / lender…'; opts = (data.loans||[]).filter(l=>l.status!=='closed').map(l => [l.lender||'Lender', (l.lender||'Lender') + (l.emi?' · EMI '+fmtINR(l.emi):'')]); } else if (type === 'labour') { placeholder = 'Select worker / gang…'; const ls = (data.vendors||[]).filter(v => (v.category||'')==='Labour Supply'); opts = [...ls.map(v=>[v.name, v.name+' · Labour Supply']), ...(data.labours||[]).map(l=>[l.name, l.name+(l.group?' · '+l.group:'')])]; } else { // other freeText = true; } const useSelect = opts.length > 0 && type !== 'other'; sel.style.display = useSelect ? '' : 'none'; txt.style.display = (!useSelect || freeText) ? '' : 'none'; if (useSelect) { sel.innerHTML = `` + opts.map(([v,l])=>``).join(''); if (keepValue && opts.some(o=>o[0]===prev)) sel.value = prev; } if (!keepValue) { const a = document.getElementById('expOutAmount'); if (a) a.value=''; if (txt) txt.value=''; } } // 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 the party auto-fetches the amount payable based on the chosen type function expOutPartyChanged() { const type = document.getElementById('expOutType')?.value || 'vendor'; const name = document.getElementById('expOutParty')?.value || ''; const amtEl = document.getElementById('expOutAmount'); if (!name || !amtEl) return; let payable = 0; if (type === 'vendor' || type === 'material' || type === 'labour') { const v = (data.vendors||[]).find(x => x.name === name); if (v) payable += Number(v.outstanding) || 0; payable += (data.accounts||[]).filter(a => a.party===name && a.status!=='paid' && a.type==='vendor-payment').reduce((s,a)=>s+Number(a.amount||0),0); } else if (type === 'subcontractor') { payable += (data.funds||[]).filter(f => (f.party===name||f.subcontractor===name) && f.status!=='paid').reduce((s,f)=>s+Number(f.amount||0),0); payable += (data.accounts||[]).filter(a => a.party===name && a.status!=='paid' && a.type==='vendor-payment').reduce((s,a)=>s+Number(a.amount||0),0); } else if (type === 'debt') { const l = (data.loans||[]).find(x => (x.lender||'') === name); if (l) payable = Math.round(Number(l.emi) || 0); // next EMI due } if (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 type = document.getElementById('expOutType')?.value || 'vendor'; const date = document.getElementById('expOutDate').value; const amount = Number(document.getElementById('expOutAmount').value) || 0; const selEl = document.getElementById('expOutParty'); const txtEl = document.getElementById('expOutPartyText'); // party = the visible control's value (dropdown or free-text) const party = (selEl && selEl.style.display !== 'none' && selEl.value) ? selEl.value.trim() : (txtEl?.value || '').trim(); const projectId = document.getElementById('expOutProject').value || null; if (!amount || !date) { alert('Enter an amount and the expected date.'); return; } if (!party) { alert('Select or type who the payment is to.'); return; } const proj = projectId ? data.projects.find(p => String(p.id) === String(projectId)) : null; const kindLabel = { vendor:'Vendor', material:'Material', subcontractor:'Subcontractor', debt:'Debt repay', labour:'Labour', other:'Other' }[type] || 'Payment'; data.accounts.push({ id: Date.now(), type: 'vendor-payment', outKind: type, category: 'out-'+type, projectId: proj ? proj.id : null, company: proj ? proj.company : null, party, amount, date: dayKey(new Date()), status: 'pending', dueDate: date, note: kindLabel }); document.getElementById('expOutDate').value = ''; document.getElementById('expOutAmount').value = ''; if (selEl) selEl.value = ''; if (txtEl) txtEl.value = ''; const op = document.getElementById('expOutProject'); if (op) op.value = ''; saveData(); renderAll(); if (typeof showToast === 'function') showToast('↑ Expected OUT · ' + kindLabel + ' ' + fmtINR(amount) + ' added'); } // ----- 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(); } // ===== EXPORT TASKS & REMINDERS AS A CALENDAR FILE (.ics) ===== // Import the file into Google/Apple Calendar → reminders appear on phone & watch // with the watch's native snooze/dismiss actions. function icsEscape(s) { return String(s||'').replace(/\\/g,'\\\\').replace(/;/g,'\\;').replace(/,/g,'\\,').replace(/\r?\n/g,'\\n'); } function exportTasksICS() { const todayK = dayKey(new Date()); const stamp = todayK.replace(/-/g,'') + 'T000000'; const L = ['BEGIN:VCALENDAR','VERSION:2.0','PRODID:-//MYDAS//Tasks//EN','CALSCALE:GREGORIAN','METHOD:PUBLISH','X-WR-CALNAME:MYDAS Reminders']; let n = 0; // Pending tasks with a date (timed ones get a 10-min-before alarm) (data.tasks||[]).filter(t => !t.completed && t.date).forEach(t => { const d = String(t.date).replace(/-/g,''); if (d.length !== 8) return; const hasTime = /^\d{1,2}:\d{2}/.test(t.reminderTime||''); L.push('BEGIN:VEVENT', 'UID:task-' + t.id + '@mydas', 'DTSTAMP:' + stamp); if (hasTime) L.push('DTSTART:' + d + 'T' + t.reminderTime.padStart(5,'0').replace(':','') + '00'); else L.push('DTSTART;VALUE=DATE:' + d); L.push('SUMMARY:' + icsEscape((t.urgent ? '🔴 ' : '') + t.text)); if (t.category) L.push('CATEGORIES:' + icsEscape(t.category)); if (t.freq === 'daily') L.push('RRULE:FREQ=DAILY'); else if (t.freq === 'weekly') L.push('RRULE:FREQ=WEEKLY'); else if (t.freq === 'monthly') L.push('RRULE:FREQ=MONTHLY'); L.push('BEGIN:VALARM','ACTION:DISPLAY','DESCRIPTION:' + icsEscape(t.text),'TRIGGER:' + (hasTime ? '-PT10M' : '-PT0M'),'END:VALARM','END:VEVENT'); n++; }); // Upcoming payment dues from the ledger (data.accounts||[]).filter(a => a.status === 'pending' && a.dueDate && a.dueDate >= todayK).forEach(a => { const d = String(a.dueDate).replace(/-/g,''); if (d.length !== 8) return; L.push('BEGIN:VEVENT', 'UID:pay-' + a.id + '@mydas', 'DTSTAMP:' + stamp, 'DTSTART;VALUE=DATE:' + d, 'SUMMARY:' + icsEscape('💸 Pay ' + (a.party||'—') + ' — ' + fmtINR(a.amount)), 'CATEGORIES:Payment', 'BEGIN:VALARM','ACTION:DISPLAY','DESCRIPTION:' + icsEscape('Payment due — ' + (a.party||'')),'TRIGGER:-PT0M','END:VALARM','END:VEVENT'); n++; }); L.push('END:VCALENDAR'); if (!n) { (typeof showToast === 'function' ? showToast : alert)('No dated pending tasks or dues to export'); return; } const blob = new Blob([L.join('\r\n')], { type: 'text/calendar;charset=utf-8' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'MYDAS-reminders-' + todayK + '.ics'; a.click(); if (typeof showToast === 'function') showToast('📥 ' + n + ' reminder' + (n===1?'':'s') + ' exported — open the file to add them to your calendar'); } // ===== PROFIT & LOSS DASHBOARD (inside Funds) — income vs expenses, revenue, invoices ===== function renderPnlDashboard() { const el = document.getElementById('pnlDashContent'); if (!el) return; if (!document.getElementById('pnlStyles')) { const s=document.createElement('style'); s.id='pnlStyles'; s.textContent=` .pnl-wrap{display:flex;flex-direction:column;gap:14px;} .pnl-row{display:grid;gap:14px;} .pnl-r1{grid-template-columns:1.5fr 1fr;} .pnl-r2{grid-template-columns:1fr 1.5fr;} .pnl-r3{grid-template-columns:1fr 1fr;} @media(max-width:900px){.pnl-r1,.pnl-r2,.pnl-r3{grid-template-columns:1fr;}} .pnl-card{background:var(--bg-surface);border:1px solid var(--border);border-radius:16px;padding:17px 18px;} .pnl-h{display:flex;align-items:center;justify-content:space-between;margin-bottom:14px;} .pnl-t{font-size:15px;font-weight:800;color:var(--text-primary);} .pnl-per{font-size:11.5px;font-weight:700;color:var(--text-muted);background:var(--bg-card);border:1px solid var(--border);border-radius:8px;padding:4px 9px;cursor:pointer;} .pnl-big{font-size:30px;font-weight:900;letter-spacing:-.02em;color:var(--text-primary);line-height:1;} .pnl-up{color:#16a34a;font-weight:800;} .pnl-dn{color:#dc2626;font-weight:800;} .pnl-muted{font-size:12px;color:var(--text-muted);font-weight:600;} .pnl-pill{display:inline-flex;align-items:center;gap:6px;font-size:11.5px;font-weight:700;color:var(--accent);background:var(--accent-glow);border-radius:20px;padding:5px 11px;} .pnl-track{height:20px;border-radius:7px;background:var(--bg-card);overflow:hidden;} .pnl-fill{height:100%;border-radius:7px;} .pnl-link{font-size:12px;font-weight:700;color:var(--accent);cursor:pointer;} .pnl-legrow{display:flex;align-items:center;gap:7px;font-size:12px;color:var(--text-primary);font-weight:600;margin-bottom:4px;} .pnl-dot{width:9px;height:9px;border-radius:50%;flex-shrink:0;} .pnl-acc{display:flex;align-items:center;justify-content:space-between;background:var(--bg-card);border:1px solid var(--border);border-radius:12px;padding:13px 15px;} .pnl-steps{display:grid;grid-template-columns:1fr 1fr 1fr;gap:6px;align-items:center;background:var(--bg-card);border:1px solid var(--border);border-radius:12px;padding:16px;} .pnl-step{display:flex;flex-direction:column;align-items:center;gap:8px;text-align:center;} .pnl-step-ico{width:44px;height:44px;border-radius:50%;background:var(--bg-surface);border:1px solid var(--border);display:flex;align-items:center;justify-content:center;font-size:18px;} .pnl-step-l{font-size:11.5px;font-weight:700;color:var(--text-primary);} .pnl-dash{border-top:1.5px dashed var(--border);} .pnl-ibars{display:flex;align-items:flex-end;gap:3px;height:70px;margin-top:12px;} .pnl-ibars span{flex:1;border-radius:3px 3px 0 0;background:var(--accent);opacity:.35;min-height:4px;} .pnl-empty{padding:20px 10px;text-align:center;color:var(--text-muted);font-size:12px;} `; document.head.appendChild(s); } const accounts=data.accounts||[], banks=data.banks||[]; const now=new Date(); now.setHours(0,0,0,0); const todayK=dayKey(new Date()); const period=Number(window._pnlPeriod||30); const perLabel=period===30?'Last 30 days':period===90?'Last 90 days':'Last 12 months'; const fromD=new Date(now); fromD.setDate(now.getDate()-(period-1)); const prevFromD=new Date(fromD); prevFromD.setDate(fromD.getDate()-period); const inR=(k,a,b)=>k&&k>=dayKey(a)&&k<=dayKey(b); const MON=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; // classify money out const PAL={Labour:'#7b6cf6',Vendor:'#3b82f6',Material:'#22c55e',OpEx:'#f59e0b',Other:'#64748b'}; const bucketOf=a=>{ const c=(a.category||'').toLowerCase(),t=(a.type||'').toLowerCase(); if(t==='vendor-payment'||c.includes('vendor'))return 'Vendor'; if(c.startsWith('labour')||t==='labour-payment')return 'Labour'; if(a.outKind==='material'||c.includes('material'))return 'Material'; if(a.outKind==='opex'||c.includes('opex'))return 'OpEx'; return 'Other'; }; const isOut=a=>a.direction==='out'||a.type==='vendor-payment'||/labour|material|opex|vendor/i.test(a.category||'')||a.outKind; const isRecvIn=a=>a.type==='payment-in' && ['completed','paid','received','settled'].includes(String(a.status||'').toLowerCase()); const dateOf=a=>a.date||a.dueDate||''; const sumOut=(a,b)=>accounts.filter(x=>isOut(x)&&x.status!=='pending'&&inR(dateOf(x),a,b)).reduce((s,x)=>s+Number(x.amount||0),0); const sumIn=(a,b)=>accounts.filter(x=>isRecvIn(x)&&inR(dateOf(x),a,b)).reduce((s,x)=>s+Number(x.amount||0),0); const expCur=sumOut(fromD,now), expPrev=sumOut(prevFromD,fromD); const incCur=sumIn(fromD,now), incPrev=sumIn(prevFromD,fromD); const netCur=incCur-expCur, netPrev=incPrev-expPrev; const dPct=(a,b)=> b? Math.round((a-b)/Math.abs(b)*100) : (a?100:0); const netDelta=dPct(netCur,netPrev), incDelta=dPct(incCur,incPrev); const arrow=v=>`${v>=0?'↑':'↓'} ${Math.abs(v)}%`; const incExpMax=Math.max(1,incCur,expCur); // Expenses by category const expCat={}; accounts.filter(x=>isOut(x)&&x.status!=='pending'&&inR(dateOf(x),fromD,now)).forEach(x=>{ const b=bucketOf(x); expCat[b]=(expCat[b]||0)+Number(x.amount||0); }); const expArr=Object.entries(expCat).filter(([k,v])=>v>0).sort((a,b)=>b[1]-a[1]); const expTot=expArr.reduce((s,[,v])=>s+v,0)||1; // semicircle gauge — concentric arcs for top categories const cx=110,cy=104; const arc=(r,frac,col)=>{ const end=180+180*Math.max(0,Math.min(1,frac)); const rad=d=>d*Math.PI/180; const x0=cx+r*Math.cos(rad(180)), y0=cy+r*Math.sin(rad(180)); const x1=cx+r*Math.cos(rad(end)), y1=cy+r*Math.sin(rad(end)); const bg=``; const fg=frac>0?``:''; return bg+fg; }; const gTop=expArr.slice(0,3); const gaugeSvg=` ${gTop.map(([k,v],i)=>arc(90-i*20, v/expTot, PAL[k]||'#64748b')).join('')} `; const gaugeLegend=gTop.length? gTop.map(([k,v])=>`
${k}${fmtINR(v)}
`).join('') : '
No expenses in this period
'; // Revenue line (daily received) const dayN=period===365?365:period; const buckets=period<=30?dayN:period<=90?15:12; const bd=Math.max(1,Math.round(dayN/buckets)); const series=[],seriesPrev=[]; for(let i=buckets-1;i>=0;i--){ const end=new Date(now); end.setDate(now.getDate()-i*bd); const st=new Date(end); st.setDate(end.getDate()-(bd-1)); series.push(sumIn(st,end)); const pend=new Date(end); pend.setDate(end.getDate()-dayN); const pst=new Date(st); pst.setDate(st.getDate()-dayN); seriesPrev.push(sumIn(pst,pend)); } const mx=Math.max(1,...series,...seriesPrev); const W=640,H=180,pL=6,pR=6,pT=12,pB=8,n=series.length; const X=i=>pL+(W-pL-pR)*(n<=1?.5:i/(n-1)); const Y=v=>pT+(H-pT-pB)*(1-v/mx); const path=arr=>smoothPath(arr.map((v,i)=>[X(i),Y(v)])); const revSvg=` `; // Bank balance const bankBal=banks.reduce((s,b)=>s+(b.transactions||[]).filter(t=>t.type==='credit').reduce((x,t)=>x+Number(t.amount||0),0)-(b.transactions||[]).filter(t=>t.type==='debit').reduce((x,t)=>x+Number(t.amount||0),0),0); // Invoices — pending receivables const pend=accounts.filter(x=>x.type==='payment-in' && !isRecvIn(x)); const overdue=pend.filter(x=>(x.dueDate||x.date||'') && (x.dueDate||x.date)s+Number(x.amount||0),0); const notDue=pend.filter(x=>!((x.dueDate||x.date||'') && (x.dueDate||x.date)s+Number(x.amount||0),0); // invoice bars (billed per month, last 12) const im=[]; for(let i=11;i>=0;i--){ const d=new Date(now.getFullYear(),now.getMonth()-i,1); im.push(dayKey(d).slice(0,7)); } const invBars=im.map(m=>accounts.filter(x=>x.type==='payment-in'&&(dateOf(x)).slice(0,7)===m).reduce((s,x)=>s+Number(x.amount||0),0)); const invMx=Math.max(1,...invBars); // Capital eligibility (from avg monthly income) const avgMonthIncome = period? incCur/(period/30) : 0; const eligible = Math.max(0, Math.round(avgMonthIncome*6/1000)*1000); // Period cycler const nextPer=period===30?90:period===90?365:30; const perBtn=`${perLabel} ▾`; el.innerHTML=`
Profit & Loss
${perBtn}
${fmtINR(netCur)} ${arrow(netDelta)}
Net income for ${perLabel.toLowerCase()}
✔ 100% categorized
${fmtINR(expCur)}
Expenses
${fmtINR(incCur)}
Income
See profit and loss report →
Bank Account
${banks.length} account${banks.length===1?'':'s'}
Bank balance
Cash on hand
● Tracked
${fmtINR(bankBal)}
${fmtINR(bankBal)}
Connect / manage accountsGo to registers →
Expenses
${perLabel}
${gTop.map(([k,v])=>`
${fmtINR(v)}
${k}
`).join('')||'
'}
${gaugeSvg}
${gaugeLegend}
Revenue
${perLabel}
${fmtINR(incCur)} ${arrow(incDelta)}
vs ${fmtINR(incPrev)} last period
${revSvg}
${fromD.getDate()} ${MON[fromD.getMonth()]}${now.getDate()} ${MON[now.getMonth()]}
Capital
Borrow →
You may be eligible for a loan up to ${fmtINR(eligible)}, depending on your business health. Conditions apply.
📋
Start your application
📥
Submit application
🧑‍⚖️
Get a decision
Invoices
See Detail
${fmtINR(overdue)}
Overdue
${fmtINR(notDue)}
Not due yet
${invBars.map(v=>``).join('')}
`; } // ===== BUSINESS MODEL — editable startup canvas: product, market, risk, value, economics ===== function bmDefaults() { return { kpis:{ revenue:120000, profit:35, share:40, risk:65, revenueDelta:'+17.8%', profitDelta:'+13%', shareDelta:'+8.2%', riskDelta:'+17.8%' }, activities:['Development','Marketing','Support'], resources:['Team','Technology','Partners'], partners:['Supplier A','Distributor B','Customer'], market:{ segments:[{label:'Core',pct:70},{label:'Adjacent',pct:20},{label:'New',pct:10}], category:'Infrastructure', target:'Municipal', area:'Regional', tam:500000000, sam:200000000, som:9000000 }, competitors:[{name:'Rival A',share:70},{name:'Rival B',share:90},{name:'Rival C',share:65},{name:'Rival D',share:30}], competitionLevel:70, risks:[ {name:'New regulations',cat:'Regulation',level:'High',strategy:'Adaptive Compliance'}, {name:'Competitor price war',cat:'Market',level:'Medium',strategy:'Cost leadership'}, {name:'Cash-flow gap',cat:'Financial',level:'High',strategy:'Working-capital buffer'}, {name:'Delayed fund release',cat:'Fund',level:'Medium',strategy:'Milestone billing'} ], value:{ customerJobs:['Deliver work on time'], customerPains:['Payment delays','Rework'], customerGains:['Trusted contractor','Repeat tenders'], solutionJobs:['Track site & money','Faster billing'], painRelievers:['Live payables','Auto reminders'], gainCreators:['Clean audit trail','On-time payouts'] }, econ:{ revenue:50000, variableCost:20000, fixedCost:31500 } }; } function ensureBizModel() { if (!data.bizModel || typeof data.bizModel!=='object') data.bizModel = bmDefaults(); const d = bmDefaults(); for (const k in d) if (data.bizModel[k]==null) data.bizModel[k]=d[k]; for (const k in d.kpis) if (data.bizModel.kpis[k]==null) data.bizModel.kpis[k]=d.kpis[k]; return data.bizModel; } function renderBusinessModel() { const el = document.getElementById('bizModelView'); if (!el) return; const bm = ensureBizModel(); if (!document.getElementById('bmStyles')) { const s=document.createElement('style'); s.id='bmStyles'; s.textContent=` .bm-wrap{display:flex;flex-direction:column;gap:14px;} .bm-top{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:10px;} .bm-chip{display:inline-flex;align-items:center;gap:7px;font-size:12px;font-weight:700;color:var(--text-muted);background:var(--bg-card);border:1px solid var(--border);border-radius:20px;padding:5px 12px;} .bm-kpis{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;} @media(max-width:900px){.bm-kpis{grid-template-columns:repeat(2,1fr);}} @media(max-width:520px){.bm-kpis{grid-template-columns:1fr;}} .bm-kpi{background:var(--bg-surface);border:1px solid var(--border);border-radius:14px;padding:15px 16px;display:flex;flex-direction:column;gap:8px;position:relative;} .bm-kpi-h{font-size:12.5px;color:var(--text-muted);font-weight:600;} .bm-kpi-v{font-size:26px;font-weight:900;letter-spacing:-.02em;color:var(--text-primary);line-height:1;} .bm-kpi-d{font-size:11px;font-weight:700;} .bm-kpi-ico{position:absolute;top:14px;right:14px;width:34px;height:34px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:15px;color:#fff;} .bm-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;} @media(max-width:1050px){.bm-grid{grid-template-columns:1fr 1fr;}} @media(max-width:680px){.bm-grid{grid-template-columns:1fr;}} .bm-card{background:var(--bg-surface);border:1px solid var(--border);border-radius:16px;padding:16px 17px;} .bm-card-t{display:flex;align-items:center;gap:9px;font-size:15px;font-weight:800;color:var(--text-primary);margin-bottom:14px;} .bm-card-t .bm-ico{width:26px;height:26px;border-radius:8px;background:var(--accent-glow);display:flex;align-items:center;justify-content:center;font-size:14px;} .bm-sub{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.05em;color:var(--text-muted);margin:12px 0 7px;display:flex;align-items:center;gap:7px;} .bm-li{font-size:12.5px;color:var(--text-primary);padding:3px 0 3px 15px;position:relative;} .bm-li:before{content:'';position:absolute;left:2px;top:9px;width:5px;height:5px;border-radius:50%;background:var(--accent);} .bm-donut-wrap{display:flex;align-items:center;gap:16px;flex-wrap:wrap;} .bm-donut{width:118px;height:118px;border-radius:50%;position:relative;flex-shrink:0;} .bm-donut-hole{position:absolute;inset:26px;border-radius:50%;background:var(--bg-surface);display:flex;align-items:center;justify-content:center;font-size:17px;font-weight:900;color:var(--text-primary);} .bm-leg{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--text-primary);font-weight:600;margin:5px 0;} .bm-leg-dot{width:10px;height:10px;border-radius:3px;flex-shrink:0;} .bm-3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-top:6px;} .bm-tile{border-radius:11px;padding:10px;text-align:center;} .bm-tile-l{font-size:9.5px;font-weight:700;line-height:1.2;opacity:.8;} .bm-tile-v{font-size:15px;font-weight:900;margin-top:5px;} .bm-field{margin-top:6px;} .bm-field-l{font-size:10.5px;color:var(--text-muted);font-weight:700;margin-bottom:3px;} .bm-field-v{font-size:12.5px;font-weight:700;color:var(--text-primary);background:var(--bg-card);border:1px solid var(--border);border-radius:8px;padding:7px 10px;} .bm-vbars{display:grid;grid-template-columns:34px 1fr;gap:6px;height:180px;} .bm-vbars-axis{display:flex;flex-direction:column;justify-content:space-between;font-size:9.5px;color:var(--text-muted);text-align:right;padding-bottom:20px;} .bm-vcols{display:flex;align-items:flex-end;gap:10px;border-left:1px solid var(--border);border-bottom:1px solid var(--border);padding:0 4px;} .bm-vcol{flex:1;display:flex;flex-direction:column;align-items:center;height:100%;justify-content:flex-end;} .bm-vbar{width:70%;max-width:34px;border-radius:6px 6px 0 0;background:linear-gradient(180deg,#8ec9ff,#3b82f6);min-height:4px;} .bm-vlbl{font-size:10px;color:var(--text-muted);margin-top:5px;text-align:center;font-weight:600;} .bm-ocean{height:12px;border-radius:8px;background:linear-gradient(90deg,#2f80ed,#7fb2f0,#f0a0a0,#e23b3b);position:relative;margin-top:8px;} .bm-ocean-mk{position:absolute;top:-4px;width:4px;height:20px;border-radius:3px;background:#111;box-shadow:0 0 0 2px #fff;} .bm-lvlbar{height:8px;border-radius:6px;background:var(--bg-card);overflow:hidden;margin:6px 0 10px;} .bm-vp{display:grid;grid-template-columns:1fr 1fr;gap:9px;} .bm-vp-box{border-radius:11px;padding:10px 11px;} .bm-vp-h{font-size:11px;font-weight:800;margin-bottom:6px;color:#333;} .bm-vp-li{font-size:11.5px;color:#333;padding:2px 0 2px 13px;position:relative;} .bm-vp-li:before{content:'';position:absolute;left:2px;top:8px;width:4px;height:4px;border-radius:50%;background:currentColor;opacity:.6;} .bm-hbar-row{margin-bottom:10px;} .bm-hbar-top{display:flex;justify-content:space-between;font-size:11.5px;font-weight:700;color:var(--text-primary);margin-bottom:4px;} .bm-hbar-track{height:22px;border-radius:7px;background:var(--bg-card);overflow:hidden;} .bm-hbar-fill{height:100%;border-radius:7px;} .bm-econ-li{display:flex;align-items:center;justify-content:space-between;padding:9px 2px;border-bottom:1px solid var(--border);font-size:12.5px;} .bm-econ-li:last-child{border-bottom:none;} .bm-badge{font-size:10px;font-weight:800;padding:3px 10px;border-radius:20px;} .bm-b-high{background:rgba(239,68,68,.15);color:#dc2626;} .bm-b-med{background:rgba(245,158,11,.16);color:#d97706;} .bm-b-low{background:rgba(34,197,94,.15);color:#16a34a;} .bm-risk-item{border:1px solid var(--border);border-radius:10px;padding:9px 11px;margin-bottom:7px;display:flex;align-items:center;justify-content:space-between;gap:8px;} `; document.head.appendChild(s); } const CATCOL={Regulation:'#6366f1',Market:'#22c55e',Financial:'#f59e0b',Fund:'#a855f7',Operational:'#ef4444',Other:'#64748b'}; const SEGCOL=['#6d5efc','#a78bfa','#111827','#3b82f6','#22c55e','#f59e0b']; const fmtBig=n=>{ n=Number(n)||0; if(n>=1e7)return '₹'+(n/1e7).toFixed(n%1e7?1:0)+' Cr'; if(n>=1e5)return '₹'+(n/1e5).toFixed(n%1e5?1:0)+' L'; if(n>=1e3)return '₹'+Math.round(n/1e3)+'K'; return '₹'+Math.round(n); }; const conic=segs=>{ let acc=0; const parts=segs.map(s=>{ const a=acc; acc+=s.pct; return `${s.color} ${a}% ${acc}%`; }); if(acc<100) parts.push(`var(--bg-card) ${acc}% 100%`); return `conic-gradient(${parts.join(',')})`; }; const li=arr=>(arr&&arr.length?arr:['—']).map(x=>`
${escHtml(x)}
`).join(''); // KPIs const k=bm.kpis; const kpiCards=[ {h:'Total Revenue',v:fmtINR(k.revenue),d:k.revenueDelta,ico:'📈',col:'#22c55e',good:true}, {h:'Profit Margin',v:(k.profit||0)+'%',d:k.profitDelta,ico:'◐',col:'#14b8a6',good:true}, {h:'Market Share',v:(k.share||0)+'%',d:k.shareDelta,ico:'◑',col:'#6d5efc',good:true}, {h:'Risk Level',v:(k.risk||0)+'%',d:k.riskDelta,ico:'⚠',col:'#ef4444',good:false}, ].map(c=>`
${c.ico}
${c.h}
${c.v}
${escHtml(c.d||'')} vs last month
`).join(''); // Market donut const segs=(bm.market.segments||[]).map((s,i)=>({label:s.label,pct:Number(s.pct)||0,color:SEGCOL[i%SEGCOL.length]})); const topSeg=segs.slice().sort((a,b)=>b.pct-a.pct)[0]; const marketDonut=`
${topSeg?topSeg.pct+'%':''}
${segs.map(s=>`
${escHtml(s.label)}${s.pct}%
`).join('')}
`; // Competition vertical bars const comps=bm.competitors||[]; const vcols=comps.map(c=>`
${escHtml(c.name)}
`).join(''); const axis=[100,80,60,40,20,0].map(v=>`
${v}%
`).join(''); const lvl=Math.max(0,Math.min(100,Number(bm.competitionLevel)||0)); // Risk distribution const catCount={}; (bm.risks||[]).forEach(r=>{ const c=r.cat||'Other'; catCount[c]=(catCount[c]||0)+1; }); const rTot=Object.values(catCount).reduce((s,v)=>s+v,0)||1; const rSegs=Object.entries(catCount).map(([c,n])=>({label:c,pct:Math.round(n/rTot*100),color:CATCOL[c]||CATCOL.Other,n})); const riskPct=Number(k.risk)||0; const riskColor=riskPct>=60?'#ef4444':riskPct>=35?'#f59e0b':'#22c55e'; const badge=lv=>{ const s=String(lv).toLowerCase(); return s==='high'?'bm-b-high':s==='low'?'bm-b-low':'bm-b-med'; }; const topRisks=(bm.risks||[]).slice(0,2).map(r=>`
${escHtml(r.name)}
${r.strategy?`
Strategy: ${escHtml(r.strategy)}
`:''}
${escHtml(r.level||'')}
`).join(''); const moreRisks=(bm.risks||[]).length>2?`
+${bm.risks.length-2} more risks
`:''; // Value proposition const v=bm.value; const vpBox=(title,items,bg,fg)=>`
${title}
${(items&&items.length?items:['—']).map(x=>`
${escHtml(x)}
`).join('')}
`; // Cost & revenue const e=bm.econ; const emax=Math.max(1,Number(e.revenue)||0,Number(e.variableCost)||0,Number(e.fixedCost)||0); const hbar=(label,val,col)=>`
${label}${fmtINR(val)}
`; el.innerHTML=`
🧩 Business Model Canvas 📍 ${escHtml(bm.market.area||'—')}
${kpiCards}
📦 Product
Key Activities
${li(bm.activities)}
Key Resources
${li(bm.resources)}
Key Partners
${li(bm.partners)}
🎯 Market
${marketDonut}
Category
${escHtml(bm.market.category||'—')}
Target Market
${escHtml(bm.market.target||'—')}
Target Area
🌐 ${escHtml(bm.market.area||'—')}
TAM / SAM / SOM
Total Addressable Market
${fmtBig(bm.market.tam)}
Serviceable Available Market
${fmtBig(bm.market.sam)}
Serviceable Obtainable Market
${fmtBig(bm.market.som)}
📊 Competition Analysis
Market Share
${axis}
${vcols||'
No competitors added
'}
Competition Level
🌊 Blue Ocean${lvl}%${lvl>=60?' — Action Needed':''}Red Ocean 🔴
⚠️ Risk Register
Risk Level${riskPct}%${riskPct>=60?' – Immediate Action Needed':''}
Risk Distribution
${(bm.risks||[]).length}
${rSegs.map(s=>`
${escHtml(s.label)}: ${s.n}${s.pct}%
`).join('')||'
No risks yet
'}
Top Risks
${topRisks||'
No risks logged
'}${moreRisks}
💡 Value Proposition
Customer Perspective
Solution Perspective
${vpBox('Customer Job', v.customerJobs, '#e8f5ee', '#166534')} ${vpBox('Solution Job', v.solutionJobs, '#eef2ff', '#3730a3')} ${vpBox('Customer Pain', v.customerPains, '#fdecec', '#b91c1c')} ${vpBox('Pain Relievers', v.painRelievers, '#fff7e6', '#b45309')} ${vpBox('Customer Gain', v.customerGains, '#e8f5ee', '#166534')} ${vpBox('Gain Creators', v.gainCreators, '#eef6ff', '#1d4ed8')}
💹 Cost & Revenue
${hbar('Revenue', e.revenue, '#22c55e')} ${hbar('Variable Cost', e.variableCost, '#3b82f6')} ${hbar('Fixed Cost', e.fixedCost, '#a78bfa')}
📉 Fixed Cost${fmtINR(e.fixedCost)}
📉 Variable Cost${fmtINR(e.variableCost)}
📈 Total Revenue${fmtINR(e.revenue)}
💰 Net${fmtINR((Number(e.revenue)||0)-(Number(e.variableCost)||0)-(Number(e.fixedCost)||0))}
`; } function openBizModelEdit() { const bm = ensureBizModel(); const k=bm.kpis, m=bm.market, e=bm.econ, v=bm.value; const ta=(id,val,rows,ph)=>``; const num=(id,val)=>``; const txt=(id,val)=>``; const lbl=t=>`
${t}
`; const g2=(a,b)=>`
${a}${b}
`; const g4=(a,b,c,d)=>`
${a}${b}${c}${d}
`; const cell=(l,inp)=>`
${l}
${inp}
`; const html=` ${lbl('Headline KPIs')} ${g4(cell('Total Revenue ₹',num('bmRev',k.revenue)),cell('Profit Margin %',num('bmProfit',k.profit)),cell('Market Share %',num('bmShare',k.share)),cell('Risk Level %',num('bmRisk',k.risk)))} ${g4(cell('Revenue Δ',txt('bmRevD',k.revenueDelta)),cell('Profit Δ',txt('bmProfitD',k.profitDelta)),cell('Share Δ',txt('bmShareD',k.shareDelta)),cell('Risk Δ',txt('bmRiskD',k.riskDelta)))} ${lbl('Product — one item per line')} ${g2(cell('Key Activities',ta('bmAct',(bm.activities||[]).join('\n'),4)),cell('Key Resources',ta('bmRes',(bm.resources||[]).join('\n'),4)))} ${cell('Key Partners',ta('bmPart',(bm.partners||[]).join('\n'),3))} ${lbl('Market')} ${g2(cell('Segments — "Label: %" per line',ta('bmSeg',(m.segments||[]).map(s=>`${s.label}: ${s.pct}`).join('\n'),4)), `
${cell('Category',txt('bmCat',m.category))}${cell('Target Market',txt('bmTgt',m.target))}${cell('Target Area',txt('bmArea',m.area))}
`)} ${g4(cell('TAM ₹',num('bmTam',m.tam)),cell('SAM ₹',num('bmSam',m.sam)),cell('SOM ₹',num('bmSom',m.som)),cell('Competition Level %',num('bmLvl',bm.competitionLevel)))} ${lbl('Competitors — "Name: share%" per line')} ${cell('',ta('bmComp',(bm.competitors||[]).map(c=>`${c.name}: ${c.share}`).join('\n'),4))} ${lbl('Risk Register — "Name | Category | Level | Strategy" per line')} ${cell('Categories: Regulation, Market, Financial, Fund, Operational',ta('bmRisks',(bm.risks||[]).map(r=>`${r.name} | ${r.cat} | ${r.level} | ${r.strategy||''}`).join('\n'),5))} ${lbl('Value Proposition — one item per line')} ${g2(cell('Customer Jobs',ta('bmVCJ',(v.customerJobs||[]).join('\n'),2)),cell('Solution Jobs',ta('bmVSJ',(v.solutionJobs||[]).join('\n'),2)))} ${g2(cell('Customer Pains',ta('bmVCP',(v.customerPains||[]).join('\n'),2)),cell('Pain Relievers',ta('bmVPR',(v.painRelievers||[]).join('\n'),2)))} ${g2(cell('Customer Gains',ta('bmVCG',(v.customerGains||[]).join('\n'),2)),cell('Gain Creators',ta('bmVGC',(v.gainCreators||[]).join('\n'),2)))} ${lbl('Cost & Revenue')} ${g4(cell('Total Revenue ₹',num('bmERev',e.revenue)),cell('Variable Cost ₹',num('bmEVar',e.variableCost)),cell('Fixed Cost ₹',num('bmEFix',e.fixedCost)),'
')} `; openGenModal('🧩 Edit Business Model', html, () => { const val=id=>{ const el=document.getElementById(id); return el?el.value:''; }; const numv=id=>Number(String(val(id)).replace(/[^0-9.\-]/g,''))||0; const lines=id=>String(val(id)).split('\n').map(x=>x.trim()).filter(Boolean); const pairs=id=>lines(id).map(l=>{ const i=l.lastIndexOf(':'); if(i<0) return {label:l,pct:0}; return {label:l.slice(0,i).trim(), pct:Number(l.slice(i+1).replace(/[^0-9.]/g,''))||0}; }); bm.kpis={ revenue:numv('bmRev'), profit:numv('bmProfit'), share:numv('bmShare'), risk:numv('bmRisk'), revenueDelta:val('bmRevD'), profitDelta:val('bmProfitD'), shareDelta:val('bmShareD'), riskDelta:val('bmRiskD') }; bm.activities=lines('bmAct'); bm.resources=lines('bmRes'); bm.partners=lines('bmPart'); bm.market={ segments:pairs('bmSeg'), category:val('bmCat'), target:val('bmTgt'), area:val('bmArea'), tam:numv('bmTam'), sam:numv('bmSam'), som:numv('bmSom') }; bm.competitors=pairs('bmComp').map(p=>({name:p.label,share:p.pct})); bm.competitionLevel=numv('bmLvl'); bm.risks=lines('bmRisks').map(l=>{ const p=l.split('|').map(x=>x.trim()); return {name:p[0]||'Risk', cat:p[1]||'Other', level:p[2]||'Medium', strategy:p[3]||''}; }); bm.value={ customerJobs:lines('bmVCJ'), solutionJobs:lines('bmVSJ'), customerPains:lines('bmVCP'), painRelievers:lines('bmVPR'), customerGains:lines('bmVCG'), gainCreators:lines('bmVGC') }; bm.econ={ revenue:numv('bmERev'), variableCost:numv('bmEVar'), fixedCost:numv('bmEFix') }; data.bizModel=bm; saveData(); closeGenModal(); renderBusinessModel(); if (typeof showToast==='function') showToast('🧩 Business model saved'); }); } // ===== REVENUE DESK — income received, receivables, payment trends ===== function renderRevenueDesk() { const el = document.getElementById('revenueDeskView'); if (!el) return; if (!document.getElementById('rdStyles')) { const s = document.createElement('style'); s.id = 'rdStyles'; s.textContent = ` .rd-wrap{display:flex;flex-direction:column;gap:14px;} .rd-head{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:10px;} .rd-refresh{font-size:11px;color:var(--text-muted);font-weight:600;} .rd-seg{display:inline-flex;background:var(--bg-card);border:1px solid var(--border);border-radius:9px;padding:3px;gap:2px;} .rd-seg button{border:none;background:transparent;cursor:pointer;border-radius:6px;padding:5px 11px;font-size:12px;font-weight:700;color:var(--text-muted);} .rd-seg button.on{background:var(--accent);color:#fff;} .rd-stats{display:grid;grid-template-columns:repeat(5,1fr);gap:12px;} @media(max-width:1100px){.rd-stats{grid-template-columns:repeat(2,1fr);}} @media(max-width:560px){.rd-stats{grid-template-columns:1fr;}} .rd-stat{background:var(--bg-surface);border:1px solid var(--border);border-radius:14px;padding:14px 15px;} .rd-stat-h{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--text-muted);font-weight:700;margin-bottom:10px;} .rd-stat-ico{width:26px;height:26px;border-radius:8px;background:var(--accent-glow);display:flex;align-items:center;justify-content:center;font-size:13px;} .rd-stat-v{font-size:24px;font-weight:900;letter-spacing:-.02em;color:var(--text-primary);line-height:1;} .rd-stat-d{font-size:11px;font-weight:700;margin-top:8px;display:flex;align-items:center;gap:5px;} .rd-up{color:#22c55e;} .rd-dn{color:#ef4444;} .rd-mid{display:grid;grid-template-columns:1.7fr 1fr;gap:14px;} @media(max-width:980px){.rd-mid{grid-template-columns:1fr;}} .rd-card{background:var(--bg-surface);border:1px solid var(--border);border-radius:16px;padding:16px 17px;} .rd-card-h{display:flex;align-items:center;justify-content:space-between;margin-bottom:14px;} .rd-card-t{font-size:15px;font-weight:800;color:var(--text-primary);} .rd-bar-row{margin-bottom:15px;} .rd-bar-top{display:flex;justify-content:space-between;font-size:12.5px;font-weight:700;color:var(--text-primary);margin-bottom:6px;} .rd-bar-track{height:8px;border-radius:6px;background:var(--bg-card);overflow:hidden;} .rd-bar-fill{height:100%;border-radius:6px;background:linear-gradient(90deg,var(--accent),color-mix(in srgb,var(--accent) 55%,#fff));} .rd-tbl{width:100%;border-collapse:collapse;font-size:12.5px;} .rd-tbl th{text-align:left;font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:var(--text-muted);font-weight:700;padding:8px 10px;border-bottom:1px solid var(--border);} .rd-tbl td{padding:11px 10px;border-bottom:1px solid var(--border);color:var(--text-primary);} .rd-tbl tr:last-child td{border-bottom:none;} .rd-badge{font-size:10.5px;font-weight:800;padding:3px 10px;border-radius:20px;} .rd-b-ok{background:rgba(34,197,94,.15);color:#16a34a;} .rd-b-pend{background:rgba(245,158,11,.16);color:#d97706;} .rd-empty{padding:26px 12px;text-align:center;color:var(--text-muted);font-size:12.5px;} `; document.head.appendChild(s); } const accounts = data.accounts||[], projects = data.projects||[]; const income = accounts.filter(a=>a.type==='payment-in'); const isRecv = a => ['completed','paid','received','settled'].includes(String(a.status||'').toLowerCase()); const recvList = income.filter(isRecv); const pendList = income.filter(a=>!isRecv(a)); const recvDate = a => a.date || a.dueDate || ''; // received entries carry a.date const now = new Date(); now.setHours(0,0,0,0); const period = Number(window._rdPeriod || 7); const periodLabel = period===7?'Last Week':period===30?'Last 30 days':period===90?'Last 90 days':'This Year'; const dayN = period===365 ? 365 : period; const fromD = new Date(now); fromD.setDate(now.getDate()-(dayN-1)); const prevFromD = new Date(fromD); prevFromD.setDate(fromD.getDate()-dayN); const inRange=(k,a,b)=> k && k>=dayKey(a) && k<=dayKey(b); const grossBilled = income.reduce((s,a)=>s+Number(a.amount||0),0); const netReceived = recvList.reduce((s,a)=>s+Number(a.amount||0),0); const outstanding = pendList.reduce((s,a)=>s+Number(a.amount||0),0); const avgEntry = income.length ? grossBilled/income.length : 0; const monthKey = dayKey(now).slice(0,7); const recvThisMonth = recvList.filter(a=>recvDate(a).slice(0,7)===monthKey).reduce((s,a)=>s+Number(a.amount||0),0); const recvCur = recvList.filter(a=>inRange(recvDate(a), fromD, now)).reduce((s,a)=>s+Number(a.amount||0),0); const recvPrev = recvList.filter(a=>inRange(recvDate(a), prevFromD, fromD)).reduce((s,a)=>s+Number(a.amount||0),0); const dPct=(a,b)=> b? Math.round((a-b)/b*100) : (a?100:0); const recvDelta = dPct(recvCur, recvPrev); const dtag=(v,txt)=>`${v>=0?'↑':'↓'} ${Math.abs(v)}% ${txt}`; // ── Daily received series over the period ────────────── const WD=['Sun','Mon','Tue','Wed','Thu','Fri','Sat']; const MON=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; const buckets = period<=7 ? 7 : period<=30 ? 10 : period<=90 ? 12 : 12; const bucketDays = Math.max(1, Math.round(dayN/buckets)); const series=[]; for(let i=buckets-1;i>=0;i--){ const end=new Date(now); end.setDate(now.getDate()-i*bucketDays); const start=new Date(end); start.setDate(end.getDate()-(bucketDays-1)); const v=recvList.filter(a=>inRange(recvDate(a), start, end)).reduce((s,a)=>s+Number(a.amount||0),0); let lbl; if(period<=7) lbl=WD[end.getDay()]; else if(period<=90) lbl=`${end.getDate()} ${MON[end.getMonth()]}`; else lbl=MON[end.getMonth()]; series.push({ v, lbl, dateLbl:`${end.getDate()} ${MON[end.getMonth()]}` }); } const mx = Math.max(1, ...series.map(d=>d.v)); const peakIdx = series.reduce((bi,d,i,arr)=> d.v>arr[bi].v?i:bi, 0); // chart geometry const W=720,H=250,padL=54,padR=16,padT=18,padB=34; const n=series.length; const X=i=> padL+(W-padL-padR)*(n<=1?0.5:i/(n-1)); const Y=v=> padT+(H-padT-padB)*(1-v/mx); const pts=series.map((d,i)=>[X(i),Y(d.v)]); const line=smoothPath(pts); const area=`${line} L ${X(n-1).toFixed(1)} ${H-padB} L ${X(0).toFixed(1)} ${H-padB} Z`; // y gridlines const yTicks=[0,.25,.5,.75,1].map(f=>{ const val=mx*f; const y=Y(val); return `${val>=1000?'₹'+Math.round(val/1000)+'k':'₹'+Math.round(val)}`; }).join(''); const xLabels=series.map((d,i)=>`${d.lbl}`).join(''); const px=X(peakIdx), py=Y(series[peakIdx].v); const peakMark = mx>1 ? `` : ''; const chartId='rdChartGrad'; const chartSvg=` ${yTicks} ${peakMark} ${xLabels} `; const peakTip = mx>1 ? `
${series[peakIdx].dateLbl} · peak${fmtINR(series[peakIdx].v)}
` : ''; // ── Income by project (progress bars) ────────────────── const srcList = recvList.length ? recvList : income; const byProj={}; srcList.forEach(a=>{ const p=projects.find(x=>x.id===a.projectId); const name=p?projNick(p):(a.party||'Direct'); byProj[name]=(byProj[name]||0)+Number(a.amount||0); }); let projArr=Object.entries(byProj).filter(([k,v])=>v>0).sort((a,b)=>b[1]-a[1]); const top=projArr.slice(0,4); const otherSum=projArr.slice(4).reduce((s,[,v])=>s+v,0); if(otherSum>0) top.push(['Other',otherSum]); const barTot=top.reduce((s,[,v])=>s+v,0)||1; const barsHtml = top.length ? top.map(([k,v])=>{ const pct=Math.round(v/barTot*100); return `
${escHtml(k)}${pct}%
${fmtINR(v)}
`; }).join('') : '
No income recorded yet
'; // ── Recent income table ──────────────────────────────── const recent=income.slice().sort((a,b)=>(recvDate(b)).localeCompare(recvDate(a))).slice(0,7); const rowsHtml = recent.length ? recent.map(a=>{ const p=projects.find(x=>x.id===a.projectId); const ref=(a.note||'').split('—')[0].trim() || ('#'+String(a.id||'').slice(-5)); const src=p?projNick(p):(a.party||'Direct'); const recv=isRecv(a); return ` ${escHtml(ref)} ${escHtml(src)}${a.party&&p?`
${escHtml(a.party)}
`:''} ${fmtINR(a.amount)} ${fmtD(recvDate(a))} ${recv?'Received':'Pending'} `; }).join('') : '
No income entries yet — add expected income in Money Management, or create an Invoice.
'; function fmtD(k){ if(!k) return '—'; const p=String(k).split('-'); return p[2]?`${MON[+p[1]-1]} ${+p[2]}`:k; } const segBtn=(v,l)=>``; el.innerHTML = `
${segBtn(7,'Last 7d')}${segBtn(30,'30d')}${segBtn(90,'90d')}${segBtn(365,'This Year')}
Live from your ledger
💰 Net Received
${fmtINR(netReceived)}
${dtag(recvDelta, 'vs prev '+dayN+'d')}
🧾 Gross Billed
${fmtINR(grossBilled)}
${income.length} entr${income.length===1?'y':'ies'}
Outstanding
${fmtINR(outstanding)}
${pendList.length} receivable${pendList.length===1?'':'s'}
📅 This Month
${fmtINR(recvThisMonth)}
received in ${MON[now.getMonth()]}
📊 Avg Entry
${fmtINR(avgEntry)}
per income entry
Income Received
${periodLabel}
${chartSvg} ${peakTip?`
${peakTip}
`:''}
Income by Project
🎯
${barsHtml}
Recent Income
View All
${rowsHtml}
RefSourceAmountDateStatus
`; } // ===== HOME — command center: spend, payments, projects, schedule, activity ===== function renderHomeView() { const el = document.getElementById('homeView'); if (!el) return; if (!document.getElementById('hmStyles')) { const s = document.createElement('style'); s.id = 'hmStyles'; s.textContent = ` .hm-wrap{display:flex;flex-direction:column;gap:14px;} .hm-row{display:grid;gap:14px;} .hm-r1{grid-template-columns:1.35fr 1fr 1fr 1fr;} .hm-r2{grid-template-columns:1.1fr 1fr 1fr;} .hm-r3{grid-template-columns:1.3fr 1.2fr 1fr;} @media(max-width:1150px){.hm-r1,.hm-r2,.hm-r3{grid-template-columns:1fr 1fr;}} @media(max-width:680px){.hm-r1,.hm-r2,.hm-r3{grid-template-columns:1fr;}} .hm-card{background:var(--bg-surface);border:1px solid var(--border);border-radius:16px;padding:16px 17px;position:relative;overflow:hidden;} .hm-h{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;} .hm-h-t{font-size:14px;font-weight:800;color:var(--text-primary);} .hm-h-a{font-size:11px;font-weight:700;color:var(--accent);cursor:pointer;} .hm-big{font-size:30px;font-weight:900;letter-spacing:-.02em;line-height:1;color:var(--text-primary);} .hm-delta{font-size:11.5px;font-weight:800;} .hm-up{color:#22c55e;} .hm-dn{color:#ef4444;} .hm-sub{font-size:11px;color:var(--text-muted);font-weight:600;} .hm-dist-grid{display:grid;grid-template-columns:1fr 1fr;gap:9px;} .hm-tile{border-radius:13px;padding:12px 13px;color:#fff;cursor:pointer;min-height:78px;display:flex;flex-direction:column;justify-content:space-between;transition:transform .15s;} .hm-tile:hover{transform:translateY(-2px);} .hm-tile.span2{grid-column:span 2;} .hm-tile-k{font-size:11px;font-weight:700;opacity:.92;} .hm-tile-p{font-size:22px;font-weight:900;line-height:1;} .hm-tile-v{font-size:11.5px;font-weight:700;opacity:.9;} .hm-bars{display:flex;align-items:flex-end;gap:3px;height:42px;margin-top:10px;} .hm-bars span{flex:1;border-radius:3px 3px 0 0;min-height:4px;opacity:.9;} .hm-chip{display:inline-block;font-size:10.5px;font-weight:700;padding:3px 9px;border-radius:20px;background:var(--bg-card);border:1px solid var(--border);color:var(--text-primary);margin:3px 3px 0 0;} .hm-lib{display:flex;gap:11px;align-items:center;padding:9px;border-radius:11px;cursor:pointer;transition:background .12s;} .hm-lib:hover{background:var(--bg-card);} .hm-lib-ico{width:34px;height:34px;border-radius:9px;background:var(--accent-glow);display:flex;align-items:center;justify-content:center;font-size:16px;flex-shrink:0;} .hm-lib-t{font-size:13px;font-weight:800;color:var(--text-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} .hm-lib-s{font-size:11px;color:var(--text-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} .hm-lib-m{font-size:10.5px;color:var(--text-muted);margin-top:2px;display:flex;gap:7px;align-items:center;} .hm-badge{font-size:9px;font-weight:800;padding:2px 7px;border-radius:6px;background:var(--accent-glow);color:var(--accent);text-transform:uppercase;letter-spacing:.03em;} .hm-sched{display:flex;gap:10px;align-items:center;padding:8px 4px;cursor:pointer;border-bottom:1px solid var(--border);} .hm-sched:last-child{border-bottom:none;} .hm-sched-dot{width:9px;height:9px;border-radius:50%;flex-shrink:0;} .hm-sched-t{font-size:12.5px;font-weight:700;color:var(--text-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} .hm-sched-s{font-size:10.5px;color:var(--text-muted);} .hm-sched-a{font-size:12.5px;font-weight:800;color:var(--text-primary);white-space:nowrap;} .hm-sug{display:flex;align-items:center;gap:8px;padding:8px 10px;border-radius:9px;background:var(--bg-card);border:1px solid var(--border);cursor:pointer;font-size:12px;font-weight:700;color:var(--text-primary);margin-top:7px;transition:transform .12s;} .hm-sug:hover{transform:translateX(3px);border-color:var(--accent);} .hm-heat{display:grid;grid-template-columns:52px repeat(12,1fr);gap:3px;align-items:center;} .hm-heat-cell{aspect-ratio:1;border-radius:3px;background:var(--bg-card);} .hm-heat-lbl{font-size:9.5px;color:var(--text-muted);font-weight:600;} .hm-heat-rl{font-size:10.5px;color:var(--text-muted);font-weight:700;white-space:nowrap;} .hm-tx{display:flex;gap:10px;align-items:center;padding:8px 2px;border-bottom:1px solid var(--border);} .hm-tx:last-child{border-bottom:none;} .hm-tx-ico{width:30px;height:30px;border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:13px;flex-shrink:0;color:#fff;font-weight:800;} .hm-empty{padding:22px 12px;text-align:center;color:var(--text-muted);font-size:12px;} .hm-leg{display:flex;align-items:center;gap:7px;font-size:11.5px;color:var(--text-primary);font-weight:600;margin-top:6px;} .hm-leg-dot{width:9px;height:9px;border-radius:3px;} .hm-src{width:30px;height:30px;border-radius:8px;background:var(--bg-card);border:1px solid var(--border);display:inline-flex;align-items:center;justify-content:center;font-size:14px;margin:0 4px 4px 0;} `; document.head.appendChild(s); } const projs=data.projects||[], attendance=data.attendance||[], labours=data.labours||[], accounts=data.accounts||[]; const lpays=data.labourPayments||[], vpays=data.vendorPayments||[], tasks=data.tasks||[]; const now=new Date(); now.setHours(0,0,0,0); const todayK=dayKey(new Date()); const MON=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; const fmtD=k=>{ if(!k) return ''; const p=String(k).split('-'); return p[2]?`${MON[+p[1]-1]} ${+p[2]}`:k; }; const ago=k=>{ if(!k) return ''; const t=new Date(k+'T00:00:00'); const d=Math.round((now-t)/864e5); return d<=0?'Today':d===1?'Yesterday':d<7?d+'d ago':d<30?Math.round(d/7)+'w ago':Math.round(d/30)+'mo ago'; }; // ── Spend distribution ────────────────────────────────── const PAL={Labour:'#7b6cf6',Vendor:'#3b82f6',Material:'#22c55e',OpEx:'#f59e0b',Other:'#64748b'}; const bucketOf=a=>{ const c=(a.category||'').toLowerCase(), t=(a.type||'').toLowerCase(); if(t==='vendor-payment'||c.includes('vendor')) return 'Vendor'; if(c.startsWith('labour')||t==='labour-payment') return 'Labour'; if(a.outKind==='material'||c.includes('material')) return 'Material'; if(a.outKind==='opex'||c.includes('opex')) return 'OpEx'; return 'Other'; }; const spend={Labour:0,Vendor:0,Material:0,OpEx:0,Other:0}; accounts.forEach(a=>{ const isOut=a.direction==='out'||a.type==='vendor-payment'||/labour|material|opex|vendor/i.test(a.category||'')||a.outKind; if(!isOut||a.status==='pending') return; spend[bucketOf(a)]+=Number(a.amount||0); }); if(spend.Labour===0) spend.Labour=lpays.reduce((s,p)=>s+Number(p.amount||0),0); if(spend.Vendor===0) spend.Vendor=vpays.reduce((s,p)=>s+Number(p.amount||0),0); const spendTotal=Object.values(spend).reduce((s,v)=>s+v,0)||1; const spendArr=Object.entries(spend).filter(([k,v])=>v>0).sort((a,b)=>b[1]-a[1]); const distTiles = spendArr.length ? spendArr.map(([k,v],i)=>{ const pct=Math.round(v/spendTotal*100); const big=(i===0&&pct>=45); return `
${k} Spend
${pct}%
${fmtINR(v)}
`; }).join('') : '
No spend recorded yet
'; // ── Payments (12-month) ───────────────────────────────── const months=[]; for(let i=11;i>=0;i--){ const d=new Date(now.getFullYear(),now.getMonth()-i,1); months.push(dayKey(d).slice(0,7)); } const payAll=[ ...lpays.map(p=>({date:p.date,amt:Number(p.amount||0),party:p.labourName,mode:p.mode,kind:'Labour',id:p.id,receipt:p.receipt})), ...vpays.map(p=>({date:p.date,amt:Number(p.amount||0),party:p.vendorName,mode:p.mode,kind:'Vendor',id:p.id,receipt:p.receipt})) ]; const cntByMonth=months.map(m=>payAll.filter(p=>(p.date||'').slice(0,7)===m).length); const sumByMonth=months.map(m=>payAll.filter(p=>(p.date||'').slice(0,7)===m).reduce((s,p)=>s+p.amt,0)); const totPays=payAll.length; const dPct=(a,b)=> b? Math.round((a-b)/b*100) : (a?100:0); const cntDelta=dPct(cntByMonth[11],cntByMonth[10]); const sumDelta=dPct(sumByMonth[11],sumByMonth[10]); const bars=(arr,color)=>{ const mx=Math.max(1,...arr); return `
${arr.map(v=>``).join('')}
`; }; const deltaTag=v=>`${v>=0?'↑':'↓'}${Math.abs(v)}% vs last month`; // ── Projects ──────────────────────────────────────────── const stageOf=p=>String(p.currentStage||p.stage||p.docStage||'').toUpperCase(); const running=projs.filter(p=>stageOf(p).includes('PROGRESS')); const chips=projs.slice(0,8).map(p=>`${escHtml(projNick(p))}`).join('') || 'No projects yet'; // ── Project library ───────────────────────────────────── const libCards=projs.length ? projs.slice(0,6).map(p=>{ const st=stageOf(p); const spent=accounts.filter(a=>a.projectId===p.id && (a.status==='paid'||a.direction==='out')).reduce((s,a)=>s+Number(a.amount||0),0); return `
🏗
${escHtml(projNick(p))}
${escHtml(p.name||'Project')}
${st?`${escHtml(st.slice(0,14))}`:''}${fmtINR(spent)} spent
`; }).join('') : '
No projects yet — add one in Projects
'; // ── Upcoming payments / schedule ──────────────────────── const upcoming=[]; accounts.filter(a=>a.status==='pending'&&(a.dueDate||a.date)).forEach(a=>upcoming.push({date:a.dueDate||a.date,title:'Pay '+(a.party||a.company||'—'),sub:(a.note||bucketOf(a)+' due'),amt:Number(a.amount||0),color:'#ef4444',go:"studioGo('funds')"})); tasks.filter(t=>!t.completed&&t.date&&t.date>=todayK).forEach(t=>upcoming.push({date:t.date,title:t.text||'Task',sub:t.category||'Task',amt:0,color:'#f59e0b',go:"studioGo('tasks')"})); (data.loans||[]).filter(l=>l.status!=='closed'&&Number(l.emi)>0).forEach(l=>upcoming.push({date:todayK,title:(l.name||'Loan')+' EMI',sub:'Monthly instalment',amt:Number(l.emi||0),color:'#8b5cf6',go:"studioGo('funds')"})); upcoming.sort((a,b)=>(a.date||'').localeCompare(b.date||'')); const upHtml=upcoming.length ? upcoming.slice(0,6).map(u=>`
${escHtml(u.title)}
${fmtD(u.date)} · ${escHtml(u.sub)}
${u.amt?`
${fmtINR(u.amt)}
`:''}
`).join('') : '
Nothing scheduled — you are all clear ✅
'; // ── Quick reports ─────────────────────────────────────── const sugg=[['💵','Labour Payable Summary'],['🏪','Vendor Dues Report'],['🏗','Project Cost Breakdown'],['👷','Attendance Register'],['📈','Running Capital Forecast']]; const sugHtml=sugg.map(([i,t])=>`
${i}${t}
`).join(''); // ── Activity heatmap (current year) ───────────────────── const yr=now.getFullYear(); const ym=Array.from({length:12},(_,i)=>`${yr}-${String(i+1).padStart(2,'0')}`); const heatRows=[ {k:'Labour',c:'#7b6cf6',f:m=>lpays.filter(p=>(p.date||'').slice(0,7)===m).length+attendance.filter(a=>(a.date||'').slice(0,7)===m).length}, {k:'Vendor',c:'#3b82f6',f:m=>vpays.filter(p=>(p.date||'').slice(0,7)===m).length}, {k:'Material',c:'#22c55e',f:m=>accounts.filter(a=>bucketOf(a)==='Material'&&(a.date||a.dueDate||'').slice(0,7)===m).length}, {k:'Tasks',c:'#f59e0b',f:m=>tasks.filter(t=>(t.date||'').slice(0,7)===m).length}, {k:'Payments',c:'#ec4899',f:m=>payAll.filter(p=>(p.date||'').slice(0,7)===m).length}, ]; const heatMax=Math.max(1,...heatRows.flatMap(r=>ym.map(m=>r.f(m)))); const heatHtml=`
${MON.map(m=>`
${m[0]}
`).join('')}` + heatRows.map(r=>`
${r.k}
`+ym.map(m=>{ const v=r.f(m); const o=v?(0.25+0.75*Math.min(1,v/heatMax)):0; return `
`; }).join('')).join('') + `
`; // ── Recent transactions ───────────────────────────────── const recent=payAll.filter(p=>p.date).sort((a,b)=>b.date.localeCompare(a.date)).slice(0,6); const kindColor=k=>k==='Vendor'?'#3b82f6':'#7b6cf6'; const recHtml=recent.length ? recent.map(p=>`
${p.kind==='Vendor'?'🏪':'👷'}
${escHtml(p.party||'—')}
${p.kind} · ${escHtml((p.mode||'cash'))} · ${ago(p.date)}
${fmtINR(p.amt)}
`).join('') : '
No payments recorded yet
'; // ── Payment mix donut (by mode, amount) ───────────────── const MODEPAL={cash:'#22c55e',upi:'#7b6cf6',bank:'#3b82f6','bank transfer':'#3b82f6',neft:'#06b6d4',cheque:'#f59e0b',card:'#ec4899',online:'#06b6d4',other:'#64748b'}; const modeMap={}; payAll.forEach(p=>{ const m=(p.mode||'cash').toLowerCase(); modeMap[m]=(modeMap[m]||0)+p.amt; }); const modeArr=Object.entries(modeMap).filter(([k,v])=>v>0).sort((a,b)=>b[1]-a[1]); const modeTotal=modeArr.reduce((s,[k,v])=>s+v,0)||1; const R=52, CIRC=2*Math.PI*R; let dOff=0; const donutSegs=modeArr.map(([k,v])=>{ const len=(v/modeTotal)*CIRC; const col=MODEPAL[k]||'#64748b'; const seg=``; dOff+=len; return seg; }).join(''); const donutHtml = modeArr.length ? `
${donutSegs} ${totPays} Payments
${modeArr.map(([k,v])=>`
${k}${Math.round(v/modeTotal*100)}%
`).join('')}
` : '
No payments to break down yet
'; // ── Data sources ──────────────────────────────────────── const totOut=spendTotal, totPaidAmt=payAll.reduce((s,p)=>s+p.amt,0); const srcIcons=['📊','👷','🏪','📦','🧾','₹']; el.innerHTML = `
Spend Distribution
View all
${distTiles}
Payments Made
${totPays}
${deltaTag(cntDelta)}
${bars(cntByMonth,'#7b6cf6')}
Amount Paid
${fmtINR(totPaidAmt)}
${deltaTag(sumDelta)}
${bars(sumByMonth,'#22c55e')}
Projects
Open
${projs.length}
${running.length} in progress · ${labours.length} workers
${chips}
Project Library
View all
${libCards}
Upcoming Payments
View all
${upHtml}
⚡ Quick Reports
Jump straight to an export or build a new report.
${sugHtml}
Activity — ${yr}
records / month
${heatHtml}
Recent Transactions
View all
${recHtml}
Payment Mix
${donutHtml}
Connected Modules
All your data, one command center
${srcIcons.map(i=>`${i}`).join('')}
${fmtINR(totOut)}
total recorded outflow
`; } // ===== STUDIO VIEW — editorial ops dashboard: live cards + big-date agenda ===== function studioGo(tab, after) { document.querySelector('.nav-item[data-tab="' + tab + '"]')?.click(); if (after) setTimeout(after, 150); } function studioToggleTask(id) { const t = (data.tasks||[]).find(x => x.id === id); if (!t) return; t.completed = !t.completed; saveData(); renderStudioView(); } function renderStudioView() { const el = document.getElementById('overviewStudioView'); if (!el) return; const todayK = dayKey(new Date()); const labours = data.labours||[], attendance = data.attendance||[], tasks = data.tasks||[], accounts = data.accounts||[]; const now = new Date(); now.setHours(0,0,0,0); const sun = new Date(now); sun.setDate(now.getDate()-now.getDay()); const sat = new Date(sun); sat.setDate(sun.getDate()+6); const wkFrom = dayKey(sun), wkTo = dayKey(sat); // ── Live stats ───────────────────────────────────────────── const presentToday = new Set(attendance.filter(a=>a.date===todayK && a.status!=='absent' && Number(a.wage)>0).map(a=>a.labourName)).size; const weekWages = attendance.filter(a=>a.date>=wkFrom && a.date<=wkTo).reduce((s,a)=>s+Number(a.wage||0),0); const tasksToday = tasks.filter(t=>!t.completed && t.date===todayK).length; const overdueTasks = tasks.filter(t=>!t.completed && t.date && t.datea.status==='pending' && a.type==='vendor-payment').reduce((s,a)=>s+Number(a.amount||0),0); let labBal = 0; labours.forEach(l => { const w = attendance.filter(a=>a.labourName===l.name && Number(a.wage)>0).reduce((s,a)=>s+Number(a.wage||0),0); const b = labBonusOn(l.name) ? computeLabourBonus(l.name).total : 0; labBal += Math.max(0, w + b - labourPaidTotal(l.name)); }); const projs = data.projects||[]; const stageOf = p => String(p.currentStage||p.stage||p.docStage||'').toUpperCase(); const running = projs.filter(p => stageOf(p).includes('PROGRESS')); const shownProjs = (running.length?running:projs).slice(0,4); const company = (projs.find(p=>p.company)||{}).company || 'Thoothukudi Corporation'; // ── Metrics for customizable widgets (run-rate, capital, income…) ── const wa = (typeof labourWageAverages==='function') ? labourWageAverages() : {avgWeekly:0,avgMonthly:0,weeksSampled:0}; const mOpex = (typeof opexMonthlyTotal==='function') ? opexMonthlyTotal() : 0; const wOpex = Math.round(mOpex/4.33); const weekRR = (wa.avgWeekly||0) + wOpex; const monthRR = (wa.avgMonthly||0) + mOpex; const expIn = accounts.filter(a=>a.type==='payment-in' && a.status==='pending').reduce((s,a)=>s+Number(a.amount||0),0); const debtMonthly = (data.loans||[]).filter(l=>l.status!=='closed').reduce((s,l)=>s+Number(l.emi||0),0); const monthKey = todayK.slice(0,7); const materialMonth = accounts.filter(a=>a.outKind==='material' && (a.dueDate||a.date||'').slice(0,7)===monthKey).reduce((s,a)=>s+Number(a.amount||0),0); let capToArrange = 0; try { const rc = runCapData(); const tc = rc.rows.reduce((s,r)=>s+r.toComplete,0); const ti = rc.rows.reduce((s,r)=>s+r.expIn,0); const cash = Number(data.runCapCash)||0; const wks = Math.max(1,Number(data.runCapWeeks)||4); const projRun = Math.round((wa.avgWeekly||0)*wks)+Math.round(wOpex*wks); capToArrange = tc + projRun - ti - cash; } catch(e){} const wctx = { weekRR, monthRR, avgWeekly:wa.avgWeekly||0, avgMonthly:wa.avgMonthly||0, wOpex, mOpex, weeksSampled:wa.weeksSampled||0, expIn, debtMonthly, materialMonth, capToArrange, labBal, pendingOutSum, weekWages, presentToday, labours, projs, running }; data.studioWidgets = Array.isArray(data.studioWidgets) ? data.studioWidgets : ['runrate','capital','income','vendordues']; // ── Agenda: today + next 3 days ──────────────────────────── const days = Array.from({length:4}, (_,i) => { const d = new Date(now); d.setDate(now.getDate()+i); return d; }); const dayHtml = days.map(d => { const dk = dayKey(d); const entries = []; tasks.filter(t=>!t.completed && t.date===dk).forEach(t => entries.push({ time: t.reminderTime || '', title: t.text, sub: t.category || 'Task', color: t.urgent ? 'var(--rose)' : t.important ? 'var(--amber)' : 'var(--accent)', chk: ``, go: "studioGo('tasks')" })); (data.schedule||[]).filter(s => scheduleDayKey(s) === dk).forEach(s => entries.push({ time: s.time || '', title: s.text || s.title || s.name || 'Scheduled', sub: 'Schedule', color: 'var(--sky)', chk: '', go: "studioGo('overview')" })); accounts.filter(a=>a.status==='pending' && a.dueDate===dk).forEach(a => entries.push({ time: '', title: 'Pay ' + (a.party||'—'), sub: fmtINR(a.amount) + ' due', color: 'var(--rose)', chk: '', go: "studioGo('funds')" })); entries.sort((a,b)=>(a.time||'99').localeCompare(b.time||'99')); const list = entries.length ? entries.map(e => `
${e.chk} ${e.time?`
${e.time}
`:''}
${escHtml(e.title)}
${escHtml(e.sub)}
`).join('') : '
🗓 There is no work yet
'; return `
${String(d.getDate()).padStart(2,'0')}${d.toLocaleDateString('en-IN',{weekday:'short'})}
${list}
`; }).join(''); el.innerHTML = `
📍 ${escHtml(company)}
${projs.length} PROJECT${projs.length===1?'':'S'}
${running.length} IN PROGRESS
${shownProjs.map(p=>`${escHtml(projNick(p))}`).join('')}
SITE COMMAND MODE
👷
${presentToday}/${labours.length}
Present today
💵
${fmtINR(weekWages)}
Wages this week
${tasksToday}${overdueTasks?` +${overdueTasks} late`:''}
Tasks due today
🏪
${fmtINR(pendingOutSum)}
Vendor dues pending
${presentToday}
Workers
on site
of ${labours.length} rostered
Payable Analysis
${labBal>0 ? fmtINR(labBal)+' DUE!' : 'ALL CLEAR!'}
${labBal>0 ? 'Labour wages + bonus outstanding' : 'No labour balance outstanding'}
My Widgets
${data.studioWidgets.map(id => studioWidgetHTML(id, wctx)).filter(Boolean).join('') || '
No widgets — tap + Add / Edit Widgets to build your dashboard.
'}
Agenda — next 4 days
${dayHtml}
`; // Count-up on the workers-on-site number (EcoDream-style stat reveal) const windEl = el.querySelector('.stu-wind-num'); const target = windEl ? Number(windEl.textContent) : 0; if (windEl && target > 0) { windEl.textContent = '0'; let t0 = null; const step = ts => { t0 = t0 ?? ts; const p = Math.min(1, (ts - t0) / 650); windEl.textContent = String(Math.round(target * (p * (2 - p)))); // ease-out if (p < 1) requestAnimationFrame(step); }; requestAnimationFrame(step); } } // ===== STUDIO CUSTOM WIDGETS (Apple-style add/remove) ===== function studioWidgetCatalog() { return [ { id:'runrate', name:'📈 Projected Run-Rate', desc:'Labour + OpEx per week & month' }, { id:'capital', name:'🏗 Capital to Arrange', desc:'Funding gap after expected income' }, { id:'income', name:'💰 Expected Income', desc:'Pending client payments (IN)' }, { id:'vendordues', name:'🏪 Vendor Dues', desc:'Pending vendor / OUT payments' }, { id:'payable', name:'👷 Labour Payable', desc:'Outstanding wages + bonus' }, { id:'projects', name:'📊 Projects', desc:'Active vs total projects' }, { id:'debt', name:'🏦 Debt / EMI', desc:'Monthly EMI outgo' }, { id:'materials', name:'🧱 Material Spend', desc:'Material OUT this month' }, { id:'weekwages', name:'💵 Wages This Week', desc:'Attendance wages this week' }, ]; } function studioWidgetHTML(id, c) { const shell = (accent, body) => `
${body}
`; const two = (a,al,b,bl) => `
${a}
${al}
${b}
${bl}
`; const one = (v,sub,color) => `
${v}
${sub}
`; const title = t => `
${t}
`; switch(id) { case 'runrate': return shell('var(--violet)', title('📈 Projected Run-Rate') + two(fmtINR(c.weekRR),'per week', fmtINR(c.monthRR),'per month') + `
labour ${fmtINR(c.avgWeekly)}/wk + opex ${fmtINR(c.wOpex)}/wk${c.weeksSampled?` · from ${c.weeksSampled} wk history`:''}
`); case 'capital': return shell(c.capToArrange>0?'var(--rose)':'var(--accent)', title('🏗 Capital to Arrange') + one(fmtINR(Math.abs(c.capToArrange)), c.capToArrange>0?'Borrow / bring in':'Surplus — funded ✓', c.capToArrange>0?'var(--rose)':'var(--accent)')); case 'income': return shell('var(--accent)', title('💰 Expected Income') + one(fmtINR(c.expIn),'Pending client payments','var(--accent)')); case 'vendordues': return shell('var(--rose)', title('🏪 Vendor Dues') + one(fmtINR(c.pendingOutSum),'Pending to pay out','var(--rose)')); case 'payable': return shell('var(--amber)', title('👷 Labour Payable') + one(c.labBal>0?fmtINR(c.labBal):'✓ Cleared','Wages + bonus outstanding', c.labBal>0?'var(--amber)':'var(--accent)')); case 'projects': return shell('var(--sky)', title('📊 Projects') + one(c.running.length+' / '+c.projs.length,'In progress / total','var(--sky)')); case 'debt': return shell('var(--violet)', title('🏦 Debt / EMI') + one(fmtINR(c.debtMonthly),'Monthly EMI outgo','var(--violet)')); case 'materials': return shell('var(--amber)', title('🧱 Material Spend') + one(fmtINR(c.materialMonth),'Material OUT this month','var(--amber)')); case 'weekwages': return shell('var(--accent)', title('💵 Wages This Week') + one(fmtINR(c.weekWages),'Attendance wages')); default: return ''; } } function studioAddWidget(id) { data.studioWidgets = Array.isArray(data.studioWidgets) ? data.studioWidgets : []; if (!data.studioWidgets.includes(id)) data.studioWidgets.push(id); saveData(); renderStudioView(); openStudioWidgetGallery(); } function studioRemoveWidget(id) { data.studioWidgets = (data.studioWidgets||[]).filter(x => x !== id); saveData(); renderStudioView(); if (document.getElementById('studioWidgetModal')) openStudioWidgetGallery(); } function openStudioWidgetGallery() { document.getElementById('studioWidgetModal')?.remove(); const enabled = new Set(data.studioWidgets||[]); const cat = studioWidgetCatalog(); const modal = document.createElement('div'); modal.id = 'studioWidgetModal'; modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.5);backdrop-filter:blur(6px);z-index:9999;display:flex;align-items:center;justify-content:center;padding:16px;'; modal.addEventListener('click', e => { if (e.target === modal) modal.remove(); }); modal.innerHTML = `
🧩 Studio Widgets
Add or remove widgets — like Apple's widget gallery.
${cat.map(w => { const on = enabled.has(w.id); return `
${w.name}
${w.desc}
${on ? `` : ``}
`; }).join('')}
`; document.body.appendChild(modal); } // ----- 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'],['bimonthly','Once in 2 months'],['weekly','Weekly'],['daily','Daily'],['yearly','Yearly'],['one-time','One-time']]; const freqOpts = freqs.map(([v,l])=>``).join(''); openGenModal('Edit Operating Expense', `
For recurring items the day-of-month is treated as the monthly due date.
`, () => { 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']; // ===== CapEx / OpEx PAYMENT (Paid) tracking ===== function payAccountOptions(sel) { const list = [...new Set([...(typeof BANK_LIST !== 'undefined' ? BANK_LIST : []), ...((data.banks||[]).map(b=>b.name).filter(Boolean)), 'Cash', 'Other'])]; return list.map(b => ``).join(''); } function entryPaid(e) { return Array.isArray(e.payments) ? e.payments.reduce((s,p)=>s+(Number(p.amount)||0),0) : (Number(e.paid)||0); } function payStatus(e) { const amt = Number(e.amount)||0, paid = entryPaid(e), out = Math.max(0, amt - paid); if (paid <= 0) return { label:'Unpaid', style:'background:rgba(217,87,63,0.12);color:var(--rose);', paid, out }; if (paid >= amt) return { label:'Paid', style:'background:var(--accent-glow);color:var(--accent);', paid, out:0 }; return { label:'Partial', style:'background:rgba(217,154,60,0.15);color:var(--amber);', paid, out }; } function recordExpensePayment(kind, id) { const arr = kind==='capex' ? (data.capexEntries||[]) : (data.opexEntries||[]); const e = arr.find(x=>x.id===id); if (!e) return; e.payments = Array.isArray(e.payments) ? e.payments : []; const out = Math.max(0, (Number(e.amount)||0) - entryPaid(e)); const hist = e.payments.length ? `
Payment history
` + e.payments.map((p,idx)=> `
${p.date||'—'} · ${escHtml(p.via||'')}${p.note?' · '+escHtml(p.note):''} ${fmtINR(p.amount)}
`).join('') : ''; openGenModal('💰 Record Payment — ' + escHtml(e.name||''), `
Cost ${fmtINR(e.amount)} · Paid ${fmtINR(entryPaid(e))} · Outstanding ${fmtINR(out)}
${hist}`, () => { const amt = Math.round(Number(document.getElementById('payAmt').value||0)); if (!amt) { alert('Enter a payment amount.'); return; } e.payments.push({ amount:amt, date:document.getElementById('payDate').value||dayKey(new Date()), via:document.getElementById('payVia').value, note:document.getElementById('payNote').value.trim() }); e.paid = entryPaid(e); saveData(); (kind==='capex'?renderCapexTab:renderOpexTab)(); closeGenModal(); if (typeof showToast==='function') showToast('Payment of ' + fmtINR(amt) + ' recorded'); }); } function delExpensePayment(kind, id, idx) { const arr = kind==='capex' ? (data.capexEntries||[]) : (data.opexEntries||[]); const e = arr.find(x=>x.id===id); if (!e || !Array.isArray(e.payments)) return; e.payments.splice(idx,1); e.paid = entryPaid(e); saveData(); (kind==='capex'?renderCapexTab:renderOpexTab)(); recordExpensePayment(kind, id); } // OpEx paid is tracked per reporting month function opexEntryPaid(e, month) { const m = (e.paymentsByMonth && e.paymentsByMonth[month]) || []; return m.reduce((s,p)=>s+(Number(p.amount)||0),0); } function opexPayStatusFor(e, month) { const due = opexActualFor(e, month), paid = opexEntryPaid(e, month), out = Math.max(0, due - paid); if (paid <= 0) return { label:'Unpaid', style:'background:rgba(217,87,63,0.12);color:var(--rose);', paid, out }; if (paid >= due) return { label:'Paid', style:'background:var(--accent-glow);color:var(--accent);', paid, out:0 }; return { label:'Partial', style:'background:rgba(217,154,60,0.15);color:var(--amber);', paid, out }; } function recordOpexPayment(id) { const e = (data.opexEntries||[]).find(x=>x.id===id); if (!e) return; const month = opexMonth(); e.paymentsByMonth = e.paymentsByMonth || {}; e.paymentsByMonth[month] = Array.isArray(e.paymentsByMonth[month]) ? e.paymentsByMonth[month] : []; const arr = e.paymentsByMonth[month]; const due = opexActualFor(e, month), out = Math.max(0, due - opexEntryPaid(e, month)); const hist = arr.length ? `
Payments — ${escHtml(month)}
` + arr.map((p,idx)=> `
${p.date||'—'} · ${escHtml(p.via||'')}${p.note?' · '+escHtml(p.note):''} ${fmtINR(p.amount)}
`).join('') : ''; openGenModal('💰 Record Payment — ' + escHtml(e.name||'') + ' · ' + escHtml(month), `
Due (${escHtml(month)}) ${fmtINR(due)} · Paid ${fmtINR(opexEntryPaid(e,month))} · Outstanding ${fmtINR(out)}
${hist}`, () => { const amt = Math.round(Number(document.getElementById('opAmt').value||0)); if (!amt) { alert('Enter a payment amount.'); return; } arr.push({ amount:amt, date:document.getElementById('opDate').value||dayKey(new Date()), via:document.getElementById('opVia').value, note:document.getElementById('opNote').value.trim() }); saveData(); renderOpexTab(); closeGenModal(); if (typeof showToast==='function') showToast('Payment of ' + fmtINR(amt) + ' recorded for ' + month); }); } function delOpexPayment(id, month, idx) { const e = (data.opexEntries||[]).find(x=>x.id===id); if (!e || !e.paymentsByMonth || !e.paymentsByMonth[month]) return; e.paymentsByMonth[month].splice(idx,1); saveData(); renderOpexTab(); recordOpexPayment(id); } 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 totalPaid = manual.reduce((s,e)=>s+entryPaid(e),0); const outstanding = Math.max(0, total - totalPaid); 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'}
Paid
${fmtINR(totalPaid)}
${total>0?Math.round(totalPaid/total*100):0}% of cost
Outstanding
${fmtINR(outstanding)}
balance to pay
${catCards}
+ Add Capital Expenditure
Capital Assets Register
${manual.length ? manual.map(e=>{ const dep = e.life>0 ? Math.round(e.amount/e.life) : 0; const ps = payStatus(e); return ``; }).join('') : ''} ${manual.length?``:''}
DateAsset / ItemCategoryAmountLifeAnnual Deprec.PaidStatusNote
${e.date||'—'} ${escHtml(e.name)} ${escHtml(e.cat)} ${fmtINR(e.amount)} ${e.life?e.life+'y':'—'} ${dep?fmtINR(dep):'—'} ${ps.paid?fmtINR(ps.paid):'—'}${ps.out>0&&ps.paid>0?`
bal ${fmtINR(ps.out)}
`:''}
${ps.label} ${escHtml(e.note||'')}
No capital expenditure recorded yet.
TOTAL${fmtINR(total)}${fmtINR(totalPaid)}
`; } // ===== OpEx helpers: actual-vs-budget + admin staff attendance ===== const OPEX_MONTHLY = (e) => e.freq==='yearly' ? e.amount/12 : e.freq==='bimonthly' ? e.amount/2 : 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 opexPaidTotal = list.reduce((s,e)=>s+opexEntryPaid(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
Paid — ${escHtml(monthLabel)}
${fmtINR(opexPaidTotal)}
Unpaid ${fmtINR(Math.max(0, Math.round(opexActualTotal)-opexPaidTotal))}
${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; const ps = opexPayStatusFor(e, month); return ``; }).join('') : ''} ${list.length?``:''}
DateExpenseCategoryTypeFreqBudget (mo)Actual (${escHtml(month)})PaidStatusNote
${e.date||'—'} ${escHtml(e.name)} ${escHtml(e.cat)} ${kind==='variable'?'Variable':'Fixed'} ${escHtml(e.freq||'monthly')} ${fmtINR(budget)} ${ps.paid?fmtINR(ps.paid):'—'}${ps.out>0&&ps.paid>0?`
bal ${fmtINR(ps.out)}
`:''}
${ps.label} ${escHtml(e.note||'')}
No operating expenditure recorded yet.
TOTAL${fmtINR(Math.round(budgetMonthly))}${fmtINR(opexActualTotal)}${fmtINR(opexPaidTotal)}
👔 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'; // ===== RUNNING CAPITAL — projected capital to finish projects, interlinking expected IN / OUT ===== function setRunCapCash(v) { data.runCapCash = Number(v) || 0; saveData(); renderRunningCapital(); } // Average weekly & monthly labour wages from historical attendance (run-rate for projections) function labourWageAverages() { const att = (data.attendance||[]).filter(a => Number(a.wage) > 0 && a.date); const out = { avgWeekly:0, avgMonthly:0, weeksSampled:0, monthsSampled:0 }; if (!att.length) return out; const byWeek = {}, byMonth = {}; att.forEach(a => { const d = new Date(a.date + 'T00:00:00'); const sun = new Date(d); sun.setDate(d.getDate() - d.getDay()); const wk = dayKey(sun); byWeek[wk] = (byWeek[wk]||0) + Number(a.wage||0); const mk = a.date.slice(0,7); byMonth[mk] = (byMonth[mk]||0) + Number(a.wage||0); }); const avg = o => { const v = Object.values(o); return v.length ? Math.round(v.reduce((s,x)=>s+x,0)/v.length) : 0; }; out.avgWeekly = avg(byWeek); out.avgMonthly = avg(byMonth); out.weeksSampled = Object.keys(byWeek).length; out.monthsSampled = Object.keys(byMonth).length; return out; } // Total OpEx normalised to a monthly figure function opexMonthlyTotal() { return Math.round((data.opexEntries||[]).reduce((s,e)=> s + (typeof OPEX_MONTHLY==='function' ? OPEX_MONTHLY(e) : Number(e.amount||0)), 0)); } function setRunCapWeeks(v) { data.runCapWeeks = Math.max(1, Math.round(Number(v)||4)); saveData(); renderRunningCapital(); } function runCapData() { const projects = (data.projects || []).filter(p => (p.status || 'planning') !== 'completed'); const accts = data.accounts || []; const pendIn = accts.filter(a => a.type === 'payment-in' && a.status === 'pending'); const pendOut = accts.filter(a => a.type === 'vendor-payment' && a.status === 'pending'); const sumBy = (arr, k) => arr.reduce((m,a) => { const key = a.projectId ?? 'none'; m[key] = (m[key]||0) + Number(a.amount||0); return m; }, {}); const inByP = sumBy(pendIn), outByP = sumBy(pendOut); const rows = projects.map(p => { const fc = computeProjectForecast(p); const expIn = inByP[p.id] || 0, expOut = outByP[p.id] || 0; const toComplete = Math.round(fc.moneyNeeded); const gap = Math.max(0, toComplete - expIn); // self-funded shortfall after client money return { p, budget: fc.budget, spent: fc.spent, progress: fc.progress, toComplete, expIn, expOut, gap }; }).sort((a,b) => b.gap - a.gap); return { rows, pendIn, pendOut }; } function renderRunningCapital() { const el = document.getElementById('runningCapitalContent'); if (!el) return; const { rows, pendIn, pendOut } = runCapData(); const cash = Number(data.runCapCash) || 0; const totComplete = rows.reduce((s,r)=>s+r.toComplete,0); const totIn = rows.reduce((s,r)=>s+r.expIn,0); const totOut = rows.reduce((s,r)=>s+r.expOut,0); const totGap = rows.reduce((s,r)=>s+r.gap,0); // ── Projected recurring costs from history: labour salary run-rate + OpEx ── const weeks = Math.max(1, Number(data.runCapWeeks)||4); // planning horizon const wageAvg = labourWageAverages(); const monthlyOpex = opexMonthlyTotal(); const weeklyOpex = Math.round(monthlyOpex / 4.33); const weeklyRunRate = wageAvg.avgWeekly + weeklyOpex; // labour + opex per week const projLabour = Math.round(wageAvg.avgWeekly * weeks); const projOpex = Math.round(weeklyOpex * weeks); const projRunning = projLabour + projOpex; // total recurring cost over the horizon const netToArrange = totComplete + projRunning - totIn - cash; // >0 → arrange funds // ── Cashflow projection: bucket pending IN / OUT by month for the next 6 months ── const monthKey = d => (d||'').slice(0,7); const now = new Date(); now.setDate(1); now.setHours(0,0,0,0); const buckets = []; for (let i=0;i<6;i++){ const d=new Date(now.getFullYear(),now.getMonth()+i,1); buckets.push({ key: dayKey(d).slice(0,7), label: d.toLocaleDateString('en-IN',{month:'short',year:'2-digit'}), in:0, out:0 }); } const bkOf = k => buckets.find(b=>b.key===k); let overdueIn=0, overdueOut=0; const nowKey = dayKey(new Date()).slice(0,7); pendIn.forEach(a=>{ const mk=monthKey(a.dueDate||a.date); const b=bkOf(mk); if(b)b.in+=Number(a.amount||0); else if(mk{ const mk=monthKey(a.dueDate||a.date); const b=bkOf(mk); if(b)b.out+=Number(a.amount||0); else if(mk{ b.recurring = i < horizonMonths ? monthlyRunRate : 0; b.out += b.recurring; }); let running = cash + overdueIn - overdueOut; buckets.forEach(b=>{ b.net=b.in-b.out; running+=b.net; b.cum=running; }); const lowest = buckets.length ? Math.min(...buckets.map(b=>b.cum)) : cash; const lowestBk = buckets.find(b=>b.cum===lowest); const kpi = (bg,bd,cl,lbl,val,sub)=>`
${lbl}
${val}
${sub}
`; el.innerHTML = `
🏗 Running capital = the cash you must float to finish your active works, after the client payments you expect. Enter expected IN (corporation bills) and OUT (vendor / labour dues) per project below — the plan tells you how much to arrange and when you'll be short.
${kpi('rgba(255,159,10,0.07)','rgba(255,159,10,0.25)','var(--amber)','To Complete', fmtINR(totComplete), rows.length+' active project'+(rows.length===1?'':'s'))} ${kpi('rgba(191,90,242,0.07)','rgba(191,90,242,0.25)','var(--violet)','Projected Labour + OpEx', fmtINR(projRunning), 'next '+weeks+' wks · '+fmtINR(weeklyRunRate)+'/wk')} ${kpi('rgba(48,209,88,0.07)','rgba(48,209,88,0.25)','var(--accent)','Expected IN', fmtINR(totIn), pendIn.length+' incoming')} ${kpi('rgba(255,69,58,0.07)','rgba(255,69,58,0.25)','var(--rose)','Expected OUT', fmtINR(totOut), pendOut.length+' payable')}
Cash on hand
Capital available now
${kpi(netToArrange>0?'rgba(255,69,58,0.10)':'rgba(48,209,88,0.10)', netToArrange>0?'rgba(255,69,58,0.35)':'rgba(48,209,88,0.35)', netToArrange>0?'var(--rose)':'var(--accent)', netToArrange>0?'Capital to Arrange':'Surplus', fmtINR(Math.abs(netToArrange)), netToArrange>0?'Borrow / bring in':'Fully funded ✓')}
🏗 Capital by Project
${rows.length} active
${rows.length ? rows.map(r=>``).join('') : ''}
Project Budget Spent To Complete Expected IN Expected OUT Fund Gap
${escHtml(projNick(r.p))}
${r.budget>0?Math.round(r.progress)+'% done':'no budget set'}
${r.budget?fmtINR(r.budget):'—'} ${fmtINR(r.spent)} ${fmtINR(r.toComplete)} ${r.expIn?fmtINR(r.expIn):''} ${r.expOut?fmtINR(r.expOut):''} ${r.gap>0?fmtINR(r.gap):'✓ funded'}
No active projects — mark projects in-progress to plan their capital.
TOTAL ${fmtINR(totComplete)} ${fmtINR(totIn)} ${fmtINR(totOut)} ${fmtINR(totGap)}
📈 Projected Run-Rate — Labour + OpEx
Plan ahead weeks
Avg weekly wages
${fmtINR(wageAvg.avgWeekly)}
from ${wageAvg.weeksSampled} week${wageAvg.weeksSampled===1?'':'s'} of history
Avg monthly wages
${fmtINR(wageAvg.avgMonthly)}
from ${wageAvg.monthsSampled} month${wageAvg.monthsSampled===1?'':'s'}
Monthly OpEx
${fmtINR(monthlyOpex)}
${(data.opexEntries||[]).length} item${(data.opexEntries||[]).length===1?'':'s'} · ${fmtINR(weeklyOpex)}/wk
Projected — ${weeks} weeks
${fmtINR(projRunning)}
labour ${fmtINR(projLabour)} + opex ${fmtINR(projOpex)}
This projected run-rate is added to project completion cost in Capital to Arrange and spread across the cashflow months below.
📆 6-Month Cashflow Projection
dues + labour/opex run-rate vs expected income
${(overdueIn||overdueOut)?`
Overdue carried in: +${fmtINR(overdueIn)} / -${fmtINR(overdueOut)}
`:''}
OUT includes the projected labour + OpEx run-rate of ${fmtINR(monthlyRunRate)}/mo for the next ${horizonMonths} month${horizonMonths===1?'':'s'}.
${buckets.map(b=>``).join('')}
MonthINOUTNetRunning Balance
${b.label} ${b.in?fmtINR(b.in):'—'} ${b.out?fmtINR(b.out):'—'} ${b.net>=0?'+':''}${fmtINR(b.net)} ${fmtINR(b.cum)}${b.cum<0?' ⚠':''}
🎯 Execution Plan
${(rows.length || projRunning) ? ` • To finish your ${rows.length} active project${rows.length===1?'':'s'} you need ${fmtINR(totComplete)}, plus ${fmtINR(projRunning)} projected labour + OpEx for the next ${weeks} weeks (from your history) = ${fmtINR(totComplete+projRunning)} total.
• Expected client payments (IN) cover ${fmtINR(totIn)}; you have ${fmtINR(cash)} cash on hand.
${netToArrange>0 ? `• Arrange ${fmtINR(netToArrange)} (own capital / borrow) to execute without stalling.` : `• ✓ Fully funded — expected inflows + cash cover the work, with ${fmtINR(-netToArrange)} to spare.`}
${lowest<0 ? `• ⚠ Cash goes negative (${fmtINR(lowest)}) around ${lowestBk?lowestBk.label:''} — line up funds before then or push some vendor dues out.` : `• Lowest projected balance is ${fmtINR(lowest)} (${lowestBk?lowestBk.label:'—'}) — stays positive through the window.`} ${totGap>0?`
• Fund the biggest gaps first: ${rows.filter(r=>r.gap>0).slice(0,3).map(r=>escHtml(projNick(r.p))+' '+fmtINR(r.gap)).join(' · ')}.`:''} ` : 'Add active projects and their expected payments to build a running-capital plan.'}
`; } // Inline: add an expected IN / OUT payment tied to a project (writes to the same ledger as Money Management) function addRunCapFlow(projectId, dir) { const proj = (data.projects||[]).find(p=>p.id===projectId); const isIn = dir === 'in'; openGenModal((isIn?'💰 Expected Payment IN':'💸 Expected Payment OUT') + ' — ' + (proj?escHtml(projNick(proj)):''), `
`, () => { const amount = Number(document.getElementById('rcAmount')?.value)||0; const date = document.getElementById('rcDate')?.value || dayKey(new Date()); if (!amount) { alert('Enter an amount.'); return; } data.accounts = data.accounts || []; data.accounts.push({ id: Date.now(), type: isIn?'payment-in':'vendor-payment', projectId, company: proj?proj.company:null, party: (document.getElementById('rcParty')?.value||'').trim(), amount, date: dayKey(new Date()), status: 'pending', dueDate: date, note: 'Running-capital plan' }); saveData(); closeGenModal(); renderRunningCapital(); if (typeof showToast==='function') showToast((isIn?'Expected IN ':'Expected OUT ')+fmtINR(amount)+' added'); }); } 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('fundsTabPnl').style.display = tab === 'pnl' ? '' : '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('fundsTabRunning').style.display = tab === 'running' ? '' : 'none'; document.getElementById('fundsTabProfitFirst').style.display = tab === 'profitfirst' ? '' : 'none'; document.getElementById('fundsTabBalSheet').style.display = tab === 'balsheet' ? '' : 'none'; if (tab === 'pnl') renderPnlDashboard(); if (tab === 'borrow') { renderLoans(); loanCategoryChanged(); } if (tab === 'planner') renderRepayPlanner(); if (tab === 'balance') renderBalanceViews(); if (tab === 'banks') renderBanksTab(); if (tab === 'forecast') renderForecastTab(); if (tab === 'running') renderRunningCapital(); 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 coreKeys = ['projects','labours','vendors','subcontractors','attendance','accounts','labourPayments','labourSupply','funds','loans','assets','tasks','invoices','quotations','purchaseOrders','vendorBills']; const found = coreKeys.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.map(k=>k+' ('+imported[k].length+')').join(', '))) return; // Restore EVERY store in the backup, not just a fixed list — new entries merge in, // existing entries (same id) are kept as they are. const keyOf = x => String(x && typeof x === 'object' ? (x.id ?? x.name ?? JSON.stringify(x)) : x); Object.keys(imported).forEach(k => { if (k === '__proto__' || k === 'constructor' || k === 'prototype') return; const val = imported[k]; if (Array.isArray(val)) { if (!Array.isArray(data[k])) data[k] = []; if (val.some(x => x && typeof x === 'object')) { const ids = new Set(data[k].map(keyOf)); val.forEach(item => { const kk = keyOf(item); if (!ids.has(kk)) { data[k].push(item); ids.add(kk); } }); } else { data[k] = Array.from(new Set([...data[k], ...val])); // scalar list → union } } else if (k === 'docCounter' && val && typeof val === 'object') { data.docCounter = data.docCounter || {}; Object.keys(val).forEach(c => { data.docCounter[c] = Math.max(Number(data.docCounter[c]||0), Number(val[c]||0)); }); } else if (k === 'finance' && val && typeof val === 'object') { data.finance = Object.assign({ income:0, budget:0, totalPaid:0, totalPayable:0, expenses:[] }, data.finance || {}); ['income','budget','totalPaid','totalPayable'].forEach(f => { if (!Number(data.finance[f]) && Number(val[f])) data.finance[f] = Number(val[f]); }); if (!Array.isArray(data.finance.expenses)) data.finance.expenses = []; const ids = new Set(data.finance.expenses.map(keyOf)); (Array.isArray(val.expenses) ? val.expenses : []).forEach(x => { if (!ids.has(keyOf(x))) data.finance.expenses.push(x); }); } else if (val && typeof val === 'object') { // plain object store (rates, toggles, settings) — backup fills gaps, current values win data[k] = Object.assign({}, val, (data[k] && typeof data[k] === 'object') ? data[k] : {}); } else if (data[k] === undefined || data[k] === null) { data[k] = val; // scalar — only fill if missing } }); 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