Workflow-App/Workflow/app/registrations/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

559 lines
24 KiB
TypeScript

// app/registrations/page.tsx
// Client component. Lists ALL fields of every record in
// ysterbaard_company_registrations, with search and per-row view links.
"use client";
import { useState, useEffect, useMemo } from "react";
import PageHeader from "@/components/PageHeader";
import Link from "next/link";
import { useRouter } from "next/navigation";
import ActionsDropdown from "@/components/ActionsDropdown";
interface RegItem {
id: string;
[k: string]: unknown;
}
const STATUS_LABELS: Record<string, string> = {
draft: "Draft",
pending_registration: "Pending Client",
submitted: "Step 1 Done",
registered: "Step 2 — Registered",
bizportal_in_progress: "Bizportal submission in progress",
bizportal_done: "Bizportal received application",
name_approved: "Name approved by Bizportal",
step3: "Awaiting client approval",
name_reserved: "Name reserved (workflow done)",
client_requested_changes: "Step 2 — Client requested changes",
resignation_sent: "Step 3 — Resignation Sent",
appointment_accepted: "Step 4 — Appointment",
cancelled: "Cancelled",
// legacy values
pending: "Pending",
approved: "Approved",
rejected: "Rejected",
};
const STATUS_STYLE: Record<string, string> = {
draft: "bg-surface border border-border text-muted",
pending_registration: "bg-surface border border-border text-muted",
submitted: "bg-blue-100 text-blue-700 border border-blue-300",
registered: "bg-success/10 text-success border border-success/30",
bizportal_in_progress: "bg-blue-500/10 text-blue-700 border border-blue-500/30",
bizportal_done: "bg-yellow-500/10 text-yellow-700 border border-yellow-500/30",
name_approved: "bg-success/10 text-success border border-success/30",
step3: "bg-purple-500/10 text-purple-700 border border-purple-500/30",
name_reserved: "bg-purple-500/10 text-purple-700 border border-purple-500/30",
client_requested_changes: "bg-yellow-500/10 text-yellow-700 border border-yellow-500/30",
resignation_sent: "bg-purple-100 text-purple-700 border border-purple-300",
appointment_accepted: "bg-accent/10 text-accent border border-accent/30",
cancelled: "bg-danger/10 text-danger border border-danger/30 line-through",
pending: "bg-surface border border-border text-muted",
approved: "bg-success/10 text-success border border-success/20",
rejected: "bg-danger/10 text-danger border border-danger/20",
};
const STEP_NUMBER: Record<string, number> = {
draft: 0,
pending_registration: 1,
submitted: 1,
registered: 2,
bizportal_in_progress: 2,
bizportal_done: 2,
name_approved: 2,
step3: 3,
name_reserved: 3,
client_requested_changes: 2,
resignation_sent: 3,
appointment_accepted: 4,
cancelled: -1,
};
const HIDDEN_SYSTEM_FIELDS = new Set([
"collectionId", "collectionName",
"expand", // relation expansions, not user-relevant here
]);
// Columns shown in the table (in order). Everything else is shown in the View modal.
const TABLE_COLUMNS: { key: string; label: string }[] = [
{ key: "reference_name", label: "Reference" },
{ key: "proposed_name_1", label: "Proposed Name" },
{ key: "client_submitted_at", label: "Submitted" },
{ key: "_step", label: "Step" },
{ key: "status", label: "Status" },
{ key: "_actions", label: "Actions" },
{ key: "company_email", label: "Email" },
{ key: "company_cell", label: "Cell" },
{ key: "trading_address", label: "Trading Address" },
{ key: "financial_year_end", label: "FYE" },
{ key: "authorised_shares", label: "Auth Shares" },
{ key: "registration_id", label: "Reg ID" },
];
function formatValue(v: unknown): string {
if (v === null || v === undefined || v === "") return "—";
if (typeof v === "boolean") return v ? "✓" : "✗";
if (typeof v === "string") {
// Render ISO date strings as "YYYY-MM-DD HH:mm" (SAST) for compact table display.
if (/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/.test(v)) {
const d = new Date(v);
if (!isNaN(d.getTime())) {
const yyyy = d.getUTCFullYear();
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
const dd = String(d.getUTCDate()).padStart(2, "0");
const hh = String(d.getUTCHours()).padStart(2, "0");
const min = String(d.getUTCMinutes()).padStart(2, "0");
return `${yyyy}-${mm}-${dd} ${hh}:${min}`;
}
}
return v;
}
return JSON.stringify(v);
}
function formatDate(iso: string | null | undefined): string {
if (!iso) return "—";
try {
return new Date(iso).toLocaleString("en-ZA", {
day: "2-digit", month: "short", year: "numeric",
hour: "2-digit", minute: "2-digit",
});
} catch {
return iso;
}
}
export default function RegistrationsPage() {
const [items, setItems] = useState<RegItem[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [copiedId, setCopiedId] = useState<string | null>(null);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [reopeningId, setReopeningId] = useState<string | null>(null);
const [reopenMsg, setReopenMsg] = useState<string>("");
const [showCancelled, setShowCancelled] = useState(false);
useEffect(() => {
fetch("/api/register/all?perPage=1000")
.then((r) => r.json())
.then((d) => {
setItems(d.items || []);
setLoading(false);
})
.catch(() => setLoading(false));
}, []);
// Manual refresh — pulled out of useEffect so the button can call it
async function refresh() {
setLoading(true);
try {
const r = await fetch("/api/register/all?perPage=1000");
const d = await r.json();
setItems(d.items || []);
} catch {
// swallow — keep existing items
} finally {
setLoading(false);
}
}
// Auto-refresh every 30s while the page is open
useEffect(() => {
const id = setInterval(refresh, 30_000);
return () => clearInterval(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const filtered = useMemo(() => {
let list = items;
if (!showCancelled) {
list = list.filter(it => (it.status || "") !== "cancelled");
}
if (!search.trim()) return list;
const ql = search.toLowerCase();
return list.filter((it) =>
Object.entries(it).some(([k, v]) => {
if (HIDDEN_SYSTEM_FIELDS.has(k)) return false;
if (typeof v !== "string") return false;
return v.toLowerCase().includes(ql);
})
);
}, [items, search, showCancelled]);
const cancelledCount = useMemo(() => items.filter(it => it.status === "cancelled").length, [items]);
const handleCopyLink = (id: string) => {
// Plain link for the client — fills form blank, submits, stays on success screen
const url = `${window.location.origin}/register/${id}`;
const el = document.createElement("textarea");
el.value = url;
el.style.position = "fixed";
el.style.opacity = "0";
document.body.appendChild(el);
el.focus();
el.select();
document.execCommand("copy");
document.body.removeChild(el);
setCopiedId(id);
setTimeout(() => setCopiedId(null), 2000);
};
const canShare = typeof navigator !== "undefined" && !!navigator.share;
const handleShare = (item: RegItem) => {
const url = `${window.location.origin}/register/${item.id}`;
navigator.share({
title: "Company Registration",
text: `Registration: ${item.reference_name || item.id}`,
url,
}).catch(() => {});
};
async function handleReopen(item: RegItem, withPrefill: boolean) {
setReopeningId(item.id);
setReopenMsg("");
try {
const res = await fetch(`/api/register/${item.id}/reopen`, { method: "POST" });
const data = await res.json();
if (!res.ok) {
setReopenMsg(`❌ Reopen failed: ${data.error || res.status}`);
return;
}
// Update local state
setItems(prev => prev.map(i => i.id === item.id ? { ...i, status: "submitted" } : i));
setReopenMsg(`✅ Reopened — opening wizard${withPrefill ? " with existing data" : ""}…`);
// Open the wizard in a new tab
const url = `${window.location.origin}/register/${item.id}${withPrefill ? "?prefill=1" : ""}`;
window.open(url, "_blank");
} catch (err: any) {
setReopenMsg(`❌ Error: ${err.message}`);
} finally {
setReopeningId(null);
setTimeout(() => setReopenMsg(""), 4000);
}
}
async function handleStatusAction(item: RegItem, action: "approve" | "cancel" | "reopen" | "back_to_step1") {
setReopeningId(item.id);
setReopenMsg("");
try {
const res = await fetch(`/api/register/${item.id}/status?action=${action}`, { method: "POST" });
const data = await res.json();
if (!res.ok) {
setReopenMsg(`❌ ${action} failed: ${data.error || res.status}`);
return;
}
// Update local state
setItems(prev => prev.map(i => i.id === item.id ? { ...i, status: data.status } : i));
if (action === "approve") setReopenMsg(`✅ Approved.`);
} catch (err: any) {
setReopenMsg(`❌ Error: ${err.message}`);
} finally {
setReopeningId(null);
setTimeout(() => setReopenMsg(""), 4000);
}
}
function exportCSV() {
// Union of all keys across all records
const keySet = new Set<string>();
items.forEach(it => Object.keys(it).forEach(k => { if (!HIDDEN_SYSTEM_FIELDS.has(k)) keySet.add(k); }));
const headers = ["id", ...Array.from(keySet)];
const rows = filtered.map(it => headers.map(h => formatValue(it[h])));
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 = `registrations-${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(url);
}
return (
<>
<PageHeader
eyebrow="Workflow"
title="Company Registrations"
subtitle="Client submissions from the registration wizard — all data from ysterbaard_company_registrations."
action={
<div className="flex items-center gap-2">
<Link
href="/resolutions"
className="px-4 py-2.5 rounded-md border border-border bg-surface text-fg text-sm font-semibold no-underline hover:bg-surface-warm transition-colors"
>
← Back to Resolutions
</Link>
<button
onClick={refresh}
disabled={loading}
className="px-4 py-2.5 rounded-md border border-border bg-surface text-fg text-sm font-semibold hover:border-accent hover:text-accent disabled:opacity-50 transition-colors"
title="Reload from PocketBase (auto-refreshes every 30s)"
>
{loading ? "⟳ Loading…" : "⟳ Refresh"}
</button>
<button
onClick={exportCSV}
disabled={filtered.length === 0}
className="px-4 py-2.5 rounded-md border border-border bg-surface text-fg text-sm font-semibold hover:border-accent hover:text-accent disabled:opacity-50 transition-colors"
>
⬇ Export CSV
</button>
</div>
}
/>
<div className="container-x py-10 space-y-4">
{/* Search bar + counts */}
<div className="flex items-center gap-3 flex-wrap">
<div className="flex-1 min-w-64 relative">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search across all fields…"
className="w-full px-4 py-2.5 rounded-md border border-border bg-surface text-fg text-sm placeholder:text-muted focus:outline-none focus:ring-2 focus:ring-accent"
/>
{search && (
<button
onClick={() => setSearch("")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted hover:text-fg text-xs"
>
✕
</button>
)}
</div>
{cancelledCount > 0 && (
<label className="flex items-center gap-2 text-sm text-muted whitespace-nowrap cursor-pointer">
<input
type="checkbox"
checked={showCancelled}
onChange={(e) => setShowCancelled(e.target.checked)}
className="w-4 h-4 accent-red-500"
/>
Show cancelled ({cancelledCount})
</label>
)}
<p className="text-sm text-muted whitespace-nowrap">
<span className="font-semibold text-fg">{filtered.length}</span> of <span className="font-semibold text-fg">{items.length}</span> record{items.length === 1 ? "" : "s"}
</p>
</div>
{/* Reopen status message */}
{reopenMsg && (
<div className="text-sm px-4 py-3 rounded bg-surface-warm border border-border text-fg">
{reopenMsg}
</div>
)}
{/* Body */}
{loading ? (
<div className="bg-surface border border-border rounded-lg p-12 text-center text-muted">
Loading registrations…
</div>
) : filtered.length === 0 ? (
<div className="bg-surface border border-border rounded-lg p-12 text-center text-muted">
{items.length === 0
? "No registrations yet. Create a new registration from the Resolutions tab."
: `No registrations match "${search}".`}
</div>
) : (
<div className="bg-surface border border-border rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-surface-warm text-left">
{TABLE_COLUMNS.map(c => (
<th key={c.key} className={`px-3 py-3 text-xs font-semibold text-muted uppercase tracking-wider whitespace-nowrap ${c.key === "_actions" ? "text-right" : "text-left"}`}>
{c.label}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border">
{filtered.map((r) => {
const status = (r.status as string) || "draft";
return (
<tr key={r.id} className="hover:bg-surface-warm transition-colors">
{TABLE_COLUMNS.map(c => {
const v = r[c.key];
if (c.key === "_step") {
const step = STEP_NUMBER[status] ?? 0;
const stepLabel = step < 0 ? "—" : `${step}/4`;
const stepClass = step < 0
? "bg-danger/10 text-danger border border-danger/30"
: step === 0
? "bg-surface border border-border text-muted"
: step === 1
? "bg-blue-100 text-blue-700 border border-blue-300"
: step === 2
? "bg-success/10 text-success border border-success/30"
: step === 3
? "bg-purple-100 text-purple-700 border border-purple-300"
: "bg-accent/10 text-accent border border-accent/30";
return (
<td key={c.key} className="px-3 py-3">
<span className={`text-xs px-2 py-0.5 rounded font-semibold ${stepClass}`}>
{stepLabel}
</span>
</td>
);
}
if (c.key === "status") {
// For bizportal_done, pick the right sub-label based on cipc_decision
// so the table mirrors the Step 2 banner wording.
let statusKey = status;
if (status === "bizportal_done") {
const dec = String(r.cipc_decision || "").toLowerCase();
if (dec === "approved") statusKey = "bizportal_done_approved";
else if (dec === "rejected") statusKey = "bizportal_done_rejected";
}
return (
<td key={c.key} className="px-3 py-3 whitespace-nowrap">
<span className={`inline-flex px-2 py-0.5 rounded text-xs font-semibold ${STATUS_STYLE[statusKey] || STATUS_STYLE.draft}`}>
{STATUS_LABELS[statusKey] || status}
</span>
</td>
);
}
if (c.key === "_actions") {
return (
<td key={c.key} className="px-3 py-3 whitespace-nowrap">
<div className="flex items-center justify-end gap-2">
<ActionsDropdown id={r.id} name={String(r.reference_name ?? "")} status={status} />
{/* Reopen client wizard — works from any non-cancelled state */}
{status !== "cancelled" && (
<button
onClick={() => handleStatusAction(r, "reopen")}
disabled={reopeningId === r.id}
title="Reset status so the wizard can be re-opened"
className="px-2.5 py-1 bg-yellow-500/10 border border-yellow-500/30 rounded text-xs font-semibold text-yellow-700 hover:bg-yellow-500/20 transition-colors whitespace-nowrap disabled:opacity-50"
>
🔓
</button>
)}
{/* Cancel — works from any non-cancelled state */}
{status !== "cancelled" && (
<button
onClick={() => {
if (confirm(`Cancel this registration for "${formatValue(r.reference_name)}"?\n\nThe data will be kept in PocketBase for later use.`)) {
handleStatusAction(r, "cancel");
}
}}
disabled={reopeningId === r.id}
title="Cancel — keeps data in PB but hides from active list"
className="px-2.5 py-1 bg-red-500/10 border border-red-500/30 rounded text-xs font-semibold text-red-600 hover:bg-red-500/20 transition-colors whitespace-nowrap disabled:opacity-50"
>
✕ Cancel
</button>
)}
<button
onClick={() => handleCopyLink(r.id)}
className="px-2.5 py-1 bg-surface border border-border rounded text-xs font-semibold text-fg hover:border-accent hover:text-accent transition-colors whitespace-nowrap"
title="Copy client wizard link"
>
{copiedId === r.id ? "✓" : "🔗"}
</button>
<Link
href={`/step2/${r.id}`}
className="px-2.5 py-1 border border-accent text-accent rounded text-xs font-semibold hover:bg-accent/10 no-underline transition-colors"
>
Step 2 →
</Link>
<Link
href={`/registrations/${r.id}`}
className="px-3 py-1 bg-accent text-accent-on rounded text-xs font-semibold hover:opacity-90 no-underline transition-opacity"
>
View →
</Link>
</div>
</td>
);
}
if (c.key === "reference_name") {
return (
<td key={c.key} className="px-3 py-3 whitespace-nowrap">
<button
onClick={() => setExpandedId(expandedId === r.id ? null : r.id)}
className="font-medium text-fg hover:text-accent text-left"
>
{formatValue(v)}
</button>
</td>
);
}
return (
<td key={c.key} className="px-3 py-3 max-w-xs truncate text-fg-2" title={formatValue(v)}>
{formatValue(v)}
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
{/* Expanded detail panel */}
{expandedId && (
<ExpandedDetail
record={items.find(i => i.id === expandedId)!}
onClose={() => setExpandedId(null)}
/>
)}
</div>
</>
);
}
function ExpandedDetail({ record, onClose }: { record: RegItem; onClose: () => void }) {
const entries = Object.entries(record).filter(([k]) => !HIDDEN_SYSTEM_FIELDS.has(k));
const status = String(record.status ?? "");
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4" onClick={onClose}>
<div className="bg-surface border border-border rounded-xl shadow-2xl w-full max-w-3xl max-h-[85vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
<div className="sticky top-0 bg-surface border-b border-border px-6 py-4 flex items-center justify-between gap-4">
<div>
<p className="text-xs uppercase tracking-[0.08em] text-muted font-semibold">Registration Detail</p>
<h3 className="text-lg font-bold text-fg mt-1">{String(record.reference_name ?? record.id)}</h3>
</div>
<div className="flex items-center gap-3">
<ActionsDropdown id={record.id} name={String(record.reference_name ?? record.id)} status={status} />
<button onClick={onClose} className="text-muted hover:text-fg text-xl font-bold">✕</button>
</div>
</div>
<dl className="px-6 py-4 grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-3">
{entries.map(([k, v]) => (
<div key={k} className="border-b border-border-soft pb-2">
<dt className="text-xs font-semibold text-muted uppercase tracking-wide">{k.replace(/_/g, " ")}</dt>
<dd className="text-sm text-fg mt-1 break-words font-mono">
{typeof v === "string" && /^\d{4}-\d{2}-\d{2}/.test(v) ? formatDate(v) : formatValue(v)}
</dd>
</div>
))}
</dl>
<div className="px-6 py-4 border-t border-border flex justify-end gap-3">
<Link
href={`/registrations/${record.id}`}
className="px-4 py-2 bg-accent text-accent-on rounded text-sm font-semibold no-underline hover:opacity-90"
>
Full View →
</Link>
<button
onClick={onClose}
className="px-4 py-2 border border-border rounded text-sm font-semibold text-fg hover:bg-surface-warm"
>
Close
</button>
</div>
</div>
</div>
);
}