Workflow-App/Workflow/components/ComplianceTreeGrid.tsx

1672 lines
67 KiB
TypeScript
Raw Normal View History

// components/ComplianceTreeGrid.tsx
// Expandable tree grid — each child row (Annual Returns, Income Tax,
// Provisional Tax, PAYE EMP201) expands independently to show the last 4
// submissions with date, amount and due date.
//
// SARSL COMPLIANCE RULES (as explained by SJ Coetzee, 2026-08-31):
//
// ANNUAL RETURNS
// - Due every year on the REGISTRATION ANNIVERSARY (day + month of registration date)
// e.g. registered 2026/05/19 → first AR due 2027/05/19, next 2028/05/19
// - Late: 30 days after due date
// - For new companies (registered this year): no prior filings exist — show
// "No prior filings. Next due: [anniversary date next year]"
//
// INCOME TAX RETURNS
// - Due: last day of the 10th month after FY end
// e.g. FY end February → due Nov 30 of that year (FY2027 → Nov 2027)
// - Late: day after the due date
// - For companies registered AFTER the FY end month: no return exists for that year;
// first return is the one for the FY year that ends AFTER registration
// e.g. registered May 2026, FY Feb → no return for FY Feb 2026 (already passed);
// first return = FY Feb 2027 (due Nov 2027)
//
// PROVISIONAL TAX (IRP6)
// - Two periods per FY: PVP1 (6 months after FY end) and PVP2 (FY end day)
// - Cut-off rule: if registered AFTER the first provisional period end date,
// the company was NOT in existence then — skip PVP1, start from PVP2
// e.g. FY end February → first provisional = Aug 31
// Company registered May 2026 → registered before Aug 31 → liable for PVP1 Aug 2026
// Company registered September 2026 → registered after Aug 31 → skip PVP1, only PVP2
// - Late PVP1: day after the due date
// - Late PVP2: day after the due date
//
// STATUS COLOURS (for dot)
// - GREEN: no incomplete/past-due submissions; all deadlines > 30 days away
// - ORANGE: deadline within 1-30 days (upcoming but not yet overdue)
// - BLUE: at least one submission is past its due date and not yet filed
// - GREY: not applicable (only used by PAYE — company has no SARS PAYE registration)
"use client";
import { useState } from "react";
// ─── Types ──────────────────────────────────────────────────────────
type Company = {
id: string;
enterprise_name: string;
enterprise_no: string;
registration_date: string;
financial_year_end_month: number;
groups: string[];
/** SARS PAYE reference number (e.g. "7760830689"). Empty/null → no PAYE registration. */
sars_paye_no?: string | null;
/** SARS VAT reference number (e.g. "4123456789"). Empty/null → no VAT registration. */
sars_vat_no?: string | null;
/** VAT cadence: 0=None, 1=Every Month, 2=Even Months, 3=Uneven Months, 6=Bi-Annually, 12=Annually. */
sars_vat_returns_period?: number | null;
};
type AnnualReturn = {
id: string;
company: string;
ar_year: number;
ar_month: string;
is_filed: boolean;
date_filed: string;
amount_paid: number;
// Optional: the /api/compliance payload only includes it when the
// PocketBase collection has the field. Unused in this component.
ar_non_compliance_date?: string;
};
type IRP6Record = {
id: string;
company: string;
return_type: string; // "IRP6" | "IncomeTax"
period: string; // "YYYYMM"
date_filed: string;
amount_paid: number;
};
type PayeRecord = {
id: string;
company: string;
period: string; // "YYYYMM" (e.g. "202609" = September 2026)
Return_Filed: boolean; // SOURCE OF TRUTH — see notes in lib/types.ts
total_payable: number;
paye_amount: number;
sdl_amount: number;
uif_amount: number;
date_submitted: string; // ISO date or empty
date_paid: string;
due_date: string;
payment_reference: string;
status: string; // "submitted" | "draft" | ...
last_updated: string; // ISO date or empty — set by push-to-PB script
};
type VatRecord = {
id: string;
company: string;
period: string; // "YYYYMM" — depends on company's VAT cadence
Return_Filed: boolean; // SOURCE OF TRUTH — see notes in lib/types.ts
total_payable: number;
date_submitted: string;
date_paid: string;
due_date: string;
payment_reference: string;
status: string;
last_updated: string;
};
// ─── Date helpers ────────────────────────────────────────────────────
const MONTH_MAP: Record<string, number> = {
JANUARY: 1, FEBRUARY: 2, MARCH: 3, APRIL: 4,
MAY: 5, JUNE: 6, JULY: 7, AUGUST: 8,
SEPTEMBER: 9, OCTOBER: 10, NOVEMBER: 11, DECEMBER: 12,
};
const MONTH_NAMES = [
"", "January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"
];
function lastDayOf(year: number, month: number): Date {
return new Date(year, month, 0);
}
function addDays(date: Date, days: number): Date {
const d = new Date(date);
d.setDate(d.getDate() + days);
return d;
}
function addMonths(date: Date, months: number): Date {
return new Date(date.getFullYear(), date.getMonth() + months, 1);
}
function safeAnniversary(regDate: Date, yearsLater: number): Date {
// Registration anniversary, e.g. registered May 19 → due May 19 next year
const y = regDate.getFullYear() + yearsLater;
const m = regDate.getMonth();
const d = regDate.getDate();
// Handle Feb 29 → Feb 28 in non-leap years
const maxDayInMonth = new Date(y, m + 1, 0).getDate();
const safeDay = Math.min(d, maxDayInMonth);
return new Date(y, m, safeDay);
}
function formatDate(date: Date | null): string {
if (!date) return "—";
return date.toLocaleDateString("en-ZA", { day: "2-digit", month: "short", year: "numeric" });
}
function formatPeriod(period: string): string {
if (period.length === 6) {
const y = period.substring(0, 4);
const m = parseInt(period.substring(4, 6));
const monthName = MONTH_NAMES[m] || "";
return `${monthName} ${y}`;
}
return period;
}
function parsePeriod(period: string): { year: number; month: number } | null {
if (period.length === 6) {
return { year: parseInt(period.substring(0, 4)), month: parseInt(period.substring(4, 6)) };
}
return null;
}
function daysBetween(a: Date, b: Date): number {
return Math.round((b.getTime() - a.getTime()) / (1000 * 60 * 60 * 24));
}
/**
* Determine which FY year label applies based on the FY end month and current date.
* FY year = the calendar year in which the FY ends.
* e.g. FY end Feb 2027 → FY year = 2027
* In April 2026 (before Feb 2026 has passed) → FY year = 2027
* In March 2026 (after Feb 2026 has passed) → FY year = 2026
*/
function getCurrentFYYear(fyEndMonth: number): number {
const now = new Date();
const currentYear = now.getFullYear();
const currentMonth = now.getMonth() + 1; // 1-based
// Find the most recent FY year that hasn't fully passed yet
// If current month > fyEndMonth, we're in the FY that ended in that month of currentYear
// If current month <= fyEndMonth, we're still in the FY that ends in that month of nextYear
if (currentMonth > fyEndMonth) {
return currentYear;
} else {
return currentYear + 1;
}
}
// ─── Status ─────────────────────────────────────────────────────────
type Status = "green" | "orange" | "blue" | "red" | "grey";
/**
* Status is determined by the EARLIEST incomplete or past-due submission deadline.
* - earliestDueDate: the due date of the earliest submission that is either not yet
* filed OR whose late date has passed (i.e. it was filed late).
* Pass null when there are no submissions at all.
* - fyConfigured: whether the company has a financial year month set.
* If false and there are unfiled submissions, show orange (incomplete setup).
* - nextDeadline: the next upcoming deadline even when there are no records.
* Used to detect missed deadlines for companies with zero filings on record.
*/
function getStatusColor(earliestDueDate: Date | null, fyConfigured: boolean = true, nextDeadline: Date | null = null): Status {
if (!earliestDueDate) {
// No records on file — check if the next deadline has already passed
if (!fyConfigured) return "orange"; // FY not set — incomplete setup, can't track
if (nextDeadline) {
const today = new Date();
today.setHours(0, 0, 0, 0);
if (nextDeadline < today) return "blue"; // deadline was missed with no filing
return "orange"; // deadline approaching, no records = outstanding
}
return "green"; // no filings exist and nothing is due yet
}
const today = new Date();
today.setHours(0, 0, 0, 0);
const daysToDue = daysBetween(today, earliestDueDate);
if (daysToDue <= 0) return "blue"; // past due or due today — must be resolved
if (daysToDue <= 30) return "orange"; // coming up soon
return "green"; // more than 30 days away
}
function isFiled(record: { date_filed: string | null | undefined }, field: "date_filed"): boolean {
const val = record[field];
return !!val && val.trim() !== "";
}
// ─── Annual Returns calculations ───────────────────────────────────
type ARSubmission = {
ar_year: number;
dueDate: Date;
lateDate: Date;
filed: boolean;
filedDate: Date | null;
amount: number;
};
/**
* Build a list of up to 4 AR submissions (past and next upcoming).
* For a newly registered company (registered this calendar year):
* - No prior filings; next AR is due on registration anniversary next year.
* For an established company:
* - Show up to 4 most recent filed/outstanding ARs.
*
* Returns:
* - subs: the submission rows to display
* - nextDueDate: the next upcoming AR deadline (for display)
* - earliestDueDate: the earliest due date of any incomplete submission
* (used for status dot — prioritises the most overdue)
* - nextDueYear, nextLateDate: for display
*/
function buildARSubmissions(company: Company, records: AnnualReturn[]): {
subs: ARSubmission[];
nextDueDate: Date | null;
earliestDueDate: Date | null; // earliest incomplete/past-due deadline for status
nextLateDate: Date | null;
nextDueYear: number;
} {
const regDate = new Date(company.registration_date);
const regYear = regDate.getFullYear();
const regMonth = regDate.getMonth() + 1; // 1-based
const today = new Date();
const currentYear = today.getFullYear();
// Determine the first AR year that could have existed:
// AR year must be >= registration year
const firstPossibleYear = regYear;
// Find the most recent completed AR year:
// If we're past the anniversary date this year, the most recent AR year = currentYear
// Otherwise it's currentYear - 1
const anniversaryPassed = today >= safeAnniversary(regDate, 0);
const mostRecentYear = anniversaryPassed ? currentYear : currentYear - 1;
// Build submissions for years: mostRecentYear down to firstPossibleYear (max 4)
// IMPORTANT: skip years before the registration year (new company — no filings exist for those years)
const subs: ARSubmission[] = [];
for (let yr = mostRecentYear; yr >= firstPossibleYear && subs.length < 4; yr--) {
// Skip AR years strictly before the registration year
if (yr < regYear) continue;
const due = new Date(yr, regMonth - 1, regDate.getDate());
const late = addDays(due, 30);
// Check if this year has a PB record
const pbRec = records.find(
(r) => r.company === company.id && r.ar_year === yr
);
const filed = pbRec ? isFiled(pbRec, "date_filed") : false;
const filedDate = pbRec?.date_filed
? new Date(pbRec.date_filed)
: null;
const amount = pbRec?.amount_paid || 0;
subs.push({ ar_year: yr, dueDate: due, lateDate: late, filed, filedDate, amount });
}
// Determine next upcoming AR due date (for display)
// If there are no past ARs (new company) OR the next anniversary is in the future:
// next AR = registration anniversary next year
// Otherwise next AR is already in our list
let nextDueYear = mostRecentYear + 1;
if (subs.length > 0 && subs[subs.length - 1].dueDate > today) {
// The oldest entry in our list IS the next upcoming
nextDueYear = subs[subs.length - 1].ar_year;
}
const nextDueDate = safeAnniversary(regDate, nextDueYear - regYear);
const nextLateDate = addDays(nextDueDate, 30);
// Find the EARLIEST incomplete/past-due deadline for status dot.
// For an unfiled submission: the status is determined by the DUE DATE (past due = BLUE).
// For a late-filed submission: the lateDate has been missed, so it is overdue.
// We iterate from oldest to newest — the first unfiled or late-filed period is most urgent.
let earliestDueDate: Date | null = null;
for (const sub of subs) {
if (!sub.filed) {
// Not filed yet — use the due date (not late date) for status
earliestDueDate = sub.dueDate;
break;
}
// Filed — check if filed late
if (sub.filedDate && sub.filedDate > sub.lateDate) {
// Filed late — the late period has been entered, mark overdue
earliestDueDate = sub.lateDate;
break;
}
}
return { subs, nextDueDate, earliestDueDate, nextLateDate, nextDueYear };
}
// ─── Income Tax calculations ────────────────────────────────────────
type ITSubmission = {
period: string;
fyYear: number;
dueDate: Date;
lateDate: Date;
filed: boolean;
filedDate: Date | null;
amount: number;
};
/**
* Income Tax Return:
* - Due: last day of the 10th month after FY end
* e.g. FY end February → due November 30 of that year (FY2027 → Nov 2027)
* - Late: day after the due date
* - New companies: first return is for the FY year that ends AFTER registration
* e.g. registered May 2026, FY Feb → no return for FY Feb 2026 (already passed);
* first return = FY Feb 2027 (due Nov 2027)
*/
function buildITSubmissions(company: Company, records: IRP6Record[]): {
subs: ITSubmission[];
nextDueDate: Date | null;
earliestDueDate: Date | null; // earliest incomplete/past-due deadline for status
nextLateDate: Date | null;
} {
const fyMonth = company.financial_year_end_month;
if (!fyMonth || fyMonth < 1 || fyMonth > 12) {
return { subs: [], nextDueDate: null, earliestDueDate: null, nextLateDate: null };
}
const today = new Date();
const currentFYYear = getCurrentFYYear(fyMonth);
// First possible IT return year: the FY year that ends AFTER registration
const regDate = new Date(company.registration_date);
const regYear = regDate.getFullYear();
const regMonth = regDate.getMonth() + 1;
// An IT return for FY ending in month M can only exist if:
// the FY end date (Feb M) is after the registration date
// e.g. registered May 2026 → FY Feb 2026 is before May 2026 → NO
// FY Feb 2027 is after May 2026 → YES
// The FY year label = year in which the FY ends
const firstPossibleFYYear = regYear + (regMonth > fyMonth ? 1 : 0);
// Most recent completed IT return year
// The IT return for FY year Y is due in year Y, month (fyMonth + 10)
// e.g. FY Feb 2027 → due Nov 2027
// In 2026, the most recent completed return would be FY Feb 2026 (due Nov 2026)
// which is already past
// Actually we show returns that are due (regardless of whether filed or not)
// Find the most recent FY year where the IT return due date has passed
let mostRecentFYYear = currentFYYear - 1; // last completed FY
// But we need to check if it's past the due date
const checkDueMonth = fyMonth + 10;
const checkDueYear = mostRecentFYYear + (checkDueMonth > 12 ? 1 : 0);
const actualDueMonth = checkDueMonth > 12 ? checkDueMonth - 12 : checkDueMonth;
const mostRecentDueDate = lastDayOf(checkDueYear, actualDueMonth);
if (today >= mostRecentDueDate) {
// mostRecentFYYear is actually the most recent
} else {
mostRecentFYYear = mostRecentFYYear - 1;
}
const subs: ITSubmission[] = [];
for (let yr = mostRecentFYYear; yr >= firstPossibleFYYear && subs.length < 4; yr--) {
// Due date: last day of month (fyMonth + 10) in year yr
const dueMonth = fyMonth + 10;
const dueYear = yr + (dueMonth > 12 ? 1 : 0);
const actualDueMonth = dueMonth > 12 ? dueMonth - 12 : dueMonth;
const due = lastDayOf(dueYear, actualDueMonth);
const late = addDays(due, 1); // late day after due date
// PB record: period = "YYYYMM" where MM = fyMonth
const periodStr = `${yr}${String(fyMonth).padStart(2, "0")}`;
const pbRec = records.find(
(r) => r.company === company.id && r.return_type === "IncomeTax" && r.period === periodStr
);
const filed = pbRec ? isFiled(pbRec, "date_filed") : false;
const filedDate = pbRec?.date_filed ? new Date(pbRec.date_filed) : null;
const amount = pbRec?.amount_paid || 0;
subs.push({ period: periodStr, fyYear: yr, dueDate: due, lateDate: late, filed, filedDate, amount });
}
// Next upcoming IT return (first FY year not yet in subs)
const nextFYYear = subs.length > 0 ? subs[subs.length - 1].fyYear + 1 : firstPossibleFYYear;
const nextDueMonth = fyMonth + 10;
const nextDueYear = nextFYYear + (nextDueMonth > 12 ? 1 : 0);
const nextActualDueMonth = nextDueMonth > 12 ? nextDueMonth - 12 : nextDueMonth;
const nextDueDate = lastDayOf(nextDueYear, nextActualDueMonth);
const nextLateDate = addDays(nextDueDate, 1);
// Find the EARLIEST incomplete/past-due deadline for status dot.
// For an unfiled submission: status is past due if the due date has passed (dueDate).
// For a late-filed submission: the lateDate was missed.
let earliestDueDate: Date | null = null;
for (const sub of subs) {
if (!sub.filed) {
earliestDueDate = sub.dueDate; // use due date for status
break;
}
if (sub.filedDate && sub.filedDate > sub.lateDate) {
earliestDueDate = sub.lateDate;
break;
}
}
return { subs, nextDueDate, earliestDueDate, nextLateDate };
}
// ─── Provisional Tax (IRP6) calculations ────────────────────────────
type ProvSubmission = {
period: string; // "YYYY.01" or "YYYY.02"
label: string; // "Aug YYYY" or "Feb YYYY"
fyYear: number; // e.g. 2027
periodNum: 1 | 2;
dueDate: Date;
lateDate: Date;
filed: boolean;
filedDate: Date | null;
amount: number;
liable: boolean; // was company registered before this period's end?
};
/**
* Provisional Tax (IRP6):
* - Two provisional periods per FY:
* PVP1 (period 1): due last day of (fyMonth + 6)th month, late day after
* PVP2 (period 2): due last day of fyMonth, late day after
* - Cut-off rule: if registered AFTER the first provisional period end date,
* the company was NOT in existence → skip PVP1, start from PVP2
* e.g. FY Feb → PVP1 end = Aug 31
* Registered May 2026 → before Aug 31 → liable for PVP1 Aug 2026
* Registered Sep 2026 → after Aug 31 → NOT liable for PVP1 Aug 2026, only PVP2 Feb 2027
* - Late PVP1: day after the due date
* - Late PVP2: day after the due date
* - First year of liability = FY year in which registration falls OR the next FY
*/
function buildProvSubmissions(company: Company, records: IRP6Record[]): {
subs: ProvSubmission[];
nextDueDate: Date | null; // next upcoming deadline (for display)
earliestDueDate: Date | null; // earliest incomplete/past-due deadline (for status dot)
mostRecentPastDeadline: Date | null; // most recent past due date (for zero-record companies)
fyConfigured: boolean; // whether FY month is set
hasUnfiled: boolean; // whether there are unfiled IRP6 records (for status when FY missing)
} {
const fyMonth = company.financial_year_end_month;
const fyConfigured = !!(fyMonth && fyMonth >= 1 && fyMonth <= 12);
// Check for unfiled IRP6 records on raw PB data (needed even when FY not configured)
const hasUnfiled = records.some(r => r.return_type === "IRP6" && !isFiled(r, "date_filed"));
if (!fyConfigured) {
// Has IRP6 records but no FY month — can't compute deadlines meaningfully
return { subs: [], nextDueDate: null, earliestDueDate: null, mostRecentPastDeadline: null, fyConfigured: false, hasUnfiled };
}
const regDate = new Date(company.registration_date);
const regYear = regDate.getFullYear();
const today = new Date();
const currentFYYear = getCurrentFYYear(fyMonth);
// PVP1: last day of (fyMonth + 6)th month in FY year Y
// PVP2: last day of fyMonth in FY year Y
// e.g. FY Feb 2027: PVP1 = Aug 31 2026, PVP2 = Feb 28/29 2027
function pvp1EndDate(fyYear: number): Date {
// PVP1 falls in the calendar year BEFORE the FY ends.
// FY 2027 (ends Feb 2027) → PVP1 = Aug 2026 → year = 2026 = fyYear - 1
const m = fyMonth + 6;
const y = fyYear - 1 + (m > 12 ? 1 : 0);
const actualM = m > 12 ? m - 12 : m;
return lastDayOf(y, actualM);
}
function pvp2EndDate(fyYear: number): Date {
// PVP2 falls in the same calendar year the FY ends.
// FY 2027 (ends Feb 2027) → PVP2 = Feb 2027 → year = fyYear
return lastDayOf(fyYear, fyMonth);
}
// First FY year that could have provisional tax liability:
// The registration must be before the PVP1 end date to be liable for that year
// Find the earliest FY year where the company was registered before PVP1 end
let firstLiableFYYear = currentFYYear;
// Walk backward to find the first year
for (let yr = currentFYYear - 1; yr >= 1; yr--) {
if (regDate < pvp1EndDate(yr)) {
firstLiableFYYear = yr;
} else {
break; // earlier years also don't apply
}
}
// Build submissions: most recent completed first, up to 4
// Within each year, PVP1 (Aug) comes before PVP2 (Feb) chronologically
const subs: ProvSubmission[] = [];
for (let yr = currentFYYear - 1; yr >= firstLiableFYYear && subs.length < 4; yr--) {
const pvp1Due = pvp1EndDate(yr);
const pvp1Late = addDays(pvp1Due, 1);
const pvp2Due = pvp2EndDate(yr);
const pvp2Late = addDays(pvp2Due, 1);
const liable1 = regDate < pvp1Due;
// PVP1
if (liable1) {
// Period: YYYYMM matching the actual due date year (pvp1Due's year)
const period1Str = `${pvp1Due.getFullYear()}${String(pvp1Due.getMonth() + 1).padStart(2, "0")}`;
const pbRec1 = records.find(
(r) => r.company === company.id && r.return_type === "IRP6" && r.period === period1Str
);
const label1 = `${MONTH_NAMES[pvp1Due.getMonth() + 1]} ${pvp1Due.getFullYear()}`;
subs.push({
period: period1Str, label: label1, fyYear: yr, periodNum: 1,
dueDate: pvp1Due, lateDate: pvp1Late,
filed: pbRec1 ? isFiled(pbRec1, "date_filed") : false,
filedDate: pbRec1?.date_filed ? new Date(pbRec1.date_filed) : null,
amount: pbRec1?.amount_paid || 0,
liable: true,
});
}
// PVP2 — always liable for the year if FY end is after registration
const liable2 = regDate < pvp2EndDate(yr);
if (liable2) {
// Period: YYYYMM matching the actual due date year
const period2Str = `${pvp2Due.getFullYear()}${String(pvp2Due.getMonth() + 1).padStart(2, "0")}`;
const pbRec2 = records.find(
(r) => r.company === company.id && r.return_type === "IRP6" && r.period === period2Str
);
const label2 = `${MONTH_NAMES[pvp2Due.getMonth() + 1]} ${pvp2Due.getFullYear()}`;
subs.push({
period: period2Str, label: label2, fyYear: yr, periodNum: 2,
dueDate: pvp2Due, lateDate: pvp2Late,
filed: pbRec2 ? isFiled(pbRec2, "date_filed") : false,
filedDate: pbRec2?.date_filed ? new Date(pbRec2.date_filed) : null,
amount: pbRec2?.amount_paid || 0,
liable: true,
});
}
}
// Next upcoming provisional tax (for display)
// First upcoming: PVP1 of currentFYYear (if liable) or PVP2 of currentFYYear
let nextDueDate: Date | null = null;
const pvp1Curr = pvp1EndDate(currentFYYear);
const pvp2Curr = pvp2EndDate(currentFYYear);
if (regDate < pvp1Curr && today <= pvp1Curr) {
nextDueDate = pvp1Curr;
} else if (today <= pvp2Curr) {
nextDueDate = pvp2Curr;
} else {
// Both PVP1 and PVP2 of current FY are past — next is PVP1 of next FY
const pvp1Next = pvp1EndDate(currentFYYear + 1);
nextDueDate = pvp1Next;
}
// Find the EARLIEST incomplete/past-due deadline for status dot.
// For an unfiled submission: use the due date (due today = overdue = blue).
// For a late-filed submission: the lateDate was missed.
let earliestDueDate: Date | null = null;
for (const sub of subs) {
if (!sub.filed) {
earliestDueDate = sub.dueDate; // use due date so "due today" = blue
break;
}
if (sub.filedDate && sub.filedDate > sub.lateDate) {
earliestDueDate = sub.lateDate;
break;
}
}
// Compute the most recent past due date (even when subs is empty / no records).
// Used for status dot when earliestDueDate is null — tells us if a deadline was missed.
let mostRecentPastDeadline: Date | null = null;
for (const sub of subs) {
if (sub.dueDate < today) {
mostRecentPastDeadline = sub.dueDate; // keep updating = get most recent past
}
}
return { subs, nextDueDate, earliestDueDate, mostRecentPastDeadline, fyConfigured, hasUnfiled };
}
// ─── PAYE EMP201 calculations ────────────────────────────────────────
type PayeSubmission = {
period: string; // "YYYYMM"
label: string; // "September 2026"
dueDate: Date; // day 7 of next month
lateDate: Date; // day 8 of next month
filed: boolean;
filedDate: Date | null;
amount: number;
};
/** True when the EMP201 row represents a return that was actually
* filed to SARS. We use the explicit `Return_Filed` flag rather
* than checking for a non-empty `date_submitted` because PB
* zero-coerces the amount fields for unfilled rows — same
* problem with `total_payable > 0`. The flag is the source of truth. */
function payeFiled(record: PayeRecord | undefined): boolean {
return !!record && record.Return_Filed === true;
}
/**
* PAYE EMP201 (Employee's Tax, monthly):
* - Period format is YYYYMM (e.g. "202609" = September 2026)
* - Filing window opens on day 25 of the period's own month
* - Due: day 7 of the NEXT month (e.g. 202609 → 7 Oct)
* - Late: day 8 of the NEXT month onwards
* - 12 returns per year, one per calendar month
* - If the company has no `sars_paye_no`, the section is "not applicable"
* and we always return the grey status.
*
* Colour rules:
* - GREY: no SARS PAYE number on the company record
* - BLUE: previous month's period is past day 7 of this month and not filed
* - ORANGE: this month's period is in the filing window (day 25+) and not filed,
* OR previous month's period is still in its window (day 1–7) and not filed
* - GREEN: current / previous obligations are filed or not yet due
*/
function buildPayeSubmissions(
company: Company,
records: PayeRecord[],
today: Date
): { subs: PayeSubmission[]; status: Status; hasPayeNumber: boolean; latestFiled: PayeSubmission | null } {
const hasPayeNumber = !!company.sars_paye_no && company.sars_paye_no.trim() !== "";
if (!hasPayeNumber) {
// No PAYE registration — return grey status with no rows.
return { subs: [], status: "grey", hasPayeNumber: false, latestFiled: null };
}
const companyRecords = records.filter((r) => r.company === company.id);
// Build last 4 monthly periods: current month + 3 previous.
const subs: PayeSubmission[] = [];
for (let offset = 0; offset < 4; offset++) {
const monthDate = new Date(today.getFullYear(), today.getMonth() - offset, 1);
const periodYear = monthDate.getFullYear();
const periodMonth = monthDate.getMonth() + 1; // 1-based
const periodStr = `${periodYear}${String(periodMonth).padStart(2, "0")}`;
// Due = day 7 of next month; late = day 8 of next month.
const dueDate = new Date(periodYear, periodMonth, 7); // month is 0-based here
const lateDate = new Date(periodYear, periodMonth, 8);
const pbRec = companyRecords.find((r) => r.period === periodStr);
const filed = payeFiled(pbRec);
// Use the explicit submission date if the capture script populated it.
// We deliberately do NOT fall back to `last_updated` here — that's the
// push-to-PB script's verification timestamp, NOT the SARS submission
// date, and using it would mis-report every row as "Filed late" (the
// script runs after the due date has passed). When the date is empty
// the FilingLine shows "Filed" (without on-time/late determination).
const dateStr = pbRec?.date_submitted && pbRec.date_submitted.trim() !== ""
? pbRec.date_submitted
: "";
const filedDate = dateStr ? new Date(dateStr) : null;
const amount = pbRec?.total_payable ?? 0;
subs.push({
period: periodStr,
label: `${MONTH_NAMES[periodMonth]} ${periodYear}`,
dueDate,
lateDate,
filed,
filedDate,
amount,
});
}
// Determine status dot colour for the company row.
const curPeriod = `${today.getFullYear()}${String(today.getMonth() + 1).padStart(2, "0")}`;
const prevDate = new Date(today.getFullYear(), today.getMonth() - 1, 1);
const prevPeriod = `${prevDate.getFullYear()}${String(prevDate.getMonth() + 1).padStart(2, "0")}`;
const curFiled = payeFiled(companyRecords.find((r) => r.period === curPeriod));
const prevFiled = payeFiled(companyRecords.find((r) => r.period === prevPeriod));
const todayDay = today.getDate();
let status: Status;
// 1. Previous month overdue (takes priority — a missed previous month is
// worse than a current-month-not-yet-due).
if (!prevFiled && todayDay >= 8) {
status = "blue";
}
// 2. Current month in filing window (day 25+) and not yet filed.
else if (todayDay >= 25 && !curFiled) {
status = "orange";
}
// 3. Previous month still in its window (day 1–7 of current month).
else if (todayDay < 8 && !prevFiled) {
status = "orange";
}
// 4. Everything filed or no obligation yet.
else {
status = "green";
}
// Latest filed period — used by the teaser so it never shows
// "Sep 2026: Not filed" while we're still 14 days before Sep's window opens.
const latestFiled = subs.find((s) => s.filed) ?? null;
return { subs, status, hasPayeNumber, latestFiled };
}
// ─── VAT201 calculations ────────────────────────────────────────────
/** True when the VAT201 row represents a return that was actually
* filed to SARS. Same convention as PAYE — `Return_Filed` is the
* source of truth; number fields zero-coerce when unfiled. */
function vatFiled(record: VatRecord | undefined): boolean {
return !!record && record.Return_Filed === true;
}
/** Valid VAT period months for a given cadence value
* (`sars_vat_returns_period`). 0/None returns []. */
function vatPeriodMonths(cadence: number | null | undefined): number[] {
switch (cadence) {
case 1: return [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]; // Every Month
case 2: return [2, 4, 6, 8, 10, 12]; // Even Months
case 3: return [1, 3, 5, 7, 9, 11]; // Uneven Months
case 6: return [2, 8]; // Bi-Annually (Feb, Aug)
case 12: return [3]; // Annually (March)
default: return [];
}
}
/** Find the most recent VAT period for the company that's at or
* before today. Returns null if no valid period exists for the
* cadence on/before today. */
function currentVatPeriod(cadence: number | null | undefined, today: Date): { year: number; month: number } | null {
const validMonths = vatPeriodMonths(cadence);
if (validMonths.length === 0) return null;
// Walk back from today until we find a valid period month.
for (let i = 0; i < 24; i++) {
const d = new Date(today.getFullYear(), today.getMonth() - i, 1);
if (validMonths.includes(d.getMonth() + 1)) {
return { year: d.getFullYear(), month: d.getMonth() + 1 };
}
}
return null;
}
type VatSubmission = {
period: string; // "YYYYMM"
label: string; // "August 2026"
dueDate: Date; // 25th of the month AFTER the period's month
lateDate: Date; // 26th of the month AFTER the period's month
filed: boolean;
filedDate: Date | null;
amount: number;
};
/**
* VAT201 (Value Added Tax, monthly/bi-monthly/etc. depending on cadence):
* - Period format is YYYYMM
* - Cadence is company-specific (`sars_vat_returns_period`)
* - Due: 25th of the month AFTER the period's month
* (e.g. period 202608 → due 25 Sept; period 202602 → due 25 March)
* - Late: from the 26th onwards
* - If the company has no `sars_vat_no`, the section is "not applicable"
* and we always return the grey status.
*
* Colour rules:
* - GREY: no SARS VAT number on the company record, OR cadence is None/0
* - BLUE: most recent applicable period is past its 25th due date and not filed
* - ORANGE: in the filing window for the current period, not yet filed
* - GREEN: current obligation is filed or no obligation yet
*
* NOTE: the due-date rule is a placeholder — SJ's actual VAT201 deadline
* needs to be confirmed. The colour logic structure mirrors PAYE so
* the only thing to tune later is the dueDate/lateDate calculation.
*/
function buildVatSubmissions(
company: Company,
records: VatRecord[],
today: Date
): { subs: VatSubmission[]; status: Status; hasVatNumber: boolean; latestFiled: VatSubmission | null } {
const hasVatNumber = !!company.sars_vat_no && company.sars_vat_no.trim() !== "";
const cadence = company.sars_vat_returns_period ?? null;
if (!hasVatNumber || cadence == null || cadence === 0) {
return { subs: [], status: "grey", hasVatNumber, latestFiled: null };
}
const companyRecords = records.filter((r) => r.company === company.id);
// Build the last 4 applicable VAT periods, walking back from today
const validMonths = vatPeriodMonths(cadence);
const periods: { year: number; month: number }[] = [];
for (let i = 0; i < 50 && periods.length < 4; i++) {
const d = new Date(today.getFullYear(), today.getMonth() - i, 1);
if (validMonths.includes(d.getMonth() + 1)) {
periods.push({ year: d.getFullYear(), month: d.getMonth() + 1 });
}
}
const subs: VatSubmission[] = periods.map(({ year, month }) => {
const periodStr = `${year}${String(month).padStart(2, "0")}`;
// Due = 25th of NEXT month after period
const dueDate = new Date(year, month, 25);
const lateDate = new Date(year, month, 26);
const pbRec = companyRecords.find((r) => r.period === periodStr);
const filed = vatFiled(pbRec);
// Use date_submitted first, fall back to last_updated (push script
// verification timestamp) — same fallback pattern as PAYE rows
// where date_submitted is often empty.
const dateStr =
(pbRec?.date_submitted && pbRec.date_submitted.trim() !== "" && pbRec.date_submitted) ||
(pbRec?.last_updated && pbRec.last_updated.trim() !== "" && pbRec.last_updated) ||
"";
const filedDate = dateStr ? new Date(dateStr) : null;
const amount = pbRec?.total_payable ?? 0;
return {
period: periodStr,
label: `${MONTH_NAMES[month]} ${year}`,
dueDate,
lateDate,
filed,
filedDate,
amount,
};
});
// Status colour for the dot — find the current obligation
const curPeriod = currentVatPeriod(cadence, today);
const prevPeriod = (() => {
// The period BEFORE the current one (within cadence), if any
if (!curPeriod) return null;
const d = new Date(curPeriod.year, curPeriod.month - 2, 1); // -2 because months are 1-based
const validMonths = vatPeriodMonths(cadence);
for (let i = 0; i < 24; i++) {
const test = new Date(d.getFullYear(), d.getMonth() - i, 1);
if (validMonths.includes(test.getMonth() + 1)) {
return {
year: test.getFullYear(),
month: test.getMonth() + 1,
};
}
}
return null;
})();
const curPeriodStr = curPeriod ? `${curPeriod.year}${String(curPeriod.month).padStart(2, "0")}` : "";
const prevPeriodStr = prevPeriod ? `${prevPeriod.year}${String(prevPeriod.month).padStart(2, "0")}` : "";
const curFiled = vatFiled(companyRecords.find((r) => r.period === curPeriodStr));
const prevFiled = vatFiled(companyRecords.find((r) => r.period === prevPeriodStr));
const todayDay = today.getDate();
let status: Status;
// 1. Previous period overdue (takes priority)
if (prevPeriod && !prevFiled) {
// Previous period's late date is 26th of the month after prevPeriod's month.
// If today >= that late date → previous is overdue.
const prevLateDate = new Date(prevPeriod.year, prevPeriod.month, 26);
if (today >= prevLateDate) status = "blue";
else status = "orange"; // still in prev's filing window (1st-25th of cur month)
}
// 2. Current period in window (today is 1st-25th of cur month)
else if (curPeriod && !curFiled && todayDay <= 25) {
status = "orange";
}
// 3. Current period past due
else if (curPeriod && !curFiled) {
status = "blue";
}
// 4. Filed or no obligation
else {
status = "green";
}
const latestFiled = subs.find((s) => s.filed) ?? null;
return { subs, status, hasVatNumber, latestFiled };
}
// ─── Company overall status (worst-of across all 4 sections) ─────
/**
* "Worst-of" reducer for two status colours. Grey is treated as "not
* applicable" and excluded from the comparison so a company without
* PAYE registration isn't pulled down to grey just because PAYE
* doesn't apply to them.
*/
function worstOf(a: Status, b: Status): Status {
if (a === "grey") return b;
if (b === "grey") return a;
const order: Status[] = ["green", "orange", "blue", "red"];
return order[Math.max(order.indexOf(a), order.indexOf(b))];
}
/**
* Per-type status for a single company — one Status colour for each of
* the 5 submission types. Used by the page-level filter so the colour
* filter can be scoped to the SELECTED types (e.g. "show me companies
* whose EMP dot is Blue"), instead of being pinned to the worst-of
* overall status.
*/
function getCompanyTypeStatuses(
company: Company,
annualReturns: AnnualReturn[],
irp6Records: IRP6Record[],
payeRecords: PayeRecord[],
vatRecords: VatRecord[],
today: Date
): Record<FilterType, Status> {
const { earliestDueDate: arEarliest, nextDueDate: nextARDate } = buildARSubmissions(company, annualReturns);
const { earliestDueDate: itEarliest, nextDueDate: nextITDate } = buildITSubmissions(company, irp6Records);
const { earliestDueDate: provEarliest, mostRecentPastDeadline: provPastDeadline, nextDueDate: provNextDate } = buildProvSubmissions(company, irp6Records);
const { status: payeStatus } = buildPayeSubmissions(company, payeRecords, today);
const { status: vatStatus } = buildVatSubmissions(company, vatRecords, today);
const hasRegDate = !!(company.registration_date && company.registration_date.trim() !== "");
const fyConfigured = !!(company.financial_year_end_month && company.financial_year_end_month >= 1 && company.financial_year_end_month <= 12);
const arStatus: Status = hasRegDate ? getStatusColor(arEarliest, fyConfigured, nextARDate ?? null) : "red";
const itStatus: Status = hasRegDate ? getStatusColor(itEarliest, fyConfigured, nextITDate ?? null) : "red";
const provStatus: Status = hasRegDate ? getStatusColor(provEarliest, fyConfigured, provPastDeadline ?? provNextDate ?? null) : "red";
return {
ar: arStatus,
it: itStatus,
prov: provStatus,
emp: payeStatus,
vat: vatStatus,
};
}
/**
* Overall compliance status for a single company = worst of its
* AR · IT · Prov · PAYE · VAT dots. Used as a fallback for the colour
* filter when NO submission types are selected (so the colour still
* has something to compare against).
*/
function getCompanyOverallStatus(
company: Company,
annualReturns: AnnualReturn[],
irp6Records: IRP6Record[],
payeRecords: PayeRecord[],
vatRecords: VatRecord[],
today: Date
): Status {
const ts = getCompanyTypeStatuses(company, annualReturns, irp6Records, payeRecords, vatRecords, today);
return worstOf(worstOf(worstOf(worstOf(ts.ar, ts.it), ts.prov), ts.emp), ts.vat);
}
// ─── Submission dot ─────────────────────────────────────────────────
function StatusDot({ status }: { status: Status }) {
const colors: Record<Status, string> = {
green: "bg-green-500",
orange: "bg-amber-500",
blue: "bg-blue-500",
red: "bg-red-500",
grey: "bg-gray-400",
};
return (
<span className={`inline-block w-3 h-3 rounded-full ${colors[status]} shrink-0`} />
);
}
// ─── FilingLine: single submission row ──────────────────────────────
function FilingLine({ label, filed, filedDate, amount, dueDate, lateDate, notLiable }: {
label: string;
filed: boolean;
filedDate: Date | null;
amount: number;
dueDate: Date | null;
lateDate: Date | null;
notLiable?: boolean;
}) {
if (notLiable) {
return (
<div className="flex items-center gap-4 text-sm py-2 px-4 border-b border-border/50 last:border-0 text-muted/40 italic">
<span className="w-24 shrink-0 font-semibold text-fg">{label}</span>
<span className="col-span-4 text-xs">— Not applicable (company not yet registered during this period)</span>
</div>
);
}
const isLate = filed && filedDate != null && lateDate != null && filedDate > lateDate;
const isOnTime = filed && filedDate != null && lateDate != null && filedDate <= lateDate;
// Default reflects the boolean filed flag — if we know it's filed but
// don't have a date, we still say "Filed" rather than "Not filed".
// (PAYE rows can have Return_Filed=true with an empty date_submitted
// — without this, the line would always say "Not filed" and mislead.)
let statusLabel = filed ? "Filed" : "Not filed";
let statusClass = filed ? "text-green-600 font-medium" : "text-muted";
if (isLate) { statusLabel = "Filed late"; statusClass = "text-blue-600 font-medium"; }
else if (isOnTime) { statusLabel = "Filed on time"; statusClass = "text-green-600 font-medium"; }
return (
<div className="flex items-center gap-4 text-sm py-2 px-4 border-b border-border/50 last:border-0 hover:bg-surface transition-colors">
<span className="w-24 shrink-0 font-semibold text-fg">{label}</span>
<span className="w-28 shrink-0 text-muted">
{filedDate ? formatDate(filedDate) : <span className="text-muted/50">—</span>}
</span>
<span className="w-24 shrink-0 font-mono">
{amount > 0 ? `R ${amount.toLocaleString()}` : <span className="text-muted/50">—</span>}
</span>
<span className="w-36 shrink-0 text-muted">
{dueDate ? <>Due: {formatDate(dueDate)}</> : <span className="text-muted/50">—</span>}
</span>
<span className={`shrink-0 ml-auto font-medium ${statusClass}`}>{statusLabel}</span>
</div>
);
}
// ─── Column headers for expanded section ─────────────────────────────
function ExpandedHeader() {
return (
<div className="flex items-center gap-4 py-1.5 px-4 bg-surface-warm border-b border-border text-xs font-semibold uppercase tracking-wide text-muted">
<span className="w-24 shrink-0">Period</span>
<span className="w-28 shrink-0">Date Filed</span>
<span className="w-24 shrink-0">Amount Paid</span>
<span className="w-36 shrink-0">Due Date</span>
<span className="shrink-0 ml-auto">Status</span>
</div>
);
}
// ─── AR Child row ───────────────────────────────────────────────────
function ARChildRow({ company, records }: { company: Company; records: AnnualReturn[] }) {
const [open, setOpen] = useState(false);
const { subs, nextDueDate, earliestDueDate, nextLateDate } = buildARSubmissions(company, records);
const fyConfigured = !!(company.financial_year_end_month && company.financial_year_end_month >= 1 && company.financial_year_end_month <= 12);
const hasRegDate = !!(company.registration_date && company.registration_date.trim() !== "");
const status = hasRegDate ? getStatusColor(earliestDueDate, fyConfigured, nextDueDate ?? null) : "red";
// Teaser text
const teaser = (() => {
if (subs.length === 0) return "No prior filings";
const latest = subs[0];
if (latest.filed) {
return `${latest.ar_year}: ${formatDate(latest.filedDate)} · R ${latest.amount.toLocaleString()}`;
}
return `${latest.ar_year}: Not filed`;
})();
return (
<div className="border-b border-border/60 last:border-0">
<button
type="button"
onClick={() => setOpen(!open)}
className="w-full flex items-center gap-3 py-2.5 px-4 hover:bg-surface transition-colors text-left"
>
<span className="text-sm w-3 shrink-0 text-muted">{open ? "▼" : "▶"}</span>
<StatusDot status={status} />
<span className="font-medium text-fg text-sm w-36 shrink-0">Annual Returns</span>
<span className="text-xs text-muted flex-1 min-w-0 truncate">{teaser}</span>
{nextDueDate && (
<span className="text-xs text-muted shrink-0">
Next: {formatDate(nextDueDate)}
</span>
)}
</button>
{open && (
<div className="bg-surface border-t border-border/40">
<ExpandedHeader />
{subs.length === 0 ? (
<div className="py-4 px-4 text-sm text-muted italic">
No prior filings. Next AR due: {formatDate(nextDueDate)} (late: {formatDate(nextLateDate)})
</div>
) : (
subs.map((s, i) => (
<FilingLine
key={`${s.ar_year}`}
label={String(s.ar_year)}
filed={s.filed}
filedDate={s.filedDate}
amount={s.amount}
dueDate={s.dueDate}
lateDate={s.lateDate}
/>
))
)}
{subs.length > 0 && nextDueDate && (
<div className="py-2 px-4 text-xs text-muted border-t border-border/30 mt-1">
Next upcoming: {formatDate(nextDueDate)} {nextLateDate ? `(late from ${formatDate(nextLateDate)})` : ""}
</div>
)}
</div>
)}
</div>
);
}
// ─── IT Child row ────────────────────────────────────────────────────
function ITChildRow({ company, records }: { company: Company; records: IRP6Record[] }) {
const [open, setOpen] = useState(false);
const { subs, nextDueDate, earliestDueDate, nextLateDate } = buildITSubmissions(company, records);
const fyConfigured = !!(company.financial_year_end_month && company.financial_year_end_month >= 1 && company.financial_year_end_month <= 12);
const hasRegDate = !!(company.registration_date && company.registration_date.trim() !== "");
// nextDueDate is passed so a past deadline with no filings = blue (not green)
const status = hasRegDate ? getStatusColor(earliestDueDate, fyConfigured, nextDueDate ?? null) : "red";
const teaser = (() => {
if (subs.length === 0) return "No filings on record";
const latest = subs[0];
if (!latest) return "No filings on record";
const period = formatPeriod(latest.period);
if (latest.filed) {
return `${period}: ${formatDate(latest.filedDate)} · R ${latest.amount.toLocaleString()}`;
}
return `${period}: Not filed`;
})();
return (
<div className="border-b border-border/60 last:border-0">
<button
type="button"
onClick={() => setOpen(!open)}
className="w-full flex items-center gap-3 py-2.5 px-4 hover:bg-surface transition-colors text-left"
>
<span className="text-sm w-3 shrink-0 text-muted">{open ? "▼" : "▶"}</span>
<StatusDot status={status} />
<span className="font-medium text-fg text-sm w-36 shrink-0">Income Tax</span>
<span className="text-xs text-muted flex-1 min-w-0 truncate">{teaser}</span>
{nextDueDate && (
<span className="text-xs text-muted shrink-0">Next: {formatDate(nextDueDate)}</span>
)}
</button>
{open && (
<div className="bg-surface border-t border-border/40">
<ExpandedHeader />
{subs.length === 0 ? (
<div className="py-4 px-4 text-sm text-muted italic">
No income tax filings on record.
{nextDueDate && ` Next due: ${formatDate(nextDueDate)} (late from ${formatDate(nextLateDate)}).`}
</div>
) : (
subs.map((s) => (
<FilingLine
key={s.period}
label={formatPeriod(s.period)}
filed={s.filed}
filedDate={s.filedDate}
amount={s.amount}
dueDate={s.dueDate}
lateDate={s.lateDate}
/>
))
)}
{subs.length > 0 && nextDueDate && (
<div className="py-2 px-4 text-xs text-muted border-t border-border/30 mt-1">
Next upcoming: {formatDate(nextDueDate)} {nextLateDate ? `(late from ${formatDate(nextLateDate)})` : ""}
</div>
)}
</div>
)}
</div>
);
}
// ─── Prov Child row ──────────────────────────────────────────────────
function ProvChildRow({ company, records }: { company: Company; records: IRP6Record[] }) {
const [open, setOpen] = useState(false);
const { subs, nextDueDate, earliestDueDate, mostRecentPastDeadline, fyConfigured } = buildProvSubmissions(company, records);
const hasRegDate = !!(company.registration_date && company.registration_date.trim() !== "");
// When there are no records at all, use mostRecentPastDeadline to detect missed deadlines.
// When there ARE records but all filed, nextDueDate shows the upcoming deadline.
const status = hasRegDate ? getStatusColor(earliestDueDate, fyConfigured, mostRecentPastDeadline ?? nextDueDate ?? null) : "red";
const teaser = (() => {
if (subs.length === 0) return "No provisional tax submissions on record";
const latest = subs.find(s => s.filed) || subs[0];
return `${latest.label}: ${latest.filed ? formatDate(latest.filedDate) : "Not filed"}`;
})();
return (
<div className="border-b border-border/60 last:border-0">
<button
type="button"
onClick={() => setOpen(!open)}
className="w-full flex items-center gap-3 py-2.5 px-4 hover:bg-surface transition-colors text-left"
>
<span className="text-sm w-3 shrink-0 text-muted">{open ? "▼" : "▶"}</span>
<StatusDot status={status} />
<span className="font-medium text-fg text-sm w-36 shrink-0">Provisional Tax</span>
<span className="text-xs text-muted flex-1 min-w-0 truncate">{teaser}</span>
{nextDueDate && (
<span className="text-xs text-muted shrink-0">Next: {formatDate(nextDueDate)}</span>
)}
</button>
{open && (
<div className="bg-surface border-t border-border/40">
<ExpandedHeader />
{subs.length === 0 ? (
<div className="py-4 px-4 text-sm text-muted italic">
No provisional tax submissions on record.
</div>
) : (
subs.map((s, i) => (
<FilingLine
key={s.period}
label={s.label}
filed={s.filed}
filedDate={s.filedDate}
amount={s.amount}
dueDate={s.dueDate}
lateDate={s.lateDate}
/>
))
)}
</div>
)}
</div>
);
}
// ─── PAYE EMP201 Child row ───────────────────────────────────────────
function PayeChildRow({ company, records }: { company: Company; records: PayeRecord[] }) {
const [open, setOpen] = useState(false);
const { subs, status, hasPayeNumber, latestFiled } = buildPayeSubmissions(company, records, new Date());
// Teaser:
// - grey/no PAYE: "No SARS PAYE registration"
// - green (latest filed, no obligation yet): "<Latest label>: filed <date>"
// - orange/blue (something outstanding): "<Outstanding label>: Not filed"
// - no records at all: "No PAYE submissions on record"
const teaser = (() => {
if (!hasPayeNumber) return "No SARS PAYE registration";
if (status === "orange" || status === "blue") {
const outstanding = subs.find((s) => !s.filed);
if (outstanding) return `${outstanding.label}: Not filed`;
}
if (latestFiled) {
// Only show the date when we have one — many PAYE rows have
// Return_Filed=true but an empty date_submitted, so don't print
// "filed —" as that reads like "not filed".
const dateSuffix = latestFiled.filedDate
? ` ${formatDate(latestFiled.filedDate)}`
: "";
return `${latestFiled.label}: filed${dateSuffix}`;
}
return "No PAYE submissions on record";
})();
return (
<div className="border-b border-border/60 last:border-0">
<button
type="button"
onClick={() => setOpen(!open)}
className="w-full flex items-center gap-3 py-2.5 px-4 hover:bg-surface transition-colors text-left"
>
<span className="text-sm w-3 shrink-0 text-muted">{open ? "▼" : "▶"}</span>
<StatusDot status={status} />
<span className="font-medium text-fg text-sm w-36 shrink-0">PAYE EMP201</span>
<span className="text-xs text-muted flex-1 min-w-0 truncate">{teaser}</span>
</button>
{open && (
<div className="bg-surface border-t border-border/40">
<ExpandedHeader />
{!hasPayeNumber ? (
<div className="py-4 px-4 text-sm text-muted italic">
No SARS PAYE registration on this company — nothing to file.
</div>
) : subs.length === 0 ? (
<div className="py-4 px-4 text-sm text-muted italic">
No PAYE submissions on record.
</div>
) : (
subs.map((s) => (
<FilingLine
key={s.period}
label={s.label}
filed={s.filed}
filedDate={s.filedDate}
amount={s.amount}
dueDate={s.dueDate}
lateDate={s.lateDate}
/>
))
)}
</div>
)}
</div>
);
}
// ─── VAT201 Child row ────────────────────────────────────────────────
function VatChildRow({ company, records }: { company: Company; records: VatRecord[] }) {
const [open, setOpen] = useState(false);
const { subs, status, hasVatNumber, latestFiled } = buildVatSubmissions(company, records, new Date());
const teaser = (() => {
if (!hasVatNumber) return "No SARS VAT registration";
if (status === "orange" || status === "blue") {
const outstanding = subs.find((s) => !s.filed);
if (outstanding) return `${outstanding.label}: Not filed`;
}
if (latestFiled) {
const dateSuffix = latestFiled.filedDate
? ` ${formatDate(latestFiled.filedDate)}`
: "";
return `${latestFiled.label}: filed${dateSuffix}`;
}
return "No VAT submissions on record";
})();
return (
<div className="border-b border-border/60 last:border-0">
<button
type="button"
onClick={() => setOpen(!open)}
className="w-full flex items-center gap-3 py-2.5 px-4 hover:bg-surface transition-colors text-left"
>
<span className="text-sm w-3 shrink-0 text-muted">{open ? "▼" : "▶"}</span>
<StatusDot status={status} />
<span className="font-medium text-fg text-sm w-36 shrink-0">VAT201</span>
<span className="text-xs text-muted flex-1 min-w-0 truncate">{teaser}</span>
</button>
{open && (
<div className="bg-surface border-t border-border/40">
<ExpandedHeader />
{!hasVatNumber ? (
<div className="py-4 px-4 text-sm text-muted italic">
No SARS VAT registration on this company — nothing to file.
</div>
) : subs.length === 0 ? (
<div className="py-4 px-4 text-sm text-muted italic">
No VAT submissions on record.
</div>
) : (
subs.map((s) => (
<FilingLine
key={s.period}
label={s.label}
filed={s.filed}
filedDate={s.filedDate}
amount={s.amount}
dueDate={s.dueDate}
lateDate={s.lateDate}
/>
))
)}
</div>
)}
</div>
);
}
// ─── Grouped card container (CIPC vs SARS) ─────────────────────────
/** Wraps a group of child rows in a bordered card with a small group
* label at the top. Used to visually separate CIPC filings from
* SARS filings inside an expanded company row. */
function SectionCard({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="bg-surface border border-border rounded-lg overflow-hidden">
<div className="px-4 py-1.5 bg-surface-warm border-b border-border text-[10px] font-bold uppercase tracking-wider text-muted">
{label}
</div>
<div>{children}</div>
</div>
);
}
// ─── Company row ────────────────────────────────────────────────────
function CompanyRow({ company, annualReturns, irp6Records, payeRecords, vatRecords }: {
company: Company;
annualReturns: AnnualReturn[];
irp6Records: IRP6Record[];
payeRecords: PayeRecord[];
vatRecords: VatRecord[];
}) {
const [expanded, setExpanded] = useState(false);
// Per-section status (still needed for the 5 dots in the row header)
const { earliestDueDate: arEarliest, nextDueDate: nextARDate } = buildARSubmissions(company, annualReturns);
const { earliestDueDate: itEarliest, nextDueDate: nextITDate } = buildITSubmissions(company, irp6Records);
const { earliestDueDate: provEarliest, mostRecentPastDeadline: provPastDeadline, nextDueDate: provNextDate } = buildProvSubmissions(company, irp6Records);
const { status: payeStatus } = buildPayeSubmissions(company, payeRecords, new Date());
const { status: vatStatus } = buildVatSubmissions(company, vatRecords, new Date());
const hasRegDate = !!(company.registration_date && company.registration_date.trim() !== "");
const fyConfigured = !!(company.financial_year_end_month && company.financial_year_end_month >= 1 && company.financial_year_end_month <= 12);
const arStatus = hasRegDate ? getStatusColor(arEarliest, fyConfigured, nextARDate ?? null) : "red";
const itStatus = hasRegDate ? getStatusColor(itEarliest, fyConfigured, nextITDate ?? null) : "red";
const provStatus = hasRegDate ? getStatusColor(provEarliest, fyConfigured, provPastDeadline ?? provNextDate ?? null) : "red";
// overallStatus kept here for the (unused-by-default) badge that some
// future layout might want; the grid uses getCompanyOverallStatus() instead.
const overallStatus = worstOf(worstOf(worstOf(arStatus, itStatus), provStatus), payeStatus);
return (
<div className="border-b border-border last:border-0">
<button
type="button"
onClick={() => setExpanded(!expanded)}
className="w-full flex items-center gap-4 py-3 px-4 hover:bg-surface-warm transition-colors text-left"
>
<span className="text-lg w-4 shrink-0 text-muted">
{expanded ? "▼" : "▶"}
</span>
<span className="flex-1 min-w-0">
<span className="font-medium text-fg block truncate">{company.enterprise_name}</span>
<span className="text-xs text-muted font-mono">{company.enterprise_no}</span>
</span>
<div className="flex items-center gap-1 shrink-0" title="Annual Returns">
<StatusDot status={arStatus} />
</div>
<div className="flex items-center gap-1 shrink-0" title="Income Tax">
<StatusDot status={itStatus} />
</div>
<div className="flex items-center gap-1 shrink-0" title="Provisional Tax">
<StatusDot status={provStatus} />
</div>
<div className="flex items-center gap-1 shrink-0" title="PAYE EMP201">
<StatusDot status={payeStatus} />
</div>
<div className="flex items-center gap-1 shrink-0" title="VAT201">
<StatusDot status={vatStatus} />
</div>
</button>
{expanded && (
<div className="border-t border-border bg-surface-warm p-3 space-y-3">
{/* CIPC block — only Annual Returns today */}
<SectionCard label="CIPC">
<ARChildRow company={company} records={annualReturns} />
</SectionCard>
{/* SARS block — Income Tax, Provisional Tax, PAYE EMP201, VAT201 */}
<SectionCard label="SARS">
<ITChildRow company={company} records={irp6Records} />
<ProvChildRow company={company} records={irp6Records} />
<PayeChildRow company={company} records={payeRecords} />
<VatChildRow company={company} records={vatRecords} />
</SectionCard>
</div>
)}
</div>
);
}
// ─── Main grid ──────────────────────────────────────────────────────
/** Submission-type keys the page-level filter chips toggle. */
export type FilterType = "ar" | "it" | "prov" | "emp" | "vat";
/** All filter type keys, in display order. */
export const FILTER_TYPE_KEYS: FilterType[] = ["ar", "it", "prov", "emp", "vat"];
/** All type keys + human-readable labels. */
export const FILTER_TYPE_LABELS: Record<FilterType, string> = {
ar: "AR",
it: "IT",
prov: "Prov",
emp: "EMP",
vat: "VAT",
};
/** Status colours the page-level filter chips toggle. */
export const FILTER_COLOURS: Status[] = ["red", "blue", "green", "orange", "grey"];
/** Human label for each filterable colour. */
export const FILTER_COLOUR_LABELS: Record<Status, string> = {
red: "Red",
blue: "Blue",
green: "Green",
orange: "Orange",
grey: "Grey",
};
/** Tailwind bg-* utility per filterable colour (for the chip dot). */
export const FILTER_COLOUR_BG: Record<Status, string> = {
red: "bg-red-500",
blue: "bg-blue-500",
green: "bg-green-500",
orange: "bg-amber-500",
grey: "bg-gray-400",
};
interface Props {
companies: Company[];
annualReturns: AnnualReturn[];
irp6Records: IRP6Record[];
payeRecords: PayeRecord[];
vatRecords: VatRecord[];
/**
* Submission types the user wants to see companies for. A company
* passes the type filter when it has a record for at least one
* selected type. Undefined = no type filter (show all).
*/
selectedTypes?: ReadonlySet<FilterType>;
/**
* Status colours the user wants to see companies for. A company
* passes the colour filter when its overall status is in this set.
* Undefined = no colour filter (show all).
*/
selectedColours?: ReadonlySet<Status>;
}
export default function ComplianceTreeGrid({
companies,
annualReturns,
irp6Records,
payeRecords,
vatRecords,
selectedTypes,
selectedColours,
}: Props) {
const today = new Date();
// For each company, pre-compute:
// - which submission types it has at least one record of
// - its per-type status colours (for type-scoped colour filtering)
// - its overall worst-of status (fallback when no types are selected)
// Then apply the page-level filters. Empty Set = no filter.
const enriched = companies.map((c) => {
const hasAR = annualReturns.some((r) => r.company === c.id);
const hasIT = irp6Records.some((r) => r.company === c.id && r.return_type === "IncomeTax");
const hasProv = irp6Records.some((r) => r.company === c.id && r.return_type === "IRP6");
const hasEMP = payeRecords.some((r) => r.company === c.id);
const hasVAT = vatRecords.some((r) => r.company === c.id);
const typeStatuses = getCompanyTypeStatuses(c, annualReturns, irp6Records, payeRecords, vatRecords, today);
const overallStatus = worstOf(worstOf(worstOf(worstOf(typeStatuses.ar, typeStatuses.it), typeStatuses.prov), typeStatuses.emp), typeStatuses.vat);
return {
company: c,
has: { ar: hasAR, it: hasIT, prov: hasProv, emp: hasEMP, vat: hasVAT } as Record<FilterType, boolean>,
typeStatuses,
overallStatus,
};
});
// "All chips selected" (or "no chips selected") both mean "no filter"
// — only a PARTIAL chip set actually restricts the list. This way the
// default view shows every company (including ones with zero data,
// like newly-added clients), and the user opts in to filtering.
const allTypesSelected =
!!selectedTypes && selectedTypes.size === FILTER_TYPE_KEYS.length;
const allColoursSelected =
!!selectedColours && selectedColours.size === FILTER_COLOURS.length;
const typeFilterActive = !!selectedTypes && selectedTypes.size > 0 && !allTypesSelected;
const colourFilterActive = !!selectedColours && selectedColours.size > 0 && !allColoursSelected;
const visible = enriched.filter(({ has, typeStatuses, overallStatus }) => {
// Type filter: company must have data for at least one SELECTED
// type. Only applies when the chip set is partial.
if (typeFilterActive) {
const passes = FILTER_TYPE_KEYS.some(
(t) => selectedTypes!.has(t) && has[t]
);
if (!passes) return false;
}
// Colour filter — scoped to the SELECTED types so e.g. "EMP + Blue"
// means "any selected type's status is Blue". Only applies when the
// chip set is partial. Falls back to overall status when no types
// are selected (otherwise the colour filter has no targets).
if (colourFilterActive) {
if (selectedTypes && selectedTypes.size > 0) {
const colourPasses = FILTER_TYPE_KEYS.some(
(t) => selectedTypes.has(t) && selectedColours!.has(typeStatuses[t])
);
if (!colourPasses) return false;
} else {
if (!selectedColours!.has(overallStatus)) return false;
}
}
return true;
});
return (
<div className="bg-surface border border-border rounded-lg overflow-hidden">
<div className="flex items-center gap-4 py-3 px-4 bg-surface-warm border-b border-border text-xs font-semibold uppercase tracking-wide text-muted">
<span className="w-4 shrink-0" />
<span className="flex-1 min-w-0">Company</span>
<span className="w-20 text-center" title="Annual Returns">AR</span>
<span className="w-20 text-center" title="Income Tax">IT</span>
<span className="w-20 text-center" title="Provisional Tax">Prov</span>
<span className="w-20 text-center" title="PAYE EMP201">PAYE</span>
<span className="w-20 text-center" title="VAT201">VAT</span>
</div>
<div>
{visible.map(({ company }) => (
<CompanyRow
key={company.id}
company={company}
annualReturns={annualReturns}
irp6Records={irp6Records}
payeRecords={payeRecords}
vatRecords={vatRecords}
/>
))}
</div>
</div>
);
}