Compliance snapshot redesign: BizPortal AR summary + SARS placeholder; CIPC address labels; Math.abs amount fix
This commit is contained in:
parent
a79145f0d2
commit
ed3c6de272
4 changed files with 766 additions and 53 deletions
|
|
@ -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}
|
||||
|
|
|
|||
494
components/CipcAnnualReturns.tsx
Normal file
494
components/CipcAnnualReturns.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
213
components/ComplianceSnapshot.tsx
Normal file
213
components/ComplianceSnapshot.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
Loading…
Reference in a new issue