Workflow-App/Workflow/components/ComplianceTreeGrid.tsx
sjcoetzeesj-coder 21db4d0e64 fix: resolve all 7 type errors that broke the production build
next build runs 'Linting and checking validity of types' and fails on
the first type error, so the Coolify image build died at
app/api/cipc/search/route.ts:640. These errors were pre-existing but
only surface on next build (npm run dev does not type check).

- cipc/search/route.ts: coerce spawnSync stdout/stderr with toString();
  result is typed as the un-overloaded return, so they are string|Buffer
- group-report pdf+email: use fetchCollection<YsterbaardGroup> instead of
  an invalid cast; normalise description null -> undefined for ReportData
- CipcExplorer (cipc + cipc-2): split the state ternary into two returns;
  the RowState union correlates state with differs, so 'diff' | 'match'
  matched neither variant
- ComplianceTreeGrid: ar_non_compliance_date is optional; the API omits it
  and this component never reads it

Verified: tsc --noEmit 0 errors, next build exit 0, standalone output ok.
2026-10-18 15:48:50 +02:00

1671 lines
67 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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>
);
}