// 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
Period
Pay date
Cycle
Status
${payRuns.slice(0, 5).map((r) => `
${r.period_start} → ${r.period_end}
${r.pay_date}
${esc(r.pay_cycle)}
${statusPill(r.status)}
`).join('')}
`;
} 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 ? `
';
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 += `
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 };