Workflow-App/Workflow/components/ComplianceSnapshot.tsx
sjcoetzeesj-coder 11d0178ab1 feat: commit Workflow app source; move CIPC credentials to env
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.
2026-10-18 15:21:45 +02:00

489 lines
22 KiB
TypeScript

"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 TaxReturn {
id: string;
company: string;
return_type: "IRP6" | "IncomeTax";
period: string;
date_filed: string;
amount_paid: number;
}
interface Props {
companyId: string;
companyName: string;
initialRecords: AnnualReturn[];
irp6Records: TaxReturn[];
incomeTaxRecords: TaxReturn[];
}
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()}`;
}
// ── SARS Returns Section ────────────────────────────────────────────────────────
function formatPeriod(period: string, returnType: string) {
if (returnType === "IRP6") {
if (period.length === 6) {
const year = period.slice(0, 4);
const num = period.slice(4);
return `${num === "1" ? "1st" : "2nd"} period ${year}`;
}
return period;
}
return period; // Income Tax: just the year
}
function SARSReturnsSection({ irp6Records, incomeTaxRecords }: { irp6Records: TaxReturn[]; incomeTaxRecords: TaxReturn[] }) {
if (irp6Records.length === 0 && incomeTaxRecords.length === 0) {
return (
<div className="flex flex-col items-center justify-center text-center gap-3 py-6">
<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 filings recorded yet</p>
<p className="text-xs text-muted mt-1">Click "Record New Filing" to add an IRP6 or Income Tax submission.</p>
</div>
</div>
);
}
const latestIRP6 = [...irp6Records].sort((a, b) => b.period.localeCompare(a.period))[0];
const latestIncomeTax = [...incomeTaxRecords].sort((a, b) => b.period.localeCompare(a.period))[0];
return (
<div className="flex flex-col gap-5">
{/* IRP6 */}
<div>
<div className="flex items-center gap-2 mb-2">
<span className="text-xs font-bold uppercase tracking-widest text-indigo-400">IRP6 — Provisional Tax</span>
{latestIRP6 && (
<span className="text-xs text-muted">· Latest: {formatPeriod(latestIRP6.period, "IRP6")}</span>
)}
</div>
<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">Period</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</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{irp6Records.slice(0, 5).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">{formatPeriod(ret.period, "IRP6")}</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>
))}
{irp6Records.length === 0 && (
<tr>
<td colSpan={3} className="px-4 py-4 text-center text-sm text-muted">No IRP6 filings.</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{/* Income Tax */}
<div>
<div className="flex items-center gap-2 mb-2">
<span className="text-xs font-bold uppercase tracking-widest text-indigo-400">Income Tax</span>
{latestIncomeTax && (
<span className="text-xs text-muted">· Latest: {latestIncomeTax.period} year of assessment</span>
)}
</div>
<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">Date Filed</th>
<th className="text-right px-4 py-3 text-xs font-semibold text-muted uppercase tracking-wider">Amount</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{incomeTaxRecords.slice(0, 5).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.period}</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>
))}
{incomeTaxRecords.length === 0 && (
<tr>
<td colSpan={3} className="px-4 py-4 text-center text-sm text-muted">No Income Tax filings.</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
);
}
// ── New Filing Modal ──────────────────────────────────────────────────────────
function NewFilingModal({ companyId, onClose, onSuccess }: { companyId: string; onClose: () => void; onSuccess: () => void }) {
const [returnType, setReturnType] = useState<"IRP6" | "IncomeTax">("IRP6");
const [period, setPeriod] = useState("");
const [dateFiled, setDateFiled] = useState("");
const [amountPaid, setAmountPaid] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!period.trim() || !dateFiled) {
setError("Period and date filed are required.");
return;
}
setSaving(true);
setError("");
const fields: Record<string, unknown> = {
company: companyId,
return_type: returnType,
period: period.trim(),
date_filed: dateFiled,
amount_paid: amountPaid ? parseFloat(amountPaid) : 0,
};
fetch("/api/pb/records/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ collection: "ysterbaard_sars_irp6_income_tax", fields }),
})
.then((r) => r.json())
.then((data) => {
if (data.error) throw new Error(data.error);
onSuccess();
})
.catch((err: Error) => {
setError(err.message || "Failed to save. Please try again.");
setSaving(false);
});
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={(e) => e.target === e.currentTarget && onClose()}>
<div className="bg-surface border border-border rounded-xl shadow-2xl w-full max-w-md mx-4">
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<h3 className="text-lg font-semibold text-fg">Record SARS Filing</h3>
<button onClick={onClose} className="text-muted hover:text-fg transition-colors">
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
<path d="M4 4l10 10M14 4L4 14" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 flex flex-col gap-5">
{/* Return type */}
<div>
<label className="block text-xs font-semibold text-muted uppercase tracking-wider mb-2">Return Type</label>
<div className="grid grid-cols-2 gap-2">
{(["IRP6", "IncomeTax"] as const).map((t) => (
<button
key={t}
type="button"
onClick={() => setReturnType(t)}
className={`px-4 py-2.5 rounded-md border text-sm font-semibold transition-colors ${
returnType === t
? "bg-indigo-600 border-indigo-500 text-white"
: "bg-surface-warm border-border text-fg hover:border-indigo-400"
}`}
>
{t === "IRP6" ? "IRP6 — Provisional Tax" : "Income Tax"}
</button>
))}
</div>
</div>
{/* Period */}
<div>
<label className="block text-xs font-semibold text-muted uppercase tracking-wider mb-2">
Period <span className="text-red-400">*</span>
</label>
<input
type="text"
value={period}
onChange={(e) => setPeriod(e.target.value)}
placeholder={returnType === "IRP6" ? "e.g. 202602" : "e.g. 2023"}
className="w-full px-4 py-2.5 rounded-md border border-border bg-background text-fg text-sm placeholder-muted focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500"
required
/>
<p className="text-xs text-muted mt-1">
{returnType === "IRP6" ? "Year + 1 or 2 (e.g. 202602 = 2nd period 2026)" : "Year of assessment (e.g. 2023)"}
</p>
</div>
{/* Date filed */}
<div>
<label className="block text-xs font-semibold text-muted uppercase tracking-wider mb-2">
Date Filed <span className="text-red-400">*</span>
</label>
<input
type="date"
value={dateFiled}
onChange={(e) => setDateFiled(e.target.value)}
className="w-full px-4 py-2.5 rounded-md border border-border bg-background text-fg text-sm focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500"
required
/>
</div>
{/* Amount paid */}
<div>
<label className="block text-xs font-semibold text-muted uppercase tracking-wider mb-2">Amount Paid (R)</label>
<input
type="number"
value={amountPaid}
onChange={(e) => setAmountPaid(e.target.value)}
placeholder="0.00"
step="0.01"
min="0"
className="w-full px-4 py-2.5 rounded-md border border-border bg-background text-fg text-sm placeholder-muted focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500"
/>
</div>
{error && (
<div className="px-4 py-3 rounded-md bg-red-500/10 border border-red-500/30 text-sm text-red-400">
{error}
</div>
)}
<div className="flex justify-end gap-3 pt-2">
<button
type="button"
onClick={onClose}
className="px-4 py-2.5 rounded-md border border-border text-sm font-semibold text-fg hover:bg-surface-warm transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={saving}
className="px-6 py-2.5 rounded-md bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-semibold transition-colors disabled:opacity-50"
>
{saving ? "Saving…" : "Save Filing"}
</button>
</div>
</form>
</div>
</div>
);
}
// ── Main Component ────────────────────────────────────────────────────────────
export default function ComplianceSnapshot({ companyId, companyName, initialRecords, irp6Records, incomeTaxRecords }: Props) {
const [showFilingModal, setShowFilingModal] = useState(false);
const records = sortRecords(initialRecords);
const filedRecords = records.filter((r) => r.is_filed);
const lastThree = filedRecords.slice(0, 3);
const latestYear = filedRecords[0]?.ar_year;
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">
<div className="flex flex-wrap items-start justify-between gap-4 mb-6">
<h2 className="text-xl font-semibold text-fg">Compliance snapshot</h2>
</div>
<div className="flex flex-col gap-5">
{/* ── 1. BizPortal ── */}
<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">
<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>
<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">
<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>
<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">
<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">IRP6 · Income Tax</p>
</div>
</div>
<button
onClick={() => setShowFilingModal(true)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-semibold transition-colors"
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<path d="M6 1v10M1 6h10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
Record New Filing
</button>
</div>
<div className="p-5">
<SARSReturnsSection irp6Records={irp6Records} incomeTaxRecords={incomeTaxRecords} />
</div>
</div>
</div>
</section>
{showFilingModal && (
<NewFilingModal
companyId={companyId}
onClose={() => setShowFilingModal(false)}
onSuccess={() => {
setShowFilingModal(false);
window.location.reload();
}}
/>
)}
</>
);
}