/* Final mobile refinement: keep class-count cards comfortably wide. */ @media(max-width:650px){ .pupil-summary-grid{grid-template-columns:repeat(2,minmax(0,1fr))!important} .pupil-summary-grid>.card:nth-child(3){grid-column:1/-1} .class-count-grid{grid-template-columns:repeat(3,minmax(0,1fr))!important;gap:8px!important} .class-count-box{min-width:0;min-height:84px;padding:12px 8px!important;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px} .class-count-box strong{font-size:clamp(12px,3.6vw,15px)!important;line-height:1.25;overflow-wrap:anywhere} .class-count-box span{font-size:clamp(20px,6vw,26px)!important;line-height:1.1} } @media(max-width:380px){.class-count-grid{grid-template-columns:repeat(2,minmax(0,1fr))!important}.class-count-box{min-height:76px}} .gc-related-panel{margin:0 0 15px;padding:16px 18px;border:1px solid #d2e0f1;border-radius:18px;background:rgba(255,255,255,.94);box-shadow:0 2px 8px rgba(12,48,88,.045)} .gc-related-panel h3{margin:0 0 10px;font-size:12px;letter-spacing:.08em;color:#365b80} .gc-related-links{display:flex;flex-wrap:wrap;gap:8px} .gc-related-links button{border:1px solid #c8d8e9;border-radius:999px;background:#fff;color:#164b7e;padding:10px 15px;min-height:42px;font-weight:700} .gc-related-links button:focus-visible{outline:3px solid #62a9f5;outline-offset:2px} @media(max-width:650px){.gc-related-panel{padding:13px}.gc-related-links button{padding:10px 12px}}

Sign in to your accounts

Use your authorized email and password.

Owner access: Create your account through the Supabase dashboard first. Public sign-up is intentionally not offered on this page. Database policies isolate each user's records.

Dashboard

Company financial overview

Grateful Collective company accounts across school operations, rentals, shops, payroll and expenses.

Financial cards and trend visuals use recorded transactions for the selected period. Outstanding balances are shown separately and are not counted as cash received.

TOTAL PUPILS
0
Registered pupils
OUTSTANDING SCHOOL FEES
K0.00
Where fee schedules and payments are available
OUTSTANDING RENT
K0.00
Calculated from rental periods and payments

Quick actions

Financial performance

Monthly recorded income and expenses; use the period filter above to focus the dashboard.

IncomeExpenses

Items needing attention

Dashboard alerts will appear here when records are loaded.

TOTAL INCOME
K0.00
Income recorded across all sections
TOTAL EXPENSES
K0.00
Recorded expenses + paid payables + paid payroll
NET BALANCE
K0.00
Total positives minus total negative

Where the money is

Net recorded movement by payment channel (received minus paid). Add opening balances separately to see actual balances.

BANK
K0.00
CASH
K0.00
AIRTEL MONEY
K0.00
MTN MOMO
K0.00

Recorded vs actual balances

Transfers move funds between channels and are not income or expenses.

Record a transfer

Arrears alerts

Totals

Audit trail

RECENT TRANSACTIONS

`;const w=window.open('','_blank');if(!w){appAlert('Allow pop-ups to open the class list document.');return}w.document.open();w.document.write(doc);w.document.close();toast('Class list document created');}; $('pupilEnrollmentDate').value=today;$('pupilSearch').addEventListener('input',renderPupils);$('exportPupils').onclick=()=>{const rows=[['Pupil name','Class / grade','Pupil type','Requirements received','Lunch paid ZMW','Test fees paid ZMW','Amount paid by pupil ZMW','Phone','Added'],...pupils.map(p=>{const lunch=lunchPayments.filter(r=>String(r.pupil_id)===String(p.id)&&String(r.payment_status||'Paid').toLowerCase()==='paid').reduce((sum,r)=>sum+Number(r.amount||0),0),tests=testPayments.filter(r=>String(r.pupil_id)===String(p.id)&&String(r.payment_status||'Paid').toLowerCase()==='paid').reduce((sum,r)=>sum+Number(r.amount||0),0);return [p.full_name,p.class_name,p.pupil_type||'NOT A NEWCOMER',(p.requirements_checklist||[]).join('; '),lunch,tests,lunch+tests,p.guardian_phone,String(p.created_at||'').slice(0,10)]})];const data=rows.map(r=>r.map(v=>'"'+String(v??'').replace(/"/g,'""')+'"').join(',')).join('\r\n');const blob=new Blob(['\ufeff'+data],{type:'text/csv;charset=utf-8'}),url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download='grateful-collective-pupils.csv';a.click();URL.revokeObjectURL(url)}; function renderLunch(){if(!$('lunchRows'))return;const q=($('lunchSearch').value||'').toLowerCase(),status=$('lunchStatusFilter').value;$('lunchCount').textContent=lunchPayments.length;$('lunchTotal').textContent=money(lunchPayments.filter(r=>String(r.payment_status||'Paid').toLowerCase()==='paid').reduce((sum,r)=>sum+Number(r.amount||0),0));$('lunchPaidCount').textContent=lunchPayments.filter(r=>String(r.payment_status||'Paid').toLowerCase()==='paid').length;$('lunchPupil').innerHTML=''+pupils.map(p=>``).join('');const rows=[['Date','Pupil','Class','Category','Amount (ZMW)','Method / reference','Notes','Status']];lunchPayments.filter(r=>{const p=pupils.find(x=>String(x.id)===String(r.pupil_id));return (!q||(`${p?.full_name||'Pupil'} ${r.payment_date||''}`).toLowerCase().includes(q))&&(!status||String(r.payment_status||'Paid')===status)}).forEach(r=>{const p=pupils.find(x=>String(x.id)===String(r.pupil_id));const periodMatch=String(r.notes||'').match(/Category: (Per day|Per week|Per month)/);rows.push([esc(r.payment_date||''),esc(p?.full_name||`Pupil #${r.pupil_id}`),esc(p?.class_name||''),esc(periodMatch?.[1]||'Per day'),money(r.amount),esc(r.payment_method||''),esc(String(r.notes||'').replace(/Category: (Per day|Per week|Per month)\s*\|?\s*/,'').trim()),esc(r.payment_status||'Paid')])});$('lunchRows').innerHTML=tbl(rows,'No lunch payments match your search.');if($('lunchTodayDate'))$('lunchTodayDate').textContent=today;const shownDate=$('lunchTodayDate')?.dataset.date||today;const todays=lunchPayments.filter(r=>String(r.payment_date||'')===shownDate&&String(r.payment_status||'Paid').toLowerCase()==='paid');$('lunchTodayRows').innerHTML=tbl([['PUPIL','GRADE','AMOUNT','METHOD'],...todays.map(r=>{const p=pupils.find(x=>String(x.id)===String(r.pupil_id));return [esc(p?.full_name||'Pupil'),esc(p?.class_name||''),money(r.amount),esc(r.payment_method||'') ]})],'No pupils have paid for lunch today.')} $('lunchForm').addEventListener('submit',async e=>{e.preventDefault();const amount=Number($('lunchAmount').value);if(!(amount>0)){appAlert('Enter a payment amount greater than zero.');return}const period=$('lunchPeriod').value;const noteParts=[`Category: ${period}`,$('lunchNotes').value.trim()].filter(Boolean);const row={user_id:user.id,pupil_id:Number($('lunchPupil').value),payment_date:$('lunchDate').value,amount,payment_status:$('lunchStatus').value,payment_method:$('lunchMethod').value,notes:noteParts.join(' | ')||null};const {error}=await sb.from('lunch_payments').insert(row);if(error){appAlert('Lunch payment could not be saved: '+error.message);return}e.target.reset();$('lunchDate').value=today;await loadRecords();toast('Lunch payment saved')});$('lunchDate').value=today;$('lunchSearch').addEventListener('input',renderLunch);$('lunchStatusFilter').addEventListener('change',renderLunch);$('exportLunch').onclick=()=>{const rows=[['Date','Pupil','Class','Category','Amount ZMW','Payment method/reference','Notes','Status'],...lunchPayments.map(r=>{const p=pupils.find(x=>String(x.id)===String(r.pupil_id)),m=String(r.notes||'').match(/Category: (Per day|Per week|Per month)/);return [r.payment_date,p?.full_name||`Pupil #${r.pupil_id}`,p?.class_name||'',m?.[1]||'Per day',r.amount,r.payment_method,String(r.notes||'').replace(/Category: (Per day|Per week|Per month)\s*\|?\s*/,'').trim(),r.payment_status||'Paid']})];const data=rows.map(r=>r.map(v=>'"'+String(v??'').replace(/"/g,'""')+'"').join(',')).join('\r\n');const blob=new Blob(['\ufeff'+data],{type:'text/csv;charset=utf-8'}),url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download='grateful-collective-lunch-payments.csv';a.click();URL.revokeObjectURL(url)}; function renderTests(){if(!$('testRows'))return;if($('testCount'))$('testCount').textContent=schoolTests.length;$('testPaymentCount').textContent=testPayments.length;$('testPaymentTotal').textContent=money(testPayments.filter(r=>String(r.payment_status||'Paid').toLowerCase()==='paid').reduce((sum,r)=>sum+Number(r.amount||0),0));$('testPaymentTest').innerHTML=''+schoolTests.map(t=>``).join('');$('testPaymentPupil').innerHTML=''+pupils.map(p=>``).join('');const autoLinkTest=()=>{const p=pupils.find(x=>String(x.id)===$('testPaymentPupil').value);const matches=schoolTests.filter(t=>!p||!p.class_name||String(t.class_name||'').trim().toLowerCase()===String(p.class_name).trim().toLowerCase());const chosen=matches.slice().sort((a,b)=>String(b.test_date||b.created_at||'').localeCompare(String(a.test_date||a.created_at||'')))[0]||schoolTests.slice().sort((a,b)=>String(b.test_date||b.created_at||'').localeCompare(String(a.test_date||a.created_at||'')))[0];$('testPaymentTest').value=chosen?String(chosen.id):'';};autoLinkTest();const pq=($('testPaymentSearch').value||'').toLowerCase(),status=$('testPaymentStatusFilter').value;const prows=[['Date','Test','Pupil','Class','Amount (ZMW)','Method / reference','Status']];testPayments.filter(r=>{const t=schoolTests.find(x=>String(x.id)===String(r.test_id)),p=pupils.find(x=>String(x.id)===String(r.pupil_id));return (!pq||(`${t?.test_name||''} ${p?.full_name||''} ${r.payment_date||''}`).toLowerCase().includes(pq))&&(!status||String(r.payment_status||'Paid')===status)}).forEach(r=>{const t=schoolTests.find(x=>String(x.id)===String(r.test_id)),p=pupils.find(x=>String(x.id)===String(r.pupil_id));prows.push([esc(r.payment_date||''),esc(t?.test_name||`Test #${r.test_id}`),esc(p?.full_name||`Pupil #${r.pupil_id}`),esc(p?.class_name||''),money(r.amount),esc(r.payment_method||''),esc(r.payment_status||'Paid')])});$('testPaymentRows').innerHTML=tbl(prows,'No test payments match your search.')} $('testPaymentForm').addEventListener('submit',async e=>{e.preventDefault();const amount=Number($('testPaymentAmount').value),testId=$('testPaymentTest').value,pupilId=$('testPaymentPupil').value;if(!testId||!pupilId||!(amount>0)){appAlert(!testId?'Create a school test first. The payment will link automatically to the selected pupil’s class test.':'Select a pupil and enter an amount greater than zero.');return}const row={user_id:user.id,test_id:Number(testId),pupil_id:Number(pupilId),payment_date:$('testPaymentDate').value,amount,payment_status:$('testPaymentStatus').value,payment_method:$('testPaymentMethod').value};const {error}=await sb.from('test_payments').insert(row);if(error){appAlert('Test payment could not be saved: '+error.message);return}e.target.reset();$('testPaymentDate').value=today;await loadRecords();toast('Test payment saved')}); $('testPaymentPupil').addEventListener('change',()=>{renderTests();});$('testPaymentSearch').addEventListener('input',renderTests);$('testPaymentStatusFilter').addEventListener('change',renderTests);$('testPaymentDate').value=today;$('testPaymentTest').addEventListener('change',()=>{const t=schoolTests.find(x=>String(x.id)===$('testPaymentTest').value);if(t&&Number(t.test_fee)>0)$('testPaymentAmount').value=Number(t.test_fee).toFixed(2)});$('testPaymentPupil').addEventListener('change',()=>{const p=pupils.find(x=>String(x.id)===$('testPaymentPupil').value);const matches=schoolTests.filter(t=>!p||!p.class_name||String(t.class_name||'').trim().toLowerCase()===String(p.class_name).trim().toLowerCase());const t=matches.slice().sort((a,b)=>String(b.test_date||b.created_at||'').localeCompare(String(a.test_date||a.created_at||'')))[0]||schoolTests.slice().sort((a,b)=>String(b.test_date||b.created_at||'').localeCompare(String(a.test_date||a.created_at||'')))[0];$('testPaymentTest').value=t?String(t.id):'';if(t&&Number(t.test_fee)>0)$('testPaymentAmount').value=Number(t.test_fee).toFixed(2)}); $('exportTests').onclick=()=>{const rows=[['Record type','Date','Test','Class','Pupil','Total marks','Fee per pupil','Amount','Method/reference','Status'],...schoolTests.map(t=>['Test',t.test_date,t.test_name,t.class_name,'',t.total_marks,t.test_fee,'','','']),...testPayments.map(r=>{const t=schoolTests.find(x=>String(x.id)===String(r.test_id)),p=pupils.find(x=>String(x.id)===String(r.pupil_id));return ['Payment',r.payment_date,t?.test_name||'',t?.class_name||'',p?.full_name||'',t?.total_marks,t?.test_fee,r.amount,r.payment_method,r.payment_status||'Paid']})];const data=rows.map(row=>row.map(v=>'"'+String(v??'').replace(/"/g,'""')+'"').join(',')).join('\r\n');const blob=new Blob(['\ufeff'+data],{type:'text/csv;charset=utf-8'}),url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download='grateful-collective-tests-and-payments.csv';a.click();URL.revokeObjectURL(url)}; function renderPayroll(){ if(!$('workerRows'))return; $('workerCount').textContent=workers.length; const napsaCompany=payrollRecords.reduce((sum,r)=>sum+Math.max(0,Number(r.basic_pay||0))*0.05,0); const napsaEmployee=payrollRecords.reduce((sum,r)=>sum+Math.max(0,Number(r.napsa_amount||0)),0); const napsaOther=transactions.filter(t=>`${t.category||''} ${t.description||''} ${t.notes||''}`.toLowerCase().includes('napsa')).reduce((sum,t)=>sum+Number(t.amount||0),0); $('napsaCompanyTotal').textContent=money(napsaCompany);$('napsaEmployeeTotal').textContent=money(napsaEmployee);$('napsaOtherTotal').textContent=money(napsaOther); const selectedPeriod=$('payPeriod')?.value||today,periodKey=selectedPeriod.slice(0,7); const current=payrollRecords.filter(r=>String(r.pay_period||'').slice(0,7)===periodKey); $('payrollTotal').textContent=money(current.reduce((a,r)=>a+Number(r.net_pay||0),0)); $('payrollPaid').textContent=money(current.filter(r=>String(r.payment_status||'').toLowerCase()==='paid').reduce((a,r)=>a+Number(r.net_pay||0),0)); $('payrollPending').textContent=current.filter(r=>String(r.payment_status||'').toLowerCase()!=='paid').length; $('payrollWorker').innerHTML=''+workers.filter(w=>w.is_active!==false).map(w=>``).join(''); $('workerRows').innerHTML=tbl([['Worker','Job title','Department','Employment','Frequency','Basic salary (ZMW)','Contact','Status','Added','Actions'],...workers.map(w=>[esc(w.full_name),esc(w.job_title||''),esc(w.department||'Administration'),esc(w.employment_type||'Permanent'),esc(w.pay_frequency||'Monthly'),money(w.basic_salary),esc([w.phone,w.email].filter(Boolean).join(' · ')),w.is_active===false?'Inactive':'Active',esc(String(w.created_at||'').slice(0,10)),``])],'No workers added yet.'); const q=($('payrollSearch').value||'').toLowerCase(),status=$('payrollStatusFilter').value; const rows=[['Pay period','Worker','Gross pay','PAYE','NAPSA','NHIMA','Advance','Other deductions','Net pay','Method','Status','Payslip']]; payrollRecords.filter(r=>{const w=workers.find(x=>String(x.id)===String(r.worker_id));return (!q||(`${w?.full_name||'Worker'} ${r.pay_period||''}`).toLowerCase().includes(q))&&(!status||r.payment_status===status)}).forEach(r=>{const w=workers.find(x=>String(x.id)===String(r.worker_id));rows.push([esc(r.pay_period||''),esc(w?.full_name||`Worker #${r.worker_id}`),money(r.gross_pay??(Number(r.basic_pay||0)+Number(r.allowances||0)+Number(r.overtime_pay||0)+Number(r.bonus_pay||0)+Number(r.commission_pay||0))),money(r.paye_amount),money(r.napsa_amount),money(r.nhima_amount),money(r.advance_deduction),money(r.other_deductions),`${money(r.net_pay)}`,esc(r.payment_method||''),``,``])}); $('payrollRows').innerHTML=tbl(rows,'No payroll records match your search.'); } window.toggleWorkerActive=async id=>{const w=workers.find(x=>String(x.id)===String(id));if(!w)return;const active=w.is_active===false;if(!await themedConfirm(`${active?'Reactivate':'Mark'} ${w.full_name} ${active?'as active':'as inactive'}? Historical payroll records will be preserved.`))return;const {error}=await sb.from('workers').update({is_active:active}).eq('id',id);if(error){appAlert('Could not update worker status: '+error.message);return}await loadRecords();toast(active?'Worker reactivated':'Worker marked inactive')}; function calcNetPay(){const val=id=>Number($(id)?.value)||0;const gross=val('basicPay')+val('allowances')+val('overtimePay')+val('bonusPay')+val('commissionPay');const deductions=val('payeAmount')+val('napsaDeduction')+val('nhimaDeduction')+val('advanceDeduction')+val('otherDeductions');$('grossPay').value=gross.toFixed(2);$('netPay').value=Math.max(0,gross-deductions).toFixed(2)} $('workerForm').addEventListener('submit',async e=>{e.preventDefault();const row={user_id:user.id,full_name:$('workerName').value.trim(),job_title:$('workerTitle').value.trim()||null,basic_salary:Number($('workerSalary').value),department:$('workerDepartment').value,employment_type:$('workerEmployment').value,pay_frequency:$('workerFrequency').value,phone:$('workerPhone').value.trim()||null,email:$('workerEmail').value.trim()||null,start_date:$('workerStart').value||null,bank_name:$('workerBank').value.trim()||null,payment_account:$('workerAccount').value.trim()||null,is_active:true};if(row.basic_salary<0){appAlert('Basic salary cannot be negative.');return}const {error}=await sb.from('workers').insert(row);if(error){appAlert('Worker could not be saved: '+error.message);return}e.target.reset();$('workerSalary').value='0';await loadRecords();toast('Worker saved')}); $('payrollWorker').addEventListener('change',()=>{const w=workers.find(x=>String(x.id)===$('payrollWorker').value);if(w)$('basicPay').value=Number(w.basic_salary||0).toFixed(2);calcNetPay()}); ['basicPay','allowances','overtimePay','bonusPay','commissionPay','payeAmount','napsaDeduction','nhimaDeduction','advanceDeduction','otherDeductions'].forEach(id=>$(id).addEventListener('input',calcNetPay));$('payPeriod').value=today;$('payrollPaymentDate').value=today; $('payPeriod').addEventListener('change',renderPayroll);$('payrollSearch').addEventListener('input',renderPayroll);$('payrollStatusFilter').addEventListener('change',renderPayroll); $('payrollForm').addEventListener('submit',async e=>{e.preventDefault();const workerId=$('payrollWorker').value;if(!workers.some(w=>String(w.id)===workerId)){appAlert('Please select a worker.');return}const val=id=>Number($(id).value)||0,basic=val('basicPay'),allow=val('allowances'),overtime=val('overtimePay'),bonus=val('bonusPay'),commission=val('commissionPay'),paye=val('payeAmount'),napsa=val('napsaDeduction'),nhima=val('nhimaDeduction'),advance=val('advanceDeduction'),other=val('otherDeductions'),gross=basic+allow+overtime+bonus+commission,net=gross-paye-napsa-nhima-advance-other;if([basic,allow,overtime,bonus,commission,paye,napsa,nhima,advance,other].some(n=>!Number.isFinite(n)||n<0)||net<0){appAlert('Check payroll amounts. Amounts cannot be negative and total deductions cannot exceed gross pay.');return}const row={user_id:user.id,worker_id:Number(workerId),pay_period:$('payPeriod').value,basic_pay:basic,allowances:allow,overtime_pay:overtime,bonus_pay:bonus,commission_pay:commission,gross_pay:gross,paye_amount:paye,napsa_rate:0,napsa_amount:napsa,nhima_amount:nhima,advance_deduction:advance,other_deductions:other,net_pay:net,payment_status:$('paymentStatus').value,payment_method:$('payrollMethod').value,payment_date:$('payrollPaymentDate').value||null,payment_reference:$('payrollReference').value.trim()||null,approved_at:$('paymentStatus').value==='Approved'||$('paymentStatus').value==='Paid'?new Date().toISOString():null};const {error}=await sb.from('payroll_records').insert(row);if(error){appAlert('Payroll record could not be saved: '+error.message);return}e.target.reset();$('payPeriod').value=today;$('payrollPaymentDate').value=today;['allowances','overtimePay','bonusPay','commissionPay','payeAmount','napsaDeduction','nhimaDeduction','advanceDeduction','otherDeductions'].forEach(id=>$(id).value='0');calcNetPay();await loadRecords();toast('Payroll record saved')}); window.togglePayrollStatus=async id=>{const r=payrollRecords.find(x=>String(x.id)===String(id));if(!r)return;const current=String(r.payment_status||'Pending Review');const next=current==='Pending Review'?'Approved':current==='Approved'?'Paid':'Pending Review';if(!await themedConfirm(`Move this payroll record to ${next}?`))return;const {error}=await sb.from('payroll_records').update({payment_status:next,approved_at:next==='Approved'||next==='Paid'?new Date().toISOString():null,payment_date:next==='Paid'?(r.payment_date||today):r.payment_date}).eq('id',id);if(error){appAlert('Could not update payroll status: '+error.message);return}await loadRecords();toast(`Payroll marked ${next.toLowerCase()}`)}; window.printPayrollSlip=id=>{const r=payrollRecords.find(x=>String(x.id)===String(id));if(!r)return;const w=workers.find(x=>String(x.id)===String(r.worker_id))||{};const lines=[['Basic pay',r.basic_pay],['Allowances',r.allowances],['Overtime',r.overtime_pay],['Bonus',r.bonus_pay],['Commission',r.commission_pay],['Gross pay',r.gross_pay],['PAYE',-Number(r.paye_amount||0)],['Employee NAPSA',-Number(r.napsa_amount||0)],['NHIMA',-Number(r.nhima_amount||0)],['Advance / loan deduction',-Number(r.advance_deduction||0)],['Other deductions',-Number(r.other_deductions||0)],['NET PAY',r.net_pay]];const win=window.open('','_blank','width=720,height=850');if(!win){appAlert('Allow pop-ups to print the payslip.');return}win.document.write(`Payslip

THE GRATEFUL COLLECTIVE

Employee Payslip

Employee: ${esc(w.full_name||'Worker')}   Job title: ${esc(w.job_title||'')}

Department: ${esc(w.department||'')}   Period: ${esc(r.pay_period||'')}

Status: ${esc(r.payment_status||'Pending Review')}   Payment reference: ${esc(r.payment_reference||'—')}

${lines.map(([label,amount])=>``).join('')}
Pay itemAmount (ZMW)
${esc(label)}${money(Math.abs(Number(amount||0)))}

Statutory amounts must be checked against current requirements. Keep this document confidential.

`);win.document.close()}; $('exportPayroll').onclick=()=>{const rows=[['Pay period','Worker','Job title','Department','Basic pay ZMW','Allowances ZMW','Overtime ZMW','Bonus ZMW','Commission ZMW','Gross pay ZMW','PAYE ZMW','Employee NAPSA ZMW','NHIMA ZMW','Advance deduction ZMW','Other deductions ZMW','Net pay ZMW','Payment method','Payment date','Payment reference','Payment status'],...payrollRecords.map(r=>{const w=workers.find(x=>String(x.id)===String(r.worker_id));return [r.pay_period,w?.full_name||`Worker #${r.worker_id}`,w?.job_title||'',w?.department||'',r.basic_pay,r.allowances,r.overtime_pay,r.bonus_pay,r.commission_pay,r.gross_pay,r.paye_amount,r.napsa_amount,r.nhima_amount,r.advance_deduction,r.other_deductions,r.net_pay,r.payment_method,r.payment_date,r.payment_reference,r.payment_status]})];const data=rows.map(row=>row.map(v=>'"'+String(v??'').replace(/"/g,'""')+'"').join(',')).join('\r\n');const blob=new Blob(['\ufeff'+data],{type:'text/csv;charset=utf-8'}),url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download='grateful-collective-payroll.csv';a.click();URL.revokeObjectURL(url)}; start(); if('serviceWorker' in navigator){window.addEventListener('load',async()=>{try{const reg=await navigator.serviceWorker.register('/service-worker.js',{updateViaCache:'none'});await reg.update();let refreshing=false;navigator.serviceWorker.addEventListener('controllerchange',()=>{if(!refreshing){refreshing=true;location.reload()}});document.addEventListener('visibilitychange',()=>{if(document.visibilityState==='visible')reg.update().catch(()=>{})});window.addEventListener('focus',()=>reg.update().catch(()=>{}))}catch(e){console.warn('App update check unavailable',e)}})} // Period report replacement: date-scoped summaries, with transfer records excluded. function runReport(){ const from=$('from').value,to=$('to').value,inRange=d=>(!from||String(d||'')>=from)&&(!to||String(d||'')<=to); reportData=transactions.filter(t=>inRange(t.date)); const rentRange=rentPayments.filter(r=>inRange(r.payment_date)); const lunch=lunchPayments.filter(r=>inRange(r.payment_date)&&String(r.payment_status||'Paid').toLowerCase()==='paid'); const tests=testPayments.filter(r=>inRange(r.payment_date)&&String(r.payment_status||'Paid').toLowerCase()==='paid'); const payroll=payrollRecords.filter(r=>inRange(r.pay_period)); const payrollPaid=payroll.filter(r=>String(r.payment_status||'').toLowerCase()==='paid'); const payablePaidRange=payablePayments.filter(r=>inRange(r.payment_date)); const schoolTx=reportData.filter(t=>t.area==='School'&&String(t.type).toLowerCase()==='income'); const periodIncome=total(reportData,'Income')+rentRange.reduce((a,r)=>a+Number(r.amount||0),0)+lunch.reduce((a,r)=>a+Number(r.amount||0),0)+tests.reduce((a,r)=>a+Number(r.amount||0),0); const periodExpenses=total(reportData,'Expense')+payablePaidRange.reduce((a,r)=>a+Number(r.amount||0),0)+payrollPaid.reduce((a,r)=>a+Number(r.net_pay||0),0); $('rIn').textContent=money(periodIncome);$('rOut').textContent=money(periodExpenses);$('rNet').textContent=money(periodIncome-periodExpenses); $('rExpensesSummary').textContent=money(total(reportData,'Expense'));$('rSchoolExpenses').textContent=money(reportData.filter(t=>t.area==='School'&&String(t.type).toLowerCase()==='expense').reduce((a,t)=>a+Number(t.amount||0),0));if($('rSchoolExpensesOps'))$('rSchoolExpensesOps').textContent=money(reportData.filter(t=>t.area==='School'&&String(t.type).toLowerCase()==='expense').reduce((a,t)=>a+Number(t.amount||0),0)); $('rRentalsSummary').textContent=money(rentRange.reduce((a,r)=>a+Number(r.amount||0),0)); $('rShopsSummary').textContent=money(reportData.filter(t=>t.area==='Shop'&&String(t.type).toLowerCase()==='income').reduce((a,t)=>a+Number(t.amount||0),0)); $('rPayablesSummary').textContent=money(payables.filter(p=>inRange(p.due_date)).reduce((a,p)=>a+payableBalance(p),0)); $('reportRows').innerHTML=tbl(txTable(reportData,false),'No transactions for this period.'); $('rLunch').textContent=money(lunch.reduce((a,r)=>a+Number(r.amount||0),0));$('rTestFees').textContent=money(tests.reduce((a,r)=>a+Number(r.amount||0),0));$('rPayroll').textContent=money(payroll.reduce((a,r)=>a+Number(r.net_pay||0),0));$('rPeople').textContent=pupils.length+' / '+workers.length; const schoolRows=[['Type','Date','Name / description','Amount (ZMW)','Status']]; schoolTx.forEach(r=>schoolRows.push(['School payment',r.date,esc(r.description||r.category||'School receipt'),Number(r.amount||0),esc(r.type||'Income')])); reportData.filter(r=>r.area==='School'&&String(r.type).toLowerCase()==='expense').forEach(r=>schoolRows.push(['School expense',r.date,esc((r.category||'School expense')+' — '+(r.description||'')),-Number(r.amount||0),'Paid / recorded'])); lunch.forEach(r=>{const p=pupils.find(x=>String(x.id)===String(r.pupil_id));schoolRows.push(['Lunch payment',r.payment_date,esc(p?.full_name||('Pupil #'+r.pupil_id)),Number(r.amount||0),'Paid'])}); tests.forEach(r=>{const t=schoolTests.find(x=>String(x.id)===String(r.test_id)),p=pupils.find(x=>String(x.id)===String(r.pupil_id));schoolRows.push(['Test fee',r.payment_date,esc((p?.full_name||('Pupil #'+r.pupil_id))+' — '+(t?.test_name||('Test #'+r.test_id))),Number(r.amount||0),'Paid'])}); payroll.forEach(r=>{const w=workers.find(x=>String(x.id)===String(r.worker_id));schoolRows.push(['Payroll',r.pay_period,esc(w?.full_name||('Worker #'+r.worker_id)),Number(r.net_pay||0),esc(r.payment_status||'Pending')])}); payablePaidRange.forEach(r=>{const p=payables.find(x=>String(x.id)===String(r.payable_id)),s=suppliers.find(x=>String(x.id)===String(p?.supplier_id));schoolRows.push(['Supplier payment',r.payment_date,esc((s?.name||'Supplier')+' — '+(p?.invoice_number||'')),Number(r.amount||0),'Paid'])}); $('schoolReportRows').innerHTML=tbl(schoolRows,'No school records for this period.'); } $('run').onclick=runReport;