// web/js/payroll.js // // The money: entities, employees, pay runs, the day-by-day hours grid, and // payslip PDFs. // // Imports people.js for the termination drawer, which is opened from the // employee list. people.js imports only core.js, so there is no cycle. import { CONFIG, supabase, state, $, icons, esc, money, toast, api, avatar, statusPill, stat, empty, errorBox, skeleton, pageHead, screen, openDrawer, closeDrawer, ensureEmployers, entCard, renderPicker, DOW, periodDays, defaultPenalty, LEAVE_LABEL, hrs, days, hooks, setActive, } from './core.js'; import { openTerminationDrawer } from './people.js'; async function renderDashboard() { $('content').innerHTML = pageHead('Dashboard', 'Where things stand right now.') + skeleton(4); icons(); try { const employers = await ensureEmployers(); let employees = [], payRuns = []; if (state.employer) { [{ employees }, { pay_runs: payRuns }] = await Promise.all([ api(`/employees?employer_id=${state.employer.id}`), api(`/pay-runs?employer_id=${state.employer.id}`), ]); } const active = employees.filter((e) => e.status === 'active'); const drafts = payRuns.filter((r) => r.status === 'draft' || r.status === 'calculated'); const last = payRuns.find((r) => r.status === 'approved' || r.status === 'exported'); let html = pageHead('Dashboard', state.employer ? state.employer.legal_name : 'Select an entity to see its figures.'); html += `
${stat('Entities', String(employers.length), employers.length === 1 ? 'One employer' : 'Employers you can access', 'building-2')} ${stat('Active employees', String(active.length), state.employer ? 'In this entity' : 'Pick an entity', 'users')} ${stat('Open pay runs', String(drafts.length), drafts.length ? 'Awaiting approval' : 'Nothing in progress', 'banknote')} ${stat('Last paid', last ? last.pay_date : '—', last ? `${last.pay_cycle} cycle` : 'No approved runs yet', 'calendar-check')}
`; if (!state.employer) { html += empty('building-2', 'Choose an entity', 'Pick the business you want to work on and the figures above will fill in.', 'Go to entities', 'dashPick'); } else if (payRuns.length) { html += `
Recent pay runs
${payRuns.slice(0, 5).map((r) => ``).join('')}
PeriodPay dateCycleStatus
${r.period_start} → ${r.period_end} ${r.pay_date}${esc(r.pay_cycle)} ${statusPill(r.status)}
`; } else { html += empty('banknote', 'No pay runs yet', 'Create the first pay run for this entity to get started.', 'New pay run', 'dashNewRun'); } $('content').innerHTML = html; icons(); $('dashPick')?.addEventListener('click', () => hooks.navigate('entities')); $('dashNewRun')?.addEventListener('click', () => { hooks.navigate('payruns'); setTimeout(openPayRunDrawer, 120); }); document.querySelectorAll('[data-run]').forEach((el) => el.addEventListener('click', () => { hooks.navigate('payruns'); setTimeout(() => openPayRunDetail(el.dataset.run), 60); })); } catch (e) { $('content').innerHTML = pageHead('Dashboard') + errorBox(e.message); icons(); } } /* ============================================================ Entities ============================================================ */ async function renderEntities() { const canCreate = state.me?.platform_admin; $('content').innerHTML = pageHead('Entities', 'The businesses you run payroll for.', canCreate ? '' : '') + skeleton(3); icons(); $('entNew')?.addEventListener('click', openEmployerDrawer); try { const employers = await ensureEmployers(); let html = pageHead('Entities', 'The businesses you run payroll for.', canCreate ? '' + '' : ''); html += employers.length ? `
${employers.map(entCard).join('')}
` : empty('building-2', 'No entities yet', canCreate ? 'Create your first entity to start running payroll.' : 'You have not been given access to an entity yet. Ask your administrator.', canCreate ? 'New entity' : '', 'entNew3'); $('content').innerHTML = html; icons(); ['entNew2', 'entNew3'].forEach((id) => $(id)?.addEventListener('click', openEmployerDrawer)); $('entInvite')?.addEventListener('click', openNewClientDrawer); document.querySelectorAll('[data-emp]').forEach((el) => el.addEventListener('click', () => { state.employer = state.employers.find((x) => x.id === el.dataset.emp); hooks.navigate('employees'); })); } catch (e) { $('content').innerHTML = pageHead('Entities') + errorBox(e.message); icons(); } } /* ============================================================ Employees ============================================================ */ async function renderEmployees() { await ensureEmployers(); if (!state.employer) return renderPicker('Employees', 'Pick an entity to see its people.', 'employees'); $('content').innerHTML = pageHead('Employees', state.employer.legal_name, ` `) + skeleton(2); icons(); $('empBack').addEventListener('click', () => { state.employer = null; hooks.navigate('entities'); }); $('empNew').addEventListener('click', openEmployeeDrawer); try { const { employees } = await api(`/employees?employer_id=${state.employer.id}`); let html = pageHead('Employees', state.employer.legal_name, ` `); html += employees.length ? `
${employees.map((e) => ``).join('')}
NameBasisAwardRateIncome typeStatus
${avatar(e.id, e.first_name, e.last_name, true)}
${esc(e.first_name)} ${esc(e.last_name)}
${esc(e.email || 'no email')}
${esc(({ F: 'Full-time', P: 'Part-time', C: 'Casual', L: 'Labour hire', V: 'Voluntary', D: 'Death benefit', N: 'Non-employee' })[e.employment_basis] || e.employment_type || '—')} ${e.award_code ? esc(e.award_code) + (e.classification ? '
' + esc(e.classification) + '
' : '') : '—'}
${e.base_hourly_rate ? money(e.base_hourly_rate) : '—'} ${esc(e.income_stream_type || 'SAW')} ${statusPill(e.status)} ${e.status === 'active' ? `` : ''}
` : empty('users', 'No employees yet', `Add the first person to ${state.employer.legal_name}.`, 'Add employee', 'empNew3'); $('content').innerHTML = html; icons(); $('empBack2').addEventListener('click', () => { state.employer = null; hooks.navigate('entities'); }); ['empNew2', 'empNew3'].forEach((id) => $(id)?.addEventListener('click', openEmployeeDrawer)); state.curEmployees = employees; document.querySelectorAll('[data-term]').forEach((el) => el.addEventListener('click', () => openTerminationDrawer(employees.find((x) => x.id === el.dataset.term)))); } catch (e) { $('content').innerHTML = pageHead('Employees') + errorBox(e.message); icons(); } } /* generic entity picker used by employees / pay runs / access */ async function renderPayruns() { await ensureEmployers(); if (!state.employer) return renderPicker('Pay runs', 'Pick an entity to see its pay runs.', 'payruns'); $('content').innerHTML = pageHead('Pay runs', state.employer.legal_name, ` `) + skeleton(2); icons(); $('prBack').addEventListener('click', () => { state.employer = null; hooks.navigate('entities'); }); $('prNew').addEventListener('click', openPayRunDrawer); try { const { pay_runs } = await api(`/pay-runs?employer_id=${state.employer.id}`); let html = pageHead('Pay runs', state.employer.legal_name, ` `); html += pay_runs.length ? `
${pay_runs.map((r) => ``).join('')}
PeriodPay dateCycleStatus
${r.period_start} → ${r.period_end} ${r.pay_date}${esc(r.pay_cycle)} ${statusPill(r.status)}
` : empty('calendar-clock', 'No pay runs yet', `Create the first pay run for ${state.employer.legal_name}.`, 'New pay run', 'prNew3'); $('content').innerHTML = html; icons(); $('prBack2').addEventListener('click', () => { state.employer = null; hooks.navigate('entities'); }); ['prNew2', 'prNew3'].forEach((id) => $(id)?.addEventListener('click', openPayRunDrawer)); document.querySelectorAll('[data-pr]').forEach((el) => el.addEventListener('click', () => openPayRunDetail(el.dataset.pr))); } catch (e) { $('content').innerHTML = pageHead('Pay runs') + errorBox(e.message); icons(); } } async function openPayRunDetail(id) { state.payRunId = id; setActive('payruns'); $('content').innerHTML = pageHead('Pay run', state.employer?.legal_name || '', '') + '
' + skeleton(1) + '
'; icons(); $('prBackList').addEventListener('click', renderPayruns); await loadPayRunDetail(); } async function loadPayRunDetail() { try { const [{ pay_run, payslips, timesheets }, { employees }, bal] = await Promise.all([ api(`/pay-runs/get?id=${state.payRunId}`), api(`/employees?employer_id=${state.employer.id}`), api(`/leave/balances?employer_id=${state.employer.id}`).catch(() => null), ]); state.leaveBalances = bal?.available || null; state.curRun = pay_run; state.curSlips = payslips; // The grid only offers ACTIVE employees, but a payslip may still be needed // for someone terminated since the run was paid - keep the full list too. state.allEmployees = employees; state.curEmployees = employees.filter((e) => e.status === 'active'); state.curTs = {}; (timesheets || []).forEach((t) => { state.curTs[`${t.employee_id}|${t.work_date}`] = t; }); renderPayRunDetailBody(); } catch (e) { $('prDetail').innerHTML = errorBox(e.message); icons(); } } /** * Daily ordinary hours used to prefill a fresh pay run. * * Precedence: * 1. the employee's own profile (ordinary_hours_per_week / 5) * 2. the award's standard day * 3. 7.6 - a 38-hour week over five days, the standard Australian full day * * Step 2 is a placeholder. The award registry carries penalty rates, overtime * tiers and classifications, but no standard hours, so nothing populates * award_ordinary_hours_per_day today. The branch is here so an award can supply * it later without touching this logic. */ function ordinaryHoursFor(employee) { const weekly = Number(employee?.ordinary_hours_per_week); if (Number.isFinite(weekly) && weekly > 0) return Math.round((weekly / 5) * 100) / 100; const perDay = Number(employee?.award_ordinary_hours_per_day); if (Number.isFinite(perDay) && perDay > 0) return perDay; return 7.6; } function renderPayRunDetailBody() { const run = state.curRun; const editable = run.status === 'draft' || run.status === 'calculated'; const days = periodDays(run); state.curDays = days; let html = `
${stat('Period', `${run.period_start}
→ ${run.period_end}
`, `${days.length} days`, 'calendar-days')} ${stat('Pay date', `${run.pay_date}`, run.pay_cycle, 'calendar-check')} ${stat('Employees', String(state.curEmployees.length), 'Active in this entity', 'users')} ${stat('Status', `${esc(run.status)}`, editable ? 'Hours can be edited' : 'Locked', 'lock')}
`; html += `
Hours
${editable && state.curEmployees.length ? `
` : ''}
`; // Prefill only when the run has NO hours recorded at all. Once anything has // been saved, a blank day is a deliberate blank day - re-prefilling it would // quietly pay someone for a day they did not work. const fresh = !Object.keys(state.curTs || {}).length; if (!state.curEmployees.length) { html += empty('users', 'No active employees', 'Add employees to this entity before running payroll.', '', ''); } else { for (const e of state.curEmployees) { const total = days.reduce((a, d) => { const ts = state.curTs?.[`${e.id}|${d.iso}`] || {}; return a + (Number(ts.ordinary_hours) || 0) + (Number(ts.overtime_hours) || 0) + (Number(ts.leave_hours) || 0); }, 0); const bal = state.leaveBalances?.[e.id]; html += `
${avatar(e.id, e.first_name, e.last_name, true)}
${esc(e.first_name)} ${esc(e.last_name)}
${e.award_code ? esc(e.award_code) + (e.classification ? ' · ' + esc(e.classification) : '') : 'no award'}
${total.toFixed(2)} h ${bal ? `
leave available — annual ${Number(bal.annual || 0).toFixed(1)}h · personal ${Number(bal.personal || 0).toFixed(1)}h
` : ''}
`; for (const d of days) { const ts = state.curTs?.[`${e.id}|${d.iso}`] || {}; const isWeekend = d.dow === 0 || d.dow === 6; // Weekends are never prefilled: a weekend row carries a penalty rate, // so a stray 7.6 there is an expensive mistake. const prefill = fresh && editable && !isWeekend ? ordinaryHoursFor(e) : ''; const ord = ts.ordinary_hours != null ? ts.ordinary_hours : prefill; const ot = ts.overtime_hours != null ? ts.overtime_hours : ''; const pen = ts.penalty_type || defaultPenalty(d.dow); const ps = (v) => (pen === v ? 'selected' : ''); const lv = ts.leave_hours != null && Number(ts.leave_hours) > 0 ? ts.leave_hours : ''; const lt = ts.leave_type || ''; const ls = (v) => (lt === v ? 'selected' : ''); const weekend = isWeekend; html += ``; } html += `
DayOrdinaryOvertimePenalty LeaveLeave type
${d.label} ${d.iso.slice(8)}/${d.iso.slice(5, 7)}
`; } } if (editable && state.curEmployees.length) { html += `
`; } html += `
`; if (state.curSlips && state.curSlips.length) { const t = state.curSlips.reduce((a, s) => ({ gross: a.gross + +s.gross, ote: a.ote + +s.ote, payg: a.payg + +s.payg, stsl: a.stsl + +s.stsl, sup: a.sup + +s.super_amount, net: a.net + +s.net, }), { gross: 0, ote: 0, payg: 0, stsl: 0, sup: 0, net: 0 }); html += `
${stat('Gross', money(t.gross), '', 'coins')} ${stat('PAYG + STSL', money(t.payg + t.stsl), '', 'landmark')} ${stat('Super', money(t.sup), '', 'piggy-bank')} ${stat('Net pay', money(t.net), 'Total to bank', 'banknote')}
`; html += `
Payslips
${state.curSlips.map((s) => ``).join('')}
EmployeeGrossOTEPAYGSTSLSuperNet
${esc(s.employees?.first_name || '')} ${esc(s.employees?.last_name || '')} ${money(s.gross)}${money(s.ote)}${money(s.payg)} ${money(s.stsl)}${money(s.super_amount)} ${money(s.net)}
Totals ${money(t.gross)}${money(t.ote)} ${money(t.payg)}${money(t.stsl)}${money(t.sup)} ${money(t.net)}
`; html += `
`; if (run.status === 'calculated') html += ``; if (run.status === 'approved' || run.status === 'exported') html += ``; html += ``; html += `
`; } $('prDetail').innerHTML = html; icons(); $('prCalc')?.addEventListener('click', savePayRunAndCalc); $('prApprove')?.addEventListener('click', approveRun); $('prExport')?.addEventListener('click', openExportDrawer); $('prSlips')?.addEventListener('click', downloadAllPayslips); document.querySelectorAll('[data-slip]').forEach((el) => el.addEventListener('click', () => downloadPayslip(el.dataset.slip))); $('tsFill')?.addEventListener('click', fillWeekdays); document.querySelectorAll('[data-ts][data-f="ord"], [data-ts][data-f="ot"], [data-ts][data-f="lv"]') .forEach((el) => el.addEventListener('input', () => recomputeTotal(el.dataset.ts))); // The header totals are rendered from saved data, so refresh them once the // prefilled inputs are in the DOM. for (const e of state.curEmployees) recomputeTotal(e.id); // Entering leave hours without a type is the easiest mistake to make here. document.querySelectorAll('[data-ts][data-f="lv"]').forEach((el) => el.addEventListener('change', () => { const sel = document.querySelector(`[data-ts="${el.dataset.ts}"][data-d="${el.dataset.d}"][data-f="lvt"]`); if (sel && Number(el.value) > 0 && !sel.value) sel.value = 'annual'; })); } function fillWeekdays() { const v = parseFloat($('tsBulk').value); if (!v || v <= 0) { toast('Enter an hours value first', 'err'); return; } for (const e of state.curEmployees) { for (const d of state.curDays) { if (d.dow === 0 || d.dow === 6) continue; const el = document.querySelector(`[data-ts="${e.id}"][data-d="${d.iso}"][data-f="ord"]`); if (el && !el.value) el.value = v; } recomputeTotal(e.id); } } function recomputeTotal(employeeId) { let t = 0; document.querySelectorAll(`[data-ts="${employeeId}"][data-f="ord"], [data-ts="${employeeId}"][data-f="ot"], [data-ts="${employeeId}"][data-f="lv"]`) .forEach((el) => { t += parseFloat(el.value) || 0; }); const out = $(`tsTot-${employeeId}`); if (out) out.textContent = t.toFixed(2) + ' h'; } async function savePayRunAndCalc() { const entries = []; for (const e of state.curEmployees) { for (const d of state.curDays) { const sel = (f) => document.querySelector(`[data-ts="${e.id}"][data-d="${d.iso}"][data-f="${f}"]`); const ord = parseFloat(sel('ord')?.value) || 0; const ot = parseFloat(sel('ot')?.value) || 0; const lv = parseFloat(sel('lv')?.value) || 0; const lvt = sel('lvt')?.value || ''; if (ord + ot + lv <= 0) continue; if (ord + ot + lv > 24) { toast(`More than 24 hours on ${d.iso} for ${e.first_name}`, 'err'); return; } if (lv > 0 && !lvt) { toast(`Leave hours on ${d.iso} for ${e.first_name} need a leave type`, 'err'); return; } entries.push({ employee_id: e.id, work_date: d.iso, ordinary_hours: ord, overtime_hours: ot, leave_hours: lv, leave_type: lv > 0 ? lvt : null, penalty_type: sel('pen')?.value || 'ordinary', }); } } if (!entries.length) { toast('No hours entered', 'err'); return; } const btn = $('prCalc'); btn.disabled = true; const old = btn.innerHTML; btn.innerHTML = 'Calculating…'; icons(); try { await api('/pay-runs/timesheets', { method: 'POST', body: JSON.stringify({ pay_run_id: state.payRunId, entries }) }); const res = await api('/pay-runs/calculate', { method: 'POST', body: JSON.stringify({ pay_run_id: state.payRunId }) }); toast(`Calculated ${res.totals?.employees || res.payslips?.length || 0} payslip(s)`); if (res.skipped && res.skipped.length) toast(`${res.skipped.length} skipped — check award and hours`, 'err'); if (res.leave?.posted?.length) toast(`Leave accrued for ${new Set(res.leave.posted.map((x) => x.employee_id)).size} employee(s)`); if (res.leave?.taken?.length) toast(`Leave paid and deducted for ${new Set(res.leave.taken.map((x) => x.employee_id)).size} employee(s)`); (res.leave?.leaveWarnings || []).forEach((w) => toast(w.message, 'err')); await loadPayRunDetail(); } catch (e) { toast(e.message, 'err'); btn.disabled = false; btn.innerHTML = old; icons(); } } async function approveRun() { try { await api('/pay-runs/approve', { method: 'POST', body: JSON.stringify({ pay_run_id: state.payRunId }) }); toast('Pay run approved'); await loadPayRunDetail(); } catch (e) { toast(e.message, 'err'); } } /* ============================================================ Users & access ============================================================ */ function openEmployerDrawer() { openDrawer('New entity', `
`, async () => { const name = $('e_name').value.trim(); if (!name) throw new Error('Legal name is required'); await api('/employers', { method: 'POST', body: JSON.stringify({ legal_name: name, abn: $('e_abn').value.trim() || null, branch_code: $('e_branch').value.trim() || '001', accounting_target: $('e_target').value || null, default_pay_cycle: $('e_cycle').value, payer_contact_name: $('e_contact').value.trim() || null, payer_email: $('e_email').value.trim() || null, payer_phone: $('e_phone').value.trim() || null, }) }); toast('Entity created'); state.employers = []; renderEntities(); }); } function openEmployeeDrawer() { openDrawer('Add employee', `
`, async () => { const first = $('p_first').value.trim(), last = $('p_last').value.trim(); if (!first || !last) throw new Error('First and last name are required'); await api('/employees', { method: 'POST', body: JSON.stringify({ employer_id: state.employer.id, first_name: first, last_name: last, email: $('p_email').value.trim() || null, date_of_birth: $('p_dob').value || null, start_date: $('p_start').value || null, employment_type: $('p_type').value, employment_basis: $('p_basis').value || undefined, income_stream_type: $('p_stream').value, residency_status: $('p_res').value, tfn: $('p_tfn').value.trim() || undefined, tax_free_threshold: $('p_tft').checked, has_help_debt: $('p_help').checked, award_code: $('p_award').value.trim() || null, classification: $('p_class').value.trim() || null, base_hourly_rate: parseFloat($('p_rate').value) || 0, bsb: $('p_bsb').value.trim() || null, account_number: $('p_acct').value.trim() || null, account_name: $('p_acctname').value.trim() || null, }) }); toast('Employee added'); renderEmployees(); }); } function openPayRunDrawer() { openDrawer('New pay run', `

Hours are entered day by day once the run is created, so weekend and public-holiday penalties apply correctly.

`, async () => { const start = $('pr_start').value, end = $('pr_end').value, paid = $('pr_paid').value; if (!start || !end || !paid) throw new Error('All three dates are required'); const { id } = await api('/pay-runs', { method: 'POST', body: JSON.stringify({ employer_id: state.employer.id, period_start: start, period_end: end, pay_date: paid, pay_cycle: $('pr_cycle').value, }) }); toast('Pay run created'); openPayRunDetail(id); }); setTimeout(() => { const s = $('pr_cycle'); if (s && state.employer) s.value = state.employer.default_pay_cycle || 'fortnightly'; }, 0); } /** * Show a freshly issued code. This is the ONLY time it is visible - the server * stores a hash, so it cannot be looked up again. Reissue if it is lost. */ function openExportDrawer() { openDrawer('Export ABA file', `

The account wages are paid from, plus your bank's ABA header fields. Each employee is paid their net.

`, async () => { const res = await api('/pay-runs/export-aba', { method: 'POST', body: JSON.stringify({ pay_run_id: state.payRunId, header: { bankAbbrev: $('ab_bank').value.trim().toUpperCase(), userId: $('ab_uid').value.trim(), userName: $('ab_uname').value.trim(), description: $('ab_ref').value.trim() || 'PAYROLL', }, payer: { bsb: $('ab_bsb').value.trim(), account: $('ab_acct').value.trim(), name: $('ab_uname').value.trim() }, }) }); if (res.missing && res.missing.length) toast(`${res.missing.length} employee(s) missing bank details`, 'err'); const blob = new Blob([res.aba], { type: 'text/plain' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `payroll-${state.curRun.pay_date}.aba`; a.click(); URL.revokeObjectURL(a.href); toast('ABA file downloaded'); await loadPayRunDetail(); }); } /* ============================================================ Leave ============================================================ */ function payslipPdf(slip, employee, run, employer, leave) { const { jsPDF } = window.jspdf; const doc = new jsPDF({ unit: 'pt', format: 'a4' }); const L = 48, R = 547; let y = 56; const line = (yy) => { doc.setDrawColor(220, 220, 214); doc.line(L, yy, R, yy); }; const label = (t, x, yy) => { doc.setFontSize(7.5); doc.setTextColor(120); doc.text(String(t).toUpperCase(), x, yy); }; const value = (t, x, yy, size = 10, bold = false) => { doc.setFontSize(size); doc.setTextColor(30); doc.setFont('helvetica', bold ? 'bold' : 'normal'); doc.text(String(t), x, yy); doc.setFont('helvetica', 'normal'); }; const rightVal = (t, yy, bold = false) => { doc.setFontSize(10); doc.setTextColor(30); doc.setFont('helvetica', bold ? 'bold' : 'normal'); doc.text(String(t), R, yy, { align: 'right' }); doc.setFont('helvetica', 'normal'); }; doc.setFont('helvetica', 'bold'); doc.setFontSize(17); doc.setTextColor(11, 118, 174); doc.text('Payslip', L, y); doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(110); doc.text(employer?.legal_name || '', R, y - 10, { align: 'right' }); if (employer?.abn) doc.text('ABN ' + employer.abn, R, y + 2, { align: 'right' }); y += 16; line(y); y += 22; label('Employee', L, y); label('Pay period', 250, y); label('Date of payment', 410, y); y += 13; value(`${employee.first_name} ${employee.last_name}`, L, y, 10, true); value(`${run.period_start} to ${run.period_end}`, 250, y); value(run.pay_date, 410, y); y += 24; line(y); y += 22; label('Earnings', L, y); doc.text('AMOUNT', R, y, { align: 'right' }); y += 15; const rate = Number(employee.base_hourly_rate || 0); const bd = slip.breakdown || {}; const paidLeave = bd.leave || {}; const paidLeaveHours = bd.leaveHours || {}; const leaveLoading = Number(bd.leaveLoading || 0); const leaveTotal = Object.values(paidLeave).reduce((a, v) => a + Number(v || 0), 0) + leaveLoading; // Ordinary earnings excludes overtime and any paid leave, so leave can be // shown on its own line - Fair Work requires it to be itemised when paid. const ordinaryEarnings = Number(slip.gross) - Number(slip.non_ote || 0) - leaveTotal; value(`Ordinary earnings${rate ? ` — base rate ${money(rate)}/hr` : ''}`, L, y); rightVal(money(ordinaryEarnings), y); y += 15; const LEAVE_NAMES = { annual: 'Annual leave', personal: "Personal/carer's leave", long_service: 'Long service leave' }; for (const [type, amount] of Object.entries(paidLeave)) { if (!Number(amount)) continue; const h = Number(paidLeaveHours[type] || 0); value(`${LEAVE_NAMES[type] || type}${h ? ` — ${h.toFixed(2)} hrs` : ''}`, L, y); rightVal(money(amount), y); y += 15; } if (leaveLoading > 0) { value('Annual leave loading', L, y); rightVal(money(leaveLoading), y); y += 15; } if (Number(slip.non_ote) > 0) { value('Overtime and other non-OTE', L, y); rightVal(money(slip.non_ote), y); y += 15; } y += 4; line(y); y += 15; value('Gross pay', L, y, 10, true); rightVal(money(slip.gross), y, true); y += 24; label('Deductions', L, y); y += 15; value('PAYG withholding', L, y); rightVal('-' + money(slip.payg), y); y += 15; if (Number(slip.stsl) > 0) { value('Study and training loan (STSL)', L, y); rightVal('-' + money(slip.stsl), y); y += 15; } y += 4; line(y); y += 17; doc.setFillColor(226, 240, 248); doc.rect(L, y - 13, R - L, 22, 'F'); value('Net pay', L + 8, y + 2, 11, true); doc.setFont('helvetica', 'bold'); doc.setFontSize(11); doc.setTextColor(8, 88, 127); doc.text(money(slip.net), R - 8, y + 2, { align: 'right' }); doc.setFont('helvetica', 'normal'); y += 36; label('Superannuation', L, y); y += 15; value('Employer contribution (paid on top of wages)', L, y); rightVal(money(slip.super_amount), y); y += 14; const fund = employee.super_fund || {}; if (fund.name) { doc.setFontSize(8); doc.setTextColor(120); doc.text(`Fund: ${fund.name}${fund.usi ? ' · USI ' + fund.usi : ''}`, L, y); y += 12; } y += 10; // Leave. A closing figure on its own is not much use - an employee checking a // payslip wants to see what moved: what they started with, what they earned // this period, what they used, and what is left. label('Leave', L, y); y += 4; line(y); y += 14; if (leave?.notApplicable) { doc.setFontSize(9); doc.setTextColor(90); doc.text(leave.notApplicable, L, y); y += 20; } else if (leave?.rows?.length) { const cols = [L, 210, 300, 385, 470]; doc.setFontSize(7.5); doc.setTextColor(120); ['Leave type', 'Opening', 'Accrued', 'Taken', 'Closing'].forEach((h, i) => { doc.text(h.toUpperCase(), i === 0 ? cols[i] : cols[i] + 60, y, { align: i === 0 ? 'left' : 'right' }); }); y += 13; for (const r of leave.rows) { doc.setFontSize(9.5); doc.setTextColor(30); doc.text(r.label, cols[0], y); doc.setFont('helvetica', 'normal'); doc.text(r.opening.toFixed(2), cols[1] + 60, y, { align: 'right' }); doc.text((r.accrued > 0 ? '+' : '') + r.accrued.toFixed(2), cols[2] + 60, y, { align: 'right' }); doc.text(r.taken > 0 ? '-' + r.taken.toFixed(2) : '0.00', cols[3] + 60, y, { align: 'right' }); doc.setFont('helvetica', 'bold'); doc.text(r.closing.toFixed(2), cols[4] + 60, y, { align: 'right' }); doc.setFont('helvetica', 'normal'); y += 14; } doc.setFontSize(7.5); doc.setTextColor(140); doc.text('Balances are shown in hours as at the end of this pay period.', L, y + 2); y += 20; } else { doc.setFontSize(9); doc.setTextColor(150); doc.text(leave?.error || 'Leave balances were unavailable when this payslip was produced.', L, y); y += 20; } doc.setFontSize(7.5); doc.setTextColor(140); doc.text('Produced by Payflow. Retain for your records. Superannuation is paid by the employer in addition to wages.', L, 800); return doc; } /** * Leave movement for one employee over one pay period, built from the ledger. * * Closing is taken as at period_end rather than "now", so reprinting an old * payslip shows what the balance actually was at the time, not today's figure. */ async function leaveForPayslip(employee, run) { if (employee.employment_type === 'casual') { return { notApplicable: 'Casual employment — no paid leave accrues. A casual loading is paid instead.' }; } let ledger; try { ({ ledger } = await api(`/leave/ledger?employee_id=${employee.id}`)); } catch (e) { // Surface it rather than printing a payslip that quietly omits leave. toast(`Leave balances unavailable: ${e.message}`, 'err'); return { error: `Leave balances could not be read (${e.message}).` }; } const inPeriod = (t) => t.pay_run_id === run.id || (t.effective_on >= run.period_start && t.effective_on <= run.period_end); const TYPES = [ ['annual', 'Annual leave'], ['personal', "Personal/carer's leave"], ['long_service', 'Long service leave'], ]; const rows = []; for (const [type, label] of TYPES) { const mine = ledger.filter((t) => t.leave_type === type); const closing = mine .filter((t) => t.effective_on <= run.period_end) .reduce((a, t) => a + Number(t.hours), 0); const moved = mine.filter(inPeriod); const accrued = moved.filter((t) => Number(t.hours) > 0).reduce((a, t) => a + Number(t.hours), 0); const taken = Math.abs(moved.filter((t) => Number(t.hours) < 0).reduce((a, t) => a + Number(t.hours), 0)); const opening = closing - accrued + taken; // Long service only appears once there is something to report. if (type === 'long_service' && closing === 0 && accrued === 0 && taken === 0) continue; rows.push({ label, opening, accrued, taken, closing }); } return { rows }; } async function downloadPayslip(employeeId) { try { const slip = state.curSlips.find((s) => s.employee_id === employeeId); const employee = (state.allEmployees || state.curEmployees).find((e) => e.id === employeeId); if (!slip || !employee) throw new Error('Payslip not found'); const leave = await leaveForPayslip(employee, state.curRun); const doc = payslipPdf(slip, employee, state.curRun, state.employer, leave); doc.save(`payslip-${employee.last_name}-${state.curRun.pay_date}.pdf`); toast('Payslip downloaded'); } catch (e) { toast(e.message, 'err'); } } async function downloadAllPayslips() { let n = 0; for (const s of state.curSlips) { await downloadPayslip(s.employee_id); n++; } if (n) toast(`${n} payslip(s) downloaded`); } /* ============================================================ Security ============================================================ */ export { ordinaryHoursFor, renderDashboard, renderEntities, renderEmployees, renderPayruns, openPayRunDetail, loadPayRunDetail, renderPayRunDetailBody, savePayRunAndCalc, approveRun, openEmployerDrawer, openEmployeeDrawer, openPayRunDrawer, openExportDrawer, payslipPdf, leaveForPayslip, downloadPayslip, downloadAllPayslips, ordinaryHoursFor };