diff --git a/app/companies/[id]/page.tsx b/app/companies/[id]/page.tsx index 8c8a9ae..b288c7b 100644 --- a/app/companies/[id]/page.tsx +++ b/app/companies/[id]/page.tsx @@ -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 Inactive; })(); - 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 */}
-
-
-
-

Compliance snapshot

-

Registration and statutory details from the latest registry check.

-
- {company.ar_outstanding ? ( - Annual return outstanding - ) : ( - Annual returns up to date - )} -
+ {/* Compliance snapshot — BizPortal + SARS */} + [0]["initialRecords"]} + /> + -
- {registrations.map(([label, value]) => ( -
-

{label}

-

{text(value)}

-
- ))} -
-

B-BBEE status

-

{text(company.bbbee_status)}

-
-
-

B-BBEE certificate

-

{company.bbbee_certificate_applied ? "Applied" : "Not applied"}

-
-
-

Information Regulator

-

{company.info_regulator_registered ? "Registered" : "Not registered"}

-
-
- {company.ar_outstanding_details ? ( -
-

Outstanding annual return

-

{text(company.ar_outstanding_details)}

-
- ) : null} - {company.compliance_notice ? ( -
-

Compliance notice

-

{text(company.compliance_notice)}

-
- ) : null} -

Physical address

+

CIPC Physical address

{company.physical_address ? (

{text(company.physical_address)}

) : null} @@ -278,6 +246,7 @@ export default async function CompanyDetailPage({ params }: Props) {

Postal address

+

CIPC Postal address

{company.postal_address ? (

{text(company.postal_address)}

) : null} diff --git a/components/CipcAnnualReturns.tsx b/components/CipcAnnualReturns.tsx new file mode 100644 index 0000000..04fc597 --- /dev/null +++ b/components/CipcAnnualReturns.tsx @@ -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 { + 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 ? ( + + + + + Filed + + ) : ( + + Outstanding + + ); +} + +function SourceBadge({ source }: { source: string }) { + return ( + {source} + ); +} + +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([]); + 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 ( +
+ {/* Header */} +
+
+

+ CIPC Annual Returns — Best Portal +

+

+ From ysterbaard_annual_returns — filed directly on CIPC. +

+
+ {/* Overall state chips */} +
+ + + {stateLabel} + + + + No data + + + + Outstanding + + + + Non-compliant + +
+
+ + {loading ? ( +

+ Loading annual returns… +

+ ) : sorted.length === 0 ? ( +

+ No annual return records found for {companyName}. +

+ ) : ( +
+ {/* ── Last 3 submissions table ── */} + {lastThree.length > 0 && ( +
+

+ Last 3 submissions +

+
+ + + + + + + + + + + + {lastThree.map((ret) => ( + + + + + + + + ))} + +
+ Year + + Month + + Filing Status + + Date Filed + + Amount Paid +
+ {ret.ar_year} + + {ret.ar_month.toLowerCase()} + + + + {formatDate(ret.date_filed)} + + {formatAmount(ret.amount_paid)} +
+
+
+ )} + + {/* ── Outstanding Annual Returns ── */} + {outstandingRecords.length > 0 && ( +
+
+ {/* Warning triangle icon */} + + + + + +

+ Outstanding Annual Returns +

+
+
+ {outstandingRecords.slice(0, 10).map((ret) => ( +
+ + {ret.ar_month.charAt(0) + + ret.ar_month.slice(1).toLowerCase()}{" "} + {ret.ar_year} + + + Due:{" "} + {new Date(ret.ar_year, MONTH_ORDER.indexOf(ret.ar_month), 31) + .toLocaleDateString("en-ZA", { + year: "numeric", + month: "short", + day: "numeric", + }) + .replace("Invalid", "—")} + +
+ ))} + {outstandingRecords.length > 10 && ( +

+ +{outstandingRecords.length - 10} more +

+ )} +
+
+ )} + + {/* ── AR Non-Compliant State ── */} + {nonCompliantRecords.length > 0 && ( +
+
+ {/* X-circle icon */} + + + + +

+ AR Non-Compliant State +

+
+
+ {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 ( +
+
+ + {ret.ar_month.charAt(0) + + ret.ar_month.slice(1).toLowerCase()}{" "} + {ret.ar_year} + +
+
+ {complianceDate && ( + + Non-compliant:{" "} + {complianceDate.toLocaleDateString("en-ZA", { + year: "numeric", + month: "short", + day: "numeric", + })} + + )} + {monthsOverdue !== null && ( + + {monthsOverdue <= 0 + ? "< 1 month overdue" + : `${monthsOverdue} month${monthsOverdue !== 1 ? "s" : ""} overdue`} + + )} +
+
+ ); + })} +
+
+ )} + + {/* ── Full history table (collapsible) ── */} + {sorted.length > 3 && ( +
+ + + + + Show all {sorted.length} records + +
+ + + + + + + + + + + + + {sorted.map((ret) => ( + + + + + + + + + ))} + +
+ Year + + Month + + Status + + Date Filed + + Amount Paid + + Source +
+ {ret.ar_year} + + {ret.ar_month.toLowerCase()} + + + + {formatDate(ret.date_filed)} + + {formatAmount(ret.amount_paid)} + + +
+
+
+ )} +
+ )} +
+ ); +} diff --git a/components/ComplianceSnapshot.tsx b/components/ComplianceSnapshot.tsx new file mode 100644 index 0000000..39c68cc --- /dev/null +++ b/components/ComplianceSnapshot.tsx @@ -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 ? ( + + + + + Filed + + ) : ( + + Outstanding + + ); +} + +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 ( +
+ {/* Header */} +
+
+

Compliance snapshot

+
+
+ +
+ + {/* ── 1. BizPortal ── */} +
+ {/* BizPortal header */} +
+
+ {/* BizPortal icon */} +
+ + + + + + +
+
+

BizPortal — CIPC Annual Returns

+

From ysterbaard_annual_returns

+
+
+
+ + {/* BizPortal body */} +
+ {records.length === 0 ? ( +

No annual return records found for {companyName}.

+ ) : ( +
+ {/* Last 3 submissions */} +
+

Last 3 submissions

+
+ + + + + + + + + + + + {lastThree.length > 0 ? lastThree.map((ret) => ( + + + + + + + + )) : ( + + + + )} + +
YearMonthDate FiledAmount PaidStatus
{ret.ar_year}{ret.ar_month.toLowerCase()}{formatDate(ret.date_filed)}{formatAmount(ret.amount_paid)}{filedBadge(ret.is_filed)}
No filed submissions yet.
+
+
+ + {/* Next dates row */} +
+
+

Next submission date

+

{getNextSubmissionDate(latestYear)}

+
+ {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 ( +
+

Non-compliance date

+

{formatDate(nonCompliant.ar_non_compliance_date)}

+

{overdueLabel}

+
+ ); + })() : ( +
+

Non-compliance date

+

—

+
+ )} +
+
+ )} +
+
+ + {/* ── 2. SARS ── */} +
+
+
+ {/* SARS icon */} +
+ + + +
+
+

SARS

+

Income Tax · IRP6 · PAYE

+
+
+
+
+
+ + + + +
+
+

No data yet

+

SARS compliance data will appear here once populated in PocketBase.

+
+
+
+ +
+
+ ); +} diff --git a/components/EditEntityModal.tsx b/components/EditEntityModal.tsx index d6e4b33..50bf22d 100644 --- a/components/EditEntityModal.tsx +++ b/components/EditEntityModal.tsx @@ -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
- +