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.
213 lines
9.1 KiB
TypeScript
213 lines
9.1 KiB
TypeScript
// components/GroupReport.tsx
|
|
// Client component for group report table — sortable, exportable.
|
|
|
|
"use client";
|
|
|
|
import { useState } from "react";
|
|
import Link from "next/link";
|
|
import type { YsterbaardGroup, YsterbaardCompany } from "@/lib/types";
|
|
|
|
interface Props {
|
|
group: YsterbaardGroup;
|
|
companies: YsterbaardCompany[];
|
|
}
|
|
|
|
type SortKey = "enterprise_name" | "enterprise_no" | "sars_tax_number" | "sars_vat_no" | "sars_paye_no" | "enterprise_status";
|
|
|
|
export default function GroupReport({ group, companies }: Props) {
|
|
const [sortKey, setSortKey] = useState<SortKey>("enterprise_name");
|
|
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
|
|
|
|
const sorted = [...companies].sort((a, b) => {
|
|
const av = String((a[sortKey] ?? "") || "");
|
|
const bv = String((b[sortKey] ?? "") || "");
|
|
const cmp = av.localeCompare(bv);
|
|
return sortDir === "asc" ? cmp : -cmp;
|
|
});
|
|
|
|
function toggleSort(k: SortKey) {
|
|
if (k === sortKey) {
|
|
setSortDir(d => d === "asc" ? "desc" : "asc");
|
|
} else {
|
|
setSortKey(k);
|
|
setSortDir("asc");
|
|
}
|
|
}
|
|
|
|
function sortIcon(k: SortKey) {
|
|
if (k !== sortKey) return "↕";
|
|
return sortDir === "asc" ? "↑" : "↓";
|
|
}
|
|
|
|
function exportCSV() {
|
|
const headers = ["Enterprise No", "Company Name", "Income Tax", "VAT", "PAYE", "Status", "Edit", "View"];
|
|
const rows = sorted.map(c => [
|
|
c.enterprise_no || "",
|
|
c.enterprise_name || "",
|
|
c.sars_tax_number || "",
|
|
c.sars_vat_no || "",
|
|
c.sars_paye_no || "",
|
|
c.enterprise_status || "",
|
|
`/companies/${c.id}`,
|
|
`/companies/${c.id}`,
|
|
]);
|
|
const csv = [headers, ...rows]
|
|
.map(r => r.map(cell => `"${String(cell).replace(/"/g, '""')}"`).join(","))
|
|
.join("\n");
|
|
const blob = new Blob([csv], { type: "text/csv" });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = `${group.name}-report-${new Date().toISOString().slice(0, 10)}.csv`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
const stats = {
|
|
total: companies.length,
|
|
withTax: companies.filter(c => c.sars_tax_number).length,
|
|
withVat: companies.filter(c => c.sars_vat_no).length,
|
|
withPaye: companies.filter(c => c.sars_paye_no).length,
|
|
inBusiness: companies.filter(c => (c.enterprise_status || "").toLowerCase().includes("business")).length,
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Stats cards */}
|
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
|
<StatCard label="Total Companies" value={stats.total} />
|
|
<StatCard label="In Business" value={stats.inBusiness} highlight={stats.inBusiness > 0 ? "green" : "muted"} />
|
|
<StatCard label="With Tax #" value={stats.withTax} highlight={stats.withTax === stats.total ? "green" : stats.withTax === 0 ? "red" : "muted"} />
|
|
<StatCard label="With VAT #" value={stats.withVat} highlight={stats.withVat === stats.total ? "green" : stats.withVat === 0 ? "red" : "muted"} />
|
|
<StatCard label="With PAYE #" value={stats.withPaye} highlight={stats.withPaye === stats.total ? "green" : stats.withPaye === 0 ? "red" : "muted"} />
|
|
</div>
|
|
|
|
{/* Toolbar */}
|
|
<div className="flex items-center justify-between gap-3">
|
|
<p className="text-sm text-muted">
|
|
Showing {sorted.length} of {companies.length} compan{companies.length === 1 ? "y" : "ies"}
|
|
</p>
|
|
<button
|
|
onClick={exportCSV}
|
|
className="text-xs px-4 py-2 rounded border border-border bg-surface text-fg font-semibold hover:border-accent hover:text-accent transition-colors"
|
|
>
|
|
⬇ Export CSV
|
|
</button>
|
|
</div>
|
|
|
|
{/* Report table */}
|
|
{sorted.length === 0 ? (
|
|
<div className="bg-surface border border-border rounded-lg p-12 text-center text-muted">
|
|
No companies are currently in the {group.name} group.
|
|
</div>
|
|
) : (
|
|
<div className="bg-surface border border-border rounded-lg overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-surface-warm text-muted text-xs uppercase tracking-wide">
|
|
<tr>
|
|
<SortHeader label="Enterprise No" sortKey="enterprise_no" current={sortKey} dir={sortDir} onSort={toggleSort} icon={sortIcon} />
|
|
<SortHeader label="Company Name" sortKey="enterprise_name" current={sortKey} dir={sortDir} onSort={toggleSort} icon={sortIcon} />
|
|
<SortHeader label="Income Tax #" sortKey="sars_tax_number" current={sortKey} dir={sortDir} onSort={toggleSort} icon={sortIcon} />
|
|
<SortHeader label="VAT #" sortKey="sars_vat_no" current={sortKey} dir={sortDir} onSort={toggleSort} icon={sortIcon} />
|
|
<SortHeader label="PAYE #" sortKey="sars_paye_no" current={sortKey} dir={sortDir} onSort={toggleSort} icon={sortIcon} />
|
|
<SortHeader label="Status" sortKey="enterprise_status" current={sortKey} dir={sortDir} onSort={toggleSort} icon={sortIcon} />
|
|
<th className="px-3 py-3 font-semibold text-right w-24">Edit</th>
|
|
<th className="px-3 py-3 font-semibold text-right w-24">View</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-border bg-surface">
|
|
{sorted.map(c => (
|
|
<tr key={c.id} className="hover:bg-surface-warm/50 transition-colors">
|
|
<td className="px-3 py-3 font-mono text-xs whitespace-nowrap">
|
|
{c.enterprise_no || <span className="text-muted italic">—</span>}
|
|
</td>
|
|
<td className="px-3 py-3">
|
|
<Link href={`/companies/${c.id}`} className="text-fg font-semibold hover:text-accent no-underline">
|
|
{c.enterprise_name || <span className="text-muted italic">—</span>}
|
|
</Link>
|
|
</td>
|
|
<td className="px-3 py-3 font-mono text-xs whitespace-nowrap">
|
|
{c.sars_tax_number || <span className="text-muted italic">—</span>}
|
|
</td>
|
|
<td className="px-3 py-3 font-mono text-xs whitespace-nowrap">
|
|
{c.sars_vat_no || <span className="text-muted italic">—</span>}
|
|
</td>
|
|
<td className="px-3 py-3 font-mono text-xs whitespace-nowrap">
|
|
{c.sars_paye_no || <span className="text-muted italic">—</span>}
|
|
</td>
|
|
<td className="px-3 py-3">
|
|
<span className={`text-xs px-2 py-0.5 rounded font-semibold ${
|
|
(c.enterprise_status || "").toLowerCase().includes("business")
|
|
? "bg-green-100 text-green-700"
|
|
: (c.enterprise_status || "").toLowerCase().includes("deregistered")
|
|
? "bg-red-100 text-red-600"
|
|
: "bg-yellow-100 text-yellow-700"
|
|
}`}>
|
|
{c.enterprise_status || "—"}
|
|
</span>
|
|
</td>
|
|
<td className="px-3 py-3 text-right">
|
|
<Link
|
|
href={`/companies/${c.id}`}
|
|
className="text-xs px-2.5 py-1 rounded border border-border bg-surface-warm text-fg no-underline hover:border-accent hover:text-accent transition-colors"
|
|
>
|
|
Edit
|
|
</Link>
|
|
</td>
|
|
<td className="px-3 py-3 text-right">
|
|
<Link
|
|
href={`/companies/${c.id}`}
|
|
className="text-xs font-semibold no-underline text-fg hover:text-accent"
|
|
>
|
|
View →
|
|
</Link>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StatCard({ label, value, highlight = "muted" }: { label: string; value: number; highlight?: "green" | "red" | "muted" }) {
|
|
const colors = {
|
|
green: "text-green-700",
|
|
red: "text-red-600",
|
|
muted: "text-fg",
|
|
};
|
|
return (
|
|
<div className="bg-surface border border-border rounded-lg p-4">
|
|
<p className="text-xs uppercase tracking-wide text-muted font-semibold">{label}</p>
|
|
<p className={`text-2xl font-bold mt-1 ${colors[highlight]}`}>{value}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SortHeader({ label, sortKey, current, dir, onSort, icon }: {
|
|
label: string;
|
|
sortKey: SortKey;
|
|
current: SortKey;
|
|
dir: "asc" | "desc";
|
|
onSort: (k: SortKey) => void;
|
|
icon: (k: SortKey) => string;
|
|
}) {
|
|
const isActive = current === sortKey;
|
|
return (
|
|
<th className="px-3 py-3 font-semibold">
|
|
<button
|
|
onClick={() => onSort(sortKey)}
|
|
className={`flex items-center gap-1.5 text-xs uppercase tracking-wide hover:text-fg transition-colors ${
|
|
isActive ? "text-fg" : "text-muted"
|
|
}`}
|
|
>
|
|
{label}
|
|
<span className="text-[10px] opacity-70">{icon(sortKey)}</span>
|
|
</button>
|
|
</th>
|
|
);
|
|
}
|