Workflow-App/Workflow/components/GroupReport.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

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>
);
}