Workflow-App/Workflow/app/compliance/page.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

288 lines
9.3 KiB
TypeScript

// app/compliance/page.tsx
// Compliance dashboard — tree grid of client companies' submission status
"use client";
import { useEffect, useState, useCallback } from "react";
import ComplianceTreeGrid, {
FILTER_TYPE_KEYS,
FILTER_TYPE_LABELS,
FILTER_COLOURS,
FILTER_COLOUR_LABELS,
FILTER_COLOUR_BG,
type FilterType,
} from "@/components/ComplianceTreeGrid";
type Group = { id: string; name: string; description: string };
type Company = {
id: string;
enterprise_name: string;
enterprise_no: string;
registration_date: string;
financial_year_end_month: number;
groups: string[];
/** SARS PAYE reference number (e.g. "7760830689"). Empty/null → no PAYE registration. */
sars_paye_no: string | null;
/** SARS VAT reference number (e.g. "4520278641"). Empty/null → no VAT registration. */
sars_vat_no: string | null;
/** VAT cadence: 0=None, 1=Every Month, 2=Even Months, 3=Uneven Months, 6=Bi-Annually, 12=Annually. */
sars_vat_returns_period: number | null;
};
type AnnualReturn = {
id: string;
company: string;
ar_year: number;
ar_month: string;
is_filed: boolean;
date_filed: string;
amount_paid: number;
};
type IRP6Record = {
id: string;
company: string;
return_type: string;
period: string;
date_filed: string;
amount_paid: number;
};
type PayeRecord = {
id: string;
company: string;
period: string;
Return_Filed: boolean;
total_payable: number;
paye_amount: number;
sdl_amount: number;
uif_amount: number;
date_submitted: string;
date_paid: string;
due_date: string;
payment_reference: string;
status: string;
last_updated: string;
};
type VatRecord = {
id: string;
company: string;
period: string;
Return_Filed: boolean;
total_payable: number;
date_submitted: string;
date_paid: string;
due_date: string;
payment_reference: string;
status: string;
last_updated: string;
};
type ApiResponse = {
companies: Company[];
annualReturns: AnnualReturn[];
irp6Records: IRP6Record[];
payeRecords: PayeRecord[];
vatRecords: VatRecord[];
error?: string;
};
export default function CompliancePage() {
const [companies, setCompanies] = useState<Company[]>([]);
const [annualReturns, setAnnualReturns] = useState<AnnualReturn[]>([]);
const [irp6Records, setIrp6Records] = useState<IRP6Record[]>([]);
const [payeRecords, setPayeRecords] = useState<PayeRecord[]>([]);
const [vatRecords, setVatRecords] = useState<VatRecord[]>([]);
const [groups, setGroups] = useState<Group[]>([]);
const [selectedGroup, setSelectedGroup] = useState("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
// Filter chip state — every chip starts ON so the default view
// shows everything. Deselecting a chip removes that type/colour
// from the "passes filter" set.
const [selectedTypes, setSelectedTypes] = useState<Set<FilterType>>(
() => new Set(FILTER_TYPE_KEYS)
);
const [selectedColours, setSelectedColours] = useState<Set<"green" | "orange" | "blue" | "red" | "grey">>(
() => new Set(FILTER_COLOURS)
);
function toggleType(t: FilterType) {
setSelectedTypes((prev) => {
const next = new Set(prev);
if (next.has(t)) next.delete(t);
else next.add(t);
return next;
});
}
function toggleColour(c: "green" | "orange" | "blue" | "red" | "grey") {
setSelectedColours((prev) => {
const next = new Set(prev);
if (next.has(c)) next.delete(c);
else next.add(c);
return next;
});
}
const loadData = useCallback(async (groupId: string) => {
setLoading(true);
setError("");
try {
const params = groupId ? `?groupId=${encodeURIComponent(groupId)}` : "";
const res = await fetch(`/api/compliance${params}`);
const data: ApiResponse = await res.json();
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
setCompanies(data.companies);
setAnnualReturns(data.annualReturns);
setIrp6Records(data.irp6Records);
setPayeRecords(data.payeRecords ?? []);
setVatRecords(data.vatRecords ?? []);
} catch (e) {
setError(String(e));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetch("/api/compliance/groups")
.then((r) => r.json())
.then((d) => setGroups(d.groups ?? []))
.catch(() => {});
}, []);
useEffect(() => {
loadData(selectedGroup);
}, [selectedGroup, loadData]);
return (
<main className="container-x py-8 space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-fg">Compliance</h1>
<p className="text-sm text-muted mt-1">
Client company regulatory submission status
</p>
</div>
<div className="flex items-center gap-3">
<label htmlFor="group-filter" className="text-sm text-muted font-medium whitespace-nowrap">
Client group:
</label>
<select
id="group-filter"
value={selectedGroup}
onChange={(e) => setSelectedGroup(e.target.value)}
className="border border-border rounded-md px-3 py-1.5 text-sm bg-surface text-fg focus:outline-none focus:ring-2 focus:ring-accent"
>
<option value="">All clients</option>
{groups.map((g) => (
<option key={g.id} value={g.id}>
{g.description || g.name}
</option>
))}
</select>
</div>
</div>
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700 text-sm">
{error}
</div>
)}
{/* Filter chips — always visible so the user can narrow the list
while data is loading or once it has arrived. */}
<div className="flex flex-col gap-3 bg-surface border border-border rounded-lg p-4">
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3">
<label className="text-sm text-muted font-medium whitespace-nowrap sm:w-32">
Submission type:
</label>
<div className="flex flex-wrap gap-2">
{FILTER_TYPE_KEYS.map((t) => {
const on = selectedTypes.has(t);
return (
<button
key={t}
type="button"
onClick={() => toggleType(t)}
aria-pressed={on}
className={
"px-3 py-1 rounded-md text-sm font-medium border transition-colors " +
(on
? "bg-success text-white border-success hover:opacity-90"
: "bg-surface text-muted border-border hover:text-fg hover:border-success/40")
}
title={
on
? `Hide companies without ${FILTER_TYPE_LABELS[t]} data`
: `Show only companies with ${FILTER_TYPE_LABELS[t]} data`
}
>
{FILTER_TYPE_LABELS[t]}
</button>
);
})}
</div>
</div>
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3">
<label className="text-sm text-muted font-medium whitespace-nowrap sm:w-32">
Compliance dot:
</label>
<div className="flex flex-wrap gap-2">
{FILTER_COLOURS.map((c) => {
const on = selectedColours.has(c);
return (
<button
key={c}
type="button"
onClick={() => toggleColour(c)}
aria-pressed={on}
className={
"inline-flex items-center gap-2 px-3 py-1 rounded-md text-sm font-medium border transition-colors " +
(on
? "bg-success text-white border-success hover:opacity-90"
: "bg-surface text-muted border-border hover:text-fg hover:border-success/40")
}
title={
on
? `Hide companies whose overall status is not ${FILTER_COLOUR_LABELS[c]}`
: `Show only companies whose overall status is ${FILTER_COLOUR_LABELS[c]}`
}
>
<span className={`inline-block w-2.5 h-2.5 rounded-full ${FILTER_COLOUR_BG[c]}`} />
{FILTER_COLOUR_LABELS[c]}
</button>
);
})}
</div>
</div>
</div>
{loading ? (
<div className="flex items-center justify-center py-20">
<div className="animate-spin w-8 h-8 border-2 border-accent border-t-transparent rounded-full" />
</div>
) : companies.length === 0 ? (
<div className="bg-surface border border-border rounded-lg p-12 text-center text-muted">
No client companies found
{selectedGroup ? " for this group" : ""}
</div>
) : (
<ComplianceTreeGrid
companies={companies}
annualReturns={annualReturns}
irp6Records={irp6Records}
payeRecords={payeRecords}
vatRecords={vatRecords}
selectedTypes={selectedTypes}
selectedColours={selectedColours}
/>
)}
</main>
);
}