Adds the full Next.js app (app/, components/, lib/, scripts/, assets/, docs/, Dockerfile, compose, configs) to version control. Security: - CIPC BizPortal customer-code logins (Login 1 + Login 3) now read CIPC_LOGIN1_CODE / CIPC_LOGIN1_PASSWORD / CIPC_LOGIN3_CODE / CIPC_LOGIN3_PASSWORD from .env.local instead of source. - BizPortal personal login in apply-bizportal route reads BIZPORTAL_ID_NO / BIZPORTAL_PASSWORD. - Added .env.example as the committed template. .gitignore excludes .env*, node_modules, .next, logs, sqlite, Playwright storage_state.json (live CIPC cookies), scratch dirs, client PDFs and debug screenshots.
1669 lines
67 KiB
TypeScript
1669 lines
67 KiB
TypeScript
// 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;
|
||
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>
|
||
);
|
||
}
|