// 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 = { 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 { 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 = { green: "bg-green-500", orange: "bg-amber-500", blue: "bg-blue-500", red: "bg-red-500", grey: "bg-gray-400", }; return ( ); } // ─── 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 (
{label} — Not applicable (company not yet registered during this period)
); } 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 (
{label} {filedDate ? formatDate(filedDate) : —} {amount > 0 ? `R ${amount.toLocaleString()}` : —} {dueDate ? <>Due: {formatDate(dueDate)} : —} {statusLabel}
); } // ─── Column headers for expanded section ───────────────────────────── function ExpandedHeader() { return (
Period Date Filed Amount Paid Due Date Status
); } // ─── 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 (
{open && (
{subs.length === 0 ? (
No prior filings. Next AR due: {formatDate(nextDueDate)} (late: {formatDate(nextLateDate)})
) : ( subs.map((s, i) => ( )) )} {subs.length > 0 && nextDueDate && (
Next upcoming: {formatDate(nextDueDate)} {nextLateDate ? `(late from ${formatDate(nextLateDate)})` : ""}
)}
)}
); } // ─── 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 (
{open && (
{subs.length === 0 ? (
No income tax filings on record. {nextDueDate && ` Next due: ${formatDate(nextDueDate)} (late from ${formatDate(nextLateDate)}).`}
) : ( subs.map((s) => ( )) )} {subs.length > 0 && nextDueDate && (
Next upcoming: {formatDate(nextDueDate)} {nextLateDate ? `(late from ${formatDate(nextLateDate)})` : ""}
)}
)}
); } // ─── 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 (
{open && (
{subs.length === 0 ? (
No provisional tax submissions on record.
) : ( subs.map((s, i) => ( )) )}
)}
); } // ─── 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): ": filed " // - orange/blue (something outstanding): ": 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 (
{open && (
{!hasPayeNumber ? (
No SARS PAYE registration on this company — nothing to file.
) : subs.length === 0 ? (
No PAYE submissions on record.
) : ( subs.map((s) => ( )) )}
)}
); } // ─── 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 (
{open && (
{!hasVatNumber ? (
No SARS VAT registration on this company — nothing to file.
) : subs.length === 0 ? (
No VAT submissions on record.
) : ( subs.map((s) => ( )) )}
)}
); } // ─── 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 (
{label}
{children}
); } // ─── 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 (
{expanded && (
{/* CIPC block — only Annual Returns today */} {/* SARS block — Income Tax, Provisional Tax, PAYE EMP201, VAT201 */}
)}
); } // ─── 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 = { 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 = { 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 = { 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; /** * 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; } 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, 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 (
Company AR IT Prov PAYE VAT
{visible.map(({ company }) => ( ))}
); }