Compliance snapshot redesign: BizPortal AR summary + SARS placeholder; CIPC address labels; Math.abs amount fix

This commit is contained in:
Dave 2026-08-27 21:18:22 +02:00
parent a79145f0d2
commit ed3c6de272
4 changed files with 766 additions and 53 deletions

View file

@ -19,6 +19,8 @@ import ClientToggle from "@/components/ClientToggle";
import LinkedDirectorsList from "@/components/LinkedDirectorsList";
import DocumentsPanel from "@/components/DocumentsPanel";
import CompanyEditButton from "@/components/CompanyEditButton";
import ComplianceSnapshot from "@/components/ComplianceSnapshot";
import { fetchCollection } from "@/lib/pb";
export const dynamic = "force-dynamic";
@ -52,11 +54,14 @@ export default async function CompanyDetailPage({ params }: Props) {
return <StatusBadge variant="inactive">Inactive</StatusBadge>;
})();
const registrations = [
["SARS tax number", company.sars_tax_number],
["UIF registration", company.uif_registration_number],
["Compensation Fund", company.compensation_fund_registration_number]
] as const;
const registrations = null; // removed — replaced by ComplianceSnapshot
// Pre-fetch AR records for the compliance snapshot (server-side, no client fetch)
const arRecords = await fetchCollection("ysterbaard_annual_returns", {
filter: `company = "${params.id}"`,
sort: "ar_year,-ar_month",
perPage: 200,
});
return (
<>
@ -199,57 +204,20 @@ export default async function CompanyDetailPage({ params }: Props) {
{/* Company details */}
<div className="flex flex-col gap-6">
<section data-od-id="company-compliance" className="bg-surface border border-border rounded-lg p-6 md:p-8">
<div className="flex flex-wrap items-start justify-between gap-4 mb-6">
<div>
<h2 className="text-xl font-semibold text-fg">Compliance snapshot</h2>
<p className="text-sm text-muted mt-1">Registration and statutory details from the latest registry check.</p>
</div>
{company.ar_outstanding ? (
<StatusBadge variant="in_process">Annual return outstanding</StatusBadge>
) : (
<StatusBadge variant="active">Annual returns up to date</StatusBadge>
)}
</div>
{/* Compliance snapshot — BizPortal + SARS */}
<ComplianceSnapshot
companyId={company.id}
companyName={text(company.enterprise_name)}
initialRecords={arRecords.items as unknown as Parameters<typeof ComplianceSnapshot>[0]["initialRecords"]}
/>
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-px bg-border border border-border rounded-md overflow-hidden">
{registrations.map(([label, value]) => (
<div key={label} className="bg-surface p-4 min-w-0">
<p className="text-xs text-muted font-semibold mb-1">{label}</p>
<p className="font-mono text-sm text-fg break-words">{text(value)}</p>
</div>
))}
<div className="bg-surface p-4">
<p className="text-xs text-muted font-semibold mb-1">B-BBEE status</p>
<p className="text-sm text-fg">{text(company.bbbee_status)}</p>
</div>
<div className="bg-surface p-4">
<p className="text-xs text-muted font-semibold mb-1">B-BBEE certificate</p>
<p className="text-sm text-fg">{company.bbbee_certificate_applied ? "Applied" : "Not applied"}</p>
</div>
<div className="bg-surface p-4">
<p className="text-xs text-muted font-semibold mb-1">Information Regulator</p>
<p className="text-sm text-fg">{company.info_regulator_registered ? "Registered" : "Not registered"}</p>
</div>
</div>
{company.ar_outstanding_details ? (
<div className="mt-5 border-t border-border-soft pt-5">
<p className="text-xs text-muted font-semibold mb-1">Outstanding annual return</p>
<p className="text-sm text-fg-2 break-words">{text(company.ar_outstanding_details)}</p>
</div>
) : null}
{company.compliance_notice ? (
<div className="mt-4">
<p className="text-xs text-muted font-semibold mb-1">Compliance notice</p>
<p className="text-sm text-fg-2 break-words">{text(company.compliance_notice)}</p>
</div>
) : null}
</section>
<section data-od-id="company-addresses" className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-surface border border-border rounded-lg p-6">
<h2 className="text-lg font-semibold text-fg mb-3">Physical address</h2>
<p className="text-xs font-semibold text-muted mb-2">CIPC Physical address</p>
{company.physical_address ? (
<p className="text-sm text-fg-2 whitespace-pre-line break-words leading-relaxed">{text(company.physical_address)}</p>
) : null}
@ -278,6 +246,7 @@ export default async function CompanyDetailPage({ params }: Props) {
</div>
<div className="bg-surface border border-border rounded-lg p-6">
<h2 className="text-lg font-semibold text-fg mb-3">Postal address</h2>
<p className="text-xs font-semibold text-muted mb-2">CIPC Postal address</p>
{company.postal_address ? (
<p className="text-sm text-fg-2 whitespace-pre-line break-words leading-relaxed">{text(company.postal_address)}</p>
) : null}

View file

@ -0,0 +1,494 @@
"use client";
import { useEffect, useState } from "react";
interface AnnualReturn {
id: string;
ar_year: number;
ar_month: string;
is_filed: boolean;
date_filed: string;
amount_paid: number;
ar_non_compliance_date: string;
source: string;
}
interface Props {
companyId: string;
companyName: string;
}
const PB_URL = process.env.NEXT_PUBLIC_PB_URL ?? "";
const PB_TOKEN =
typeof window !== "undefined" ? localStorage.getItem("pb_token") ?? "" : "";
async function fetchAnnualReturns(companyId: string): Promise<AnnualReturn[]> {
const res = await fetch(
`${PB_URL}/api/collections/ysterbaard_annual_returns/records?perPage=200&filter=(company="${companyId}")&sort=ar_year,-ar_month`,
{ headers: { Authorization: `Bearer ${PB_TOKEN}` } }
);
if (!res.ok) return [];
const data = await res.json();
return data.items ?? [];
}
const MONTH_ORDER = [
"JANUARY",
"FEBRUARY",
"MARCH",
"APRIL",
"MAY",
"JUNE",
"JULY",
"AUGUST",
"SEPTEMBER",
"OCTOBER",
"NOVEMBER",
"DECEMBER",
];
function sortRecords(records: AnnualReturn[]) {
return [...records].sort((a, b) => {
if (b.ar_year !== a.ar_year) return b.ar_year - a.ar_year;
return MONTH_ORDER.indexOf(a.ar_month) - MONTH_ORDER.indexOf(b.ar_month);
});
}
function FiledBadge({ filed }: { filed: boolean }) {
return filed ? (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="none"
className="flex-shrink-0"
>
<path
d="M2 5l2.5 2.5L8 3"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
Filed
</span>
) : (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-amber-500/10 text-amber-400 border border-amber-500/20">
Outstanding
</span>
);
}
function SourceBadge({ source }: { source: string }) {
return (
<span className="text-xs text-muted-2 capitalize">{source}</span>
);
}
function formatAmount(amount: number) {
const abs = Math.abs(amount);
return abs > 0 ? `R ${abs.toLocaleString()}` : "—";
}
function formatDate(dateStr: string) {
if (!dateStr) return "—";
const d = new Date(dateStr + "T00:00:00");
return d.toLocaleDateString("en-ZA", {
year: "numeric",
month: "short",
day: "numeric",
});
}
export default function CipcAnnualReturns({ companyId, companyName }: Props) {
const [records, setRecords] = useState<AnnualReturn[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchAnnualReturns(companyId).then((data) => {
setRecords(data);
setLoading(false);
});
}, [companyId]);
const sorted = sortRecords(records);
const latestYear = sorted[0]?.ar_year;
// Split into: last-3-filed (most recent 3 filed records), outstanding, non-compliant
const filedRecords = sorted.filter((r) => r.is_filed);
const outstandingRecords = sorted.filter(
(r) => !r.is_filed && !r.ar_non_compliance_date
);
const nonCompliantRecords = sorted.filter(
(r) => !r.is_filed && Boolean(r.ar_non_compliance_date)
);
// Last 3 filed submissions
const lastThree = filedRecords.slice(0, 3);
// Overall compliance state
const hasNonCompliant = nonCompliantRecords.length > 0;
const hasOutstanding = outstandingRecords.length > 0;
const overallState = hasNonCompliant
? "non-compliant"
: hasOutstanding
? "outstanding"
: filedRecords.length > 0
? "compliant"
: "no-data";
const stateLabel = {
"non-compliant": "Non-compliant",
outstanding: "Outstanding",
compliant: "Compliant",
"no-data": "No data",
}[overallState];
const stateVariant = overallState; // used for chip colour
return (
<section
data-od-id="company-cipc-annual-returns"
className="bg-surface border border-border rounded-lg p-6 md:p-8"
>
{/* Header */}
<div className="flex flex-wrap items-start justify-between gap-4 mb-6">
<div>
<h2 className="text-xl font-semibold text-fg">
CIPC Annual Returns — Best Portal
</h2>
<p className="text-sm text-muted mt-1">
From ysterbaard_annual_returns — filed directly on CIPC.
</p>
</div>
{/* Overall state chips */}
<div className="flex items-center gap-2 flex-wrap">
<span
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border ${
overallState === "compliant"
? "bg-emerald-500/10 text-emerald-400 border-emerald-500/20"
: overallState === "non-compliant"
? "bg-red-500/10 text-red-400 border-red-500/20"
: overallState === "outstanding"
? "bg-amber-500/10 text-amber-400 border-amber-500/20"
: "bg-surface-warm text-muted border-border"
}`}
>
<span
className={`w-1.5 h-1.5 rounded-full ${
overallState === "compliant"
? "bg-emerald-400"
: overallState === "non-compliant"
? "bg-red-400"
: overallState === "outstanding"
? "bg-amber-400"
: "bg-muted"
}`}
/>
{stateLabel}
</span>
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold bg-surface-warm text-muted border border-border">
<span className="w-1.5 h-1.5 rounded-full bg-muted" />
No data
</span>
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold bg-amber-500/10 text-amber-400 border border-amber-500/20">
<span className="w-1.5 h-1.5 rounded-full bg-amber-400" />
Outstanding
</span>
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold bg-red-500/10 text-red-400 border border-red-500/20">
<span className="w-1.5 h-1.5 rounded-full bg-red-400" />
Non-compliant
</span>
</div>
</div>
{loading ? (
<p className="text-sm text-muted animate-pulse">
Loading annual returns…
</p>
) : sorted.length === 0 ? (
<p className="text-sm text-muted">
No annual return records found for {companyName}.
</p>
) : (
<div className="flex flex-col gap-5">
{/* ── Last 3 submissions table ── */}
{lastThree.length > 0 && (
<div>
<p className="text-xs uppercase tracking-widest font-semibold text-muted mb-3">
Last 3 submissions
</p>
<div className="overflow-hidden rounded-md border border-border">
<table className="w-full text-sm">
<thead>
<tr className="bg-border-soft border-b border-border">
<th className="text-left px-4 py-3 text-xs font-semibold text-muted uppercase tracking-wider">
Year
</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-muted uppercase tracking-wider">
Month
</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-muted uppercase tracking-wider">
Filing Status
</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-muted uppercase tracking-wider">
Date Filed
</th>
<th className="text-right px-4 py-3 text-xs font-semibold text-muted uppercase tracking-wider">
Amount Paid
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{lastThree.map((ret) => (
<tr
key={ret.id}
className="hover:bg-surface-warm/50 transition-colors"
>
<td className="px-4 py-3 font-mono font-semibold text-fg">
{ret.ar_year}
</td>
<td className="px-4 py-3 text-fg capitalize">
{ret.ar_month.toLowerCase()}
</td>
<td className="px-4 py-3">
<FiledBadge filed={ret.is_filed} />
</td>
<td className="px-4 py-3 font-mono text-muted text-xs">
{formatDate(ret.date_filed)}
</td>
<td className="px-4 py-3 text-right font-mono text-fg font-semibold">
{formatAmount(ret.amount_paid)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* ── Outstanding Annual Returns ── */}
{outstandingRecords.length > 0 && (
<div className="bg-surface-warm border border-amber-500/20 rounded-lg p-4">
<div className="flex items-center gap-2 mb-3">
{/* Warning triangle icon */}
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
className="flex-shrink-0 text-amber-400"
>
<path
d="M7 1.5L13 12H1L7 1.5Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinejoin="round"
/>
<path
d="M7 5.5v3"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
<circle cx="7" cy="10" r="0.75" fill="currentColor" />
</svg>
<p className="text-xs font-bold uppercase tracking-widest text-amber-400">
Outstanding Annual Returns
</p>
</div>
<div className="divide-y divide-border/50">
{outstandingRecords.slice(0, 10).map((ret) => (
<div
key={ret.id}
className="flex items-center justify-between py-2.5 text-sm"
>
<span className="text-fg-2">
{ret.ar_month.charAt(0) +
ret.ar_month.slice(1).toLowerCase()}{" "}
{ret.ar_year}
</span>
<span className="text-amber-400 font-semibold text-xs">
Due:{" "}
{new Date(ret.ar_year, MONTH_ORDER.indexOf(ret.ar_month), 31)
.toLocaleDateString("en-ZA", {
year: "numeric",
month: "short",
day: "numeric",
})
.replace("Invalid", "—")}
</span>
</div>
))}
{outstandingRecords.length > 10 && (
<p className="text-xs text-muted pt-2 text-center">
+{outstandingRecords.length - 10} more
</p>
)}
</div>
</div>
)}
{/* ── AR Non-Compliant State ── */}
{nonCompliantRecords.length > 0 && (
<div className="bg-red-500/5 border border-red-500/20 rounded-lg p-4">
<div className="flex items-center gap-2 mb-3">
{/* X-circle icon */}
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
className="flex-shrink-0 text-red-400"
>
<circle
cx="7"
cy="7"
r="6"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M4.5 4.5l5 5M9.5 4.5l-5 5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
<p className="text-xs font-bold uppercase tracking-widest text-red-400">
AR Non-Compliant State
</p>
</div>
<div className="divide-y divide-red-500/10">
{nonCompliantRecords.map((ret) => {
const complianceDate = ret.ar_non_compliance_date
? new Date(ret.ar_non_compliance_date + "T00:00:00")
: null;
const now = new Date();
const monthsOverdue =
complianceDate
? (now.getFullYear() - complianceDate.getFullYear()) * 12 +
(now.getMonth() - complianceDate.getMonth())
: null;
return (
<div
key={ret.id}
className="flex items-center justify-between py-2.5 text-sm"
>
<div className="flex flex-col">
<span className="text-red-300">
{ret.ar_month.charAt(0) +
ret.ar_month.slice(1).toLowerCase()}{" "}
{ret.ar_year}
</span>
</div>
<div className="flex flex-col items-end gap-0.5">
{complianceDate && (
<span className="text-red-400 font-mono text-xs">
Non-compliant:{" "}
{complianceDate.toLocaleDateString("en-ZA", {
year: "numeric",
month: "short",
day: "numeric",
})}
</span>
)}
{monthsOverdue !== null && (
<span className="text-red-500 text-xs font-semibold">
{monthsOverdue <= 0
? "< 1 month overdue"
: `${monthsOverdue} month${monthsOverdue !== 1 ? "s" : ""} overdue`}
</span>
)}
</div>
</div>
);
})}
</div>
</div>
)}
{/* ── Full history table (collapsible) ── */}
{sorted.length > 3 && (
<details className="group">
<summary className="cursor-pointer text-xs text-muted hover:text-fg transition-colors list-none flex items-center gap-1.5">
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
className="text-muted group-open:rotate-90 transition-transform"
>
<path
d="M4 2l4 4-4 4"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
Show all {sorted.length} records
</summary>
<div className="mt-3 overflow-hidden rounded-md border border-border">
<table className="w-full text-sm">
<thead>
<tr className="bg-border-soft border-b border-border">
<th className="text-left px-4 py-3 text-xs font-semibold text-muted uppercase tracking-wider">
Year
</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-muted uppercase tracking-wider">
Month
</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-muted uppercase tracking-wider">
Status
</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-muted uppercase tracking-wider">
Date Filed
</th>
<th className="text-right px-4 py-3 text-xs font-semibold text-muted uppercase tracking-wider">
Amount Paid
</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-muted uppercase tracking-wider">
Source
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{sorted.map((ret) => (
<tr
key={ret.id}
className="hover:bg-surface-warm/50 transition-colors"
>
<td className="px-4 py-3 font-mono font-semibold text-fg">
{ret.ar_year}
</td>
<td className="px-4 py-3 text-fg capitalize">
{ret.ar_month.toLowerCase()}
</td>
<td className="px-4 py-3">
<FiledBadge filed={ret.is_filed} />
</td>
<td className="px-4 py-3 font-mono text-muted text-xs">
{formatDate(ret.date_filed)}
</td>
<td className="px-4 py-3 text-right font-mono text-fg font-semibold">
{formatAmount(ret.amount_paid)}
</td>
<td className="px-4 py-3">
<SourceBadge source={ret.source} />
</td>
</tr>
))}
</tbody>
</table>
</div>
</details>
)}
</div>
)}
</section>
);
}

View file

@ -0,0 +1,213 @@
"use client";
import { useState } from "react";
interface AnnualReturn {
id: string;
ar_year: number;
ar_month: string;
is_filed: boolean;
date_filed: string;
amount_paid: number;
ar_non_compliance_date: string;
source: string;
}
interface Props {
companyId: string;
companyName: string;
/** Pre-fetched AR records from the server component */
initialRecords: AnnualReturn[];
}
const MONTH_ORDER = [
"JANUARY", "FEBRUARY", "MARCH", "APRIL", "MAY", "JUNE",
"JULY", "AUGUST", "SEPTEMBER", "OCTOBER", "NOVEMBER", "DECEMBER",
];
function sortRecords(records: AnnualReturn[]) {
return [...records].sort((a, b) => {
if (b.ar_year !== a.ar_year) return b.ar_year - a.ar_year;
return MONTH_ORDER.indexOf(a.ar_month) - MONTH_ORDER.indexOf(b.ar_month);
});
}
function filedBadge(filed: boolean) {
return filed ? (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" className="flex-shrink-0">
<path d="M2 5l2.5 2.5L8 3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
Filed
</span>
) : (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-amber-500/10 text-amber-400 border border-amber-500/20">
Outstanding
</span>
);
}
function formatAmount(amount: number) {
const abs = Math.abs(amount);
return abs > 0 ? `R ${abs.toLocaleString()}` : "—";
}
function formatDate(dateStr: string) {
if (!dateStr) return "—";
const d = new Date(dateStr + "T00:00:00");
return d.toLocaleDateString("en-ZA", { year: "numeric", month: "short", day: "numeric" });
}
function getNextSubmissionDate(latestYear: number | undefined) {
if (latestYear) {
const dueYear = latestYear + 1;
return `31 January ${dueYear}`;
}
const now = new Date();
return `31 January ${now.getFullYear()}`;
}
export default function ComplianceSnapshot({ companyId, companyName, initialRecords }: Props) {
const records = sortRecords(initialRecords);
const filedRecords = records.filter((r) => r.is_filed);
const lastThree = filedRecords.slice(0, 3);
const latestYear = filedRecords[0]?.ar_year;
// Non-compliant: unfilled with a non-compliance date set
const nonCompliant = records.find((r) => !r.is_filed && Boolean(r.ar_non_compliance_date));
return (
<section data-od-id="company-compliance" className="bg-surface border border-border rounded-lg p-6 md:p-8">
{/* Header */}
<div className="flex flex-wrap items-start justify-between gap-4 mb-6">
<div>
<h2 className="text-xl font-semibold text-fg">Compliance snapshot</h2>
</div>
</div>
<div className="flex flex-col gap-5">
{/* ── 1. BizPortal ── */}
<div className="border border-border rounded-lg overflow-hidden">
{/* BizPortal header */}
<div className="bg-border-soft px-5 py-4 border-b border-border flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
{/* BizPortal icon */}
<div className="w-8 h-8 rounded-lg bg-blue-500/10 border border-blue-500/20 flex items-center justify-center flex-shrink-0">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<rect x="2" y="2" width="5" height="5" rx="1" fill="#60a5fa" />
<rect x="9" y="2" width="5" height="5" rx="1" fill="#60a5fa" opacity="0.6" />
<rect x="2" y="9" width="5" height="5" rx="1" fill="#60a5fa" opacity="0.6" />
<rect x="9" y="9" width="5" height="5" rx="1" fill="#60a5fa" opacity="0.3" />
</svg>
</div>
<div>
<p className="text-sm font-bold text-fg">BizPortal — CIPC Annual Returns</p>
<p className="text-xs text-muted">From ysterbaard_annual_returns</p>
</div>
</div>
</div>
{/* BizPortal body */}
<div className="p-5">
{records.length === 0 ? (
<p className="text-sm text-muted py-2">No annual return records found for {companyName}.</p>
) : (
<div className="flex flex-col gap-5">
{/* Last 3 submissions */}
<div>
<p className="text-xs uppercase tracking-widest font-semibold text-muted mb-3">Last 3 submissions</p>
<div className="overflow-hidden rounded-md border border-border">
<table className="w-full text-sm">
<thead>
<tr className="bg-border-soft border-b border-border">
<th className="text-left px-4 py-3 text-xs font-semibold text-muted uppercase tracking-wider">Year</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-muted uppercase tracking-wider">Month</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-muted uppercase tracking-wider">Date Filed</th>
<th className="text-right px-4 py-3 text-xs font-semibold text-muted uppercase tracking-wider">Amount Paid</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-muted uppercase tracking-wider">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{lastThree.length > 0 ? lastThree.map((ret) => (
<tr key={ret.id} className="hover:bg-surface-warm/50 transition-colors">
<td className="px-4 py-3 font-mono font-semibold text-fg">{ret.ar_year}</td>
<td className="px-4 py-3 text-fg capitalize">{ret.ar_month.toLowerCase()}</td>
<td className="px-4 py-3 font-mono text-muted text-xs">{formatDate(ret.date_filed)}</td>
<td className="px-4 py-3 text-right font-mono text-fg font-semibold">{formatAmount(ret.amount_paid)}</td>
<td className="px-4 py-3">{filedBadge(ret.is_filed)}</td>
</tr>
)) : (
<tr>
<td colSpan={5} className="px-4 py-4 text-center text-sm text-muted">No filed submissions yet.</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{/* Next dates row */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="bg-surface-warm border border-border rounded-lg p-4">
<p className="text-xs font-semibold text-muted uppercase tracking-wider mb-1">Next submission date</p>
<p className="text-sm font-semibold text-fg">{getNextSubmissionDate(latestYear)}</p>
</div>
{nonCompliant ? (() => {
const d = new Date(nonCompliant.ar_non_compliance_date + "T00:00:00");
const now = new Date();
const months = (now.getFullYear() - d.getFullYear()) * 12 + (now.getMonth() - d.getMonth());
const overdueLabel = months <= 0 ? "< 1 month overdue" : `${months} month${months !== 1 ? "s" : ""} overdue`;
return (
<div className="bg-red-500/5 border border-red-500/20 rounded-lg p-4">
<p className="text-xs font-semibold text-red-400 uppercase tracking-wider mb-1">Non-compliance date</p>
<p className="text-sm font-semibold text-red-300">{formatDate(nonCompliant.ar_non_compliance_date)}</p>
<p className="text-xs text-red-500 mt-0.5">{overdueLabel}</p>
</div>
);
})() : (
<div className="bg-surface-warm border border-border rounded-lg p-4">
<p className="text-xs font-semibold text-muted uppercase tracking-wider mb-1">Non-compliance date</p>
<p className="text-sm text-muted">—</p>
</div>
)}
</div>
</div>
)}
</div>
</div>
{/* ── 2. SARS ── */}
<div className="border border-border rounded-lg overflow-hidden">
<div className="bg-border-soft px-5 py-4 border-b border-border flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
{/* SARS icon */}
<div className="w-8 h-8 rounded-lg bg-indigo-500/10 border border-indigo-500/20 flex items-center justify-center flex-shrink-0">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M8 2L14 5v6l-6 3L2 11V5l6-3z" stroke="#818cf8" strokeWidth="1.5" strokeLinejoin="round" />
</svg>
</div>
<div>
<p className="text-sm font-bold text-fg">SARS</p>
<p className="text-xs text-muted">Income Tax · IRP6 · PAYE</p>
</div>
</div>
</div>
<div className="p-8 flex flex-col items-center justify-center text-center gap-3">
<div className="w-10 h-10 rounded-full bg-surface-warm border border-border flex items-center justify-center">
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
<circle cx="9" cy="9" r="7" stroke="#475569" strokeWidth="1.5" />
<path d="M9 5.5v4M9 12h.01" stroke="#475569" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</div>
<div>
<p className="text-sm font-semibold text-fg">No data yet</p>
<p className="text-xs text-muted mt-1">SARS compliance data will appear here once populated in PocketBase.</p>
</div>
</div>
</div>
</div>
</section>
);
}

View file

@ -112,6 +112,7 @@ export default function EditEntityModal({ entity, record, open, onClose, onAfter
const [telephone, setTelephone] = useState("");
const [email, setEmail] = useState("");
const [postalAddress, setPostalAddress] = useState("");
const [residentialAddress, setResidentialAddress] = useState(""); // flat legacy — added 2026-08-20
const [directorType, setDirectorType] = useState("");
const [notes, setNotes] = useState("");
// Director structured address fields (added 2026-08-18)
@ -179,6 +180,7 @@ export default function EditEntityModal({ entity, record, open, onClose, onAfter
setTelephone(d.telephone ?? "");
setEmail(d.email ?? "");
setPostalAddress(d.postal_address ?? "");
setResidentialAddress(d.residential_address ?? "");
setDirectorType(d.director_type ?? "");
setNotes(d.notes ?? "");
setDateOfBirth(d.date_of_birth ?? "");
@ -268,6 +270,7 @@ export default function EditEntityModal({ entity, record, open, onClose, onAfter
fields.telephone = telephone.trim();
fields.email = email.trim();
fields.postal_address = postalAddress.trim();
fields.residential_address = residentialAddress.trim();
fields.director_type = directorType.trim();
fields.notes = notes.trim();
// Structured address fields
@ -324,6 +327,33 @@ export default function EditEntityModal({ entity, record, open, onClose, onAfter
fields.groups = selectedGroups;
}
// Pre-check for ID collisions on directors before PATCH (added 2026-08-20).
// PB's UNIQUE index on id_no_masked rejects the PATCH with a 502 if
// the typed id_no masks to a value already on another director.
// Detect this client-side and offer to open the existing record.
if (isDirector && fields.id_no && (fields.id_no as string).length >= 12) {
const typedId = fields.id_no as string;
const masked = typedId.slice(0, 6) + "XXXX" + typedId.slice(10, 12) + "X";
try {
const existing = await pbList<{ id: string; surname: string; first_names: string; id_no_masked: string }>({
collection: "ysterbaard_directors",
perPage: 5,
filter: `id_no_masked="${masked}" || id_no="${typedId}"`,
});
const conflict = existing.items.find((d) => d.id !== record.id);
if (conflict) {
setLoading(false);
setError(
`A director with this ID already exists: ${conflict.surname}, ${conflict.first_names}. ` +
`Open their record instead?`
);
return;
}
} catch {
// If pre-check fails, let the PATCH proceed and surface the 502.
}
}
await pbPatch({ collection, id: record.id, fields });
// Invalidate both list and detail caches
@ -431,12 +461,19 @@ export default function EditEntityModal({ entity, record, open, onClose, onAfter
</div>
<div>
<label className="block text-sm font-semibold text-fg mb-1.5" htmlFor="edit-postal">Postal address <span className="text-xs text-muted font-normal">(optional, free-text)</span></label>
<label className="block text-sm font-semibold text-fg mb-1.5" htmlFor="edit-postal">CIPC Postal address <span className="text-xs text-muted font-normal">(optional, free-text)</span></label>
<textarea id="edit-postal" value={postalAddress} rows={2}
onChange={(e) => setPostalAddress(e.target.value)}
className="w-full rounded-lg border border-border bg-surface-warm px-3 py-2.5 text-sm text-fg resize-none focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent" />
</div>
<div>
<label className="block text-sm font-semibold text-fg mb-1.5" htmlFor="edit-residential">Residential address <span className="text-xs text-muted font-normal">(optional, free-text)</span></label>
<textarea id="edit-residential" value={residentialAddress} rows={2}
onChange={(e) => setResidentialAddress(e.target.value)}
className="w-full rounded-lg border border-border bg-surface-warm px-3 py-2.5 text-sm text-fg resize-none focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent" />
</div>
{/* Structured residential address (added 2026-08-18) */}
<div className="border-t border-border pt-4 mt-2">
<p className="text-sm font-semibold text-fg mb-3">Residential address <span className="text-xs text-muted font-normal">(structured)</span></p>
@ -575,14 +612,14 @@ export default function EditEntityModal({ entity, record, open, onClose, onAfter
</div>
<div>
<label className="block text-sm font-semibold text-fg mb-1.5" htmlFor="edit-phys">Physical address <span className="text-xs text-muted font-normal">(optional, free-text)</span></label>
<label className="block text-sm font-semibold text-fg mb-1.5" htmlFor="edit-phys">CIPC Physical address <span className="text-xs text-muted font-normal">(optional, free-text)</span></label>
<textarea id="edit-phys" value={physicalAddress} rows={2}
onChange={(e) => setPhysicalAddress(e.target.value)}
className="w-full rounded-lg border border-border bg-surface-warm px-3 py-2.5 text-sm text-fg resize-none focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent" />
</div>
<div>
<label className="block text-sm font-semibold text-fg mb-1.5" htmlFor="edit-postalco">Postal address <span className="text-xs text-muted font-normal">(optional, free-text)</span></label>
<label className="block text-sm font-semibold text-fg mb-1.5" htmlFor="edit-postalco">CIPC Postal address <span className="text-xs text-muted font-normal">(optional, free-text)</span></label>
<textarea id="edit-postalco" value={postalAddressCo} rows={2}
onChange={(e) => setPostalAddressCo(e.target.value)}
className="w-full rounded-lg border border-border bg-surface-warm px-3 py-2.5 text-sm text-fg resize-none focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent" />