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

503 lines
23 KiB
TypeScript

// components/ResolutionExplorer.tsx
// Client Component. 4-section menu for company registration,
// director resignation, director appointment, and resolutions.
// Only Company Registration is active; others are placeholders.
"use client";
import { useState, useRef, useEffect, useCallback } from "react";
import type { ComponentProps } from "react";
import type {
PBList,
YsterbaardResolution,
ResolutionStatus,
} from "@/lib/types";
import StatusBadge from "@/components/StatusBadge";
import { useRouter } from "next/navigation";
import DirectorAppointmentTab from "@/components/DirectorAppointmentTab";
import DirectorResignationTab from "@/components/DirectorResignationTab";
import AppointmentTable from "@/components/AppointmentTable";
import ResignationTable from "@/components/ResignationTable";
import ResolutionsTable from "@/components/ResolutionsTable";
import ResolutionWizard from "@/components/ResolutionWizard";
import ShareholderChangeWizard from "@/components/ShareholderChangeWizard";
import ShareholderChangeTable from "@/components/ShareholderChangeTable";
type Section =
| "company_registration"
| "director_resignation"
| "director_appointment"
| "resolution_appointment"
| "shareholder_change";
const SECTIONS: { id: Section; label: string }[] = [
{ id: "company_registration", label: "1 — Company registration" },
{ id: "director_resignation", label: "2 — Director resignation" },
{ id: "director_appointment", label: "3 — Director appointment acceptance" },
{ id: "resolution_appointment", label: "4 — Resolution appointment and resignation" },
{ id: "shareholder_change", label: "5 — Change in shareholders" },
];
type BadgeVariant = ComponentProps<typeof StatusBadge>["variant"];
function statusVariant(s: ResolutionStatus): BadgeVariant {
switch (s) {
case "draft": return "muted";
case "pending": return "warn";
case "completed": return "success";
case "archived": return "muted";
}
}
function formatDate(iso: string | null): string {
if (!iso) return "—";
return new Date(iso).toLocaleDateString("en-ZA", {
day: "2-digit", month: "short", year: "numeric",
});
}
interface Props {
resolutions: PBList<YsterbaardResolution>;
tab?: string;
}
interface RegSearchItem {
id: string;
reference_name: string;
status: string;
proposed_name_1: string;
}
export default function ResolutionExplorer({ resolutions, tab }: Props) {
const router = useRouter();
const initialSection: Section =
tab === "appointment" ? "director_appointment"
: tab === "resignation" ? "director_resignation"
: tab === "resolution" ? "resolution_appointment"
: tab === "shareholders" ? "shareholder_change"
: "company_registration";
const [activeSection, setActiveSection] = useState<Section>(initialSection);
const [showNewDialog, setShowNewDialog] = useState(false);
const [showWizard, setShowWizard] = useState(false);
const [showShareWizard, setShowShareWizard] = useState(false);
const [newRegName, setNewRegName] = useState("");
const [newRegLoading, setNewRegLoading] = useState(false);
const [generatedLink, setGeneratedLink] = useState<string | null>(null);
const [dialogError, setDialogError] = useState<string | null>(null);
const [dialogExisting, setDialogExisting] = useState<{
id: string;
reference_name: string;
status: string;
proposed_name_1?: string;
} | null>(null);
const [linkCopied, setLinkCopied] = useState(false);
// Search state
const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState<RegSearchItem[]>([]);
const [searchLoading, setSearchLoading] = useState(false);
const [searchFocused, setSearchFocused] = useState(false);
const searchRef = useRef<HTMLDivElement>(null);
const searchTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
// Old: filteredResolutions for `company_registration` (legacy collection).
// This branch shows any legacy items with type="company_registration".
const filteredResolutions = resolutions.items
.filter((r) => r.type === "company_registration")
.sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime());
// Close search dropdown when clicking outside
useEffect(() => {
const handler = (e: MouseEvent) => {
if (searchRef.current && !searchRef.current.contains(e.target as Node)) {
setSearchFocused(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
// Search registrations as user types
const doSearch = useCallback(async (q: string) => {
if (q.trim().length === 0) { setSearchResults([]); setSearchLoading(false); return; }
setSearchLoading(true);
try {
const res = await fetch(`/api/register/search?q=${encodeURIComponent(q.trim())}&fields=id,reference_name,status,proposed_name_1`);
if (!res.ok) throw new Error();
const data = await res.json();
setSearchResults(data.items || []);
} catch { setSearchResults([]); }
finally { setSearchLoading(false); }
}, []);
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const q = e.target.value;
setSearchQuery(q);
if (searchTimeout.current) clearTimeout(searchTimeout.current);
if (!q.trim()) { setSearchResults([]); setSearchLoading(false); return; }
setSearchLoading(true);
searchTimeout.current = setTimeout(() => doSearch(q), 300);
};
const handleSelectExisting = (reg: RegSearchItem) => {
setSearchFocused(false);
setShowNewDialog(false);
router.push(`/register/${reg.id}?prefill=1`);
};
const handleCreateRegistration = async () => {
if (!newRegName.trim()) return;
setNewRegLoading(true);
setDialogError(null);
setDialogExisting(null);
try {
const res = await fetch("/api/register/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reference_name: newRegName.trim() }),
});
const data = await res.json().catch(() => ({}));
if (res.status === 409 && data.duplicate) {
setDialogExisting(data.existing);
setDialogError(`"${newRegName.trim()}" is already in use by another registration.`);
return;
}
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
setGeneratedLink(`${window.location.origin}/register/${data.id}`);
} catch (err: unknown) {
setDialogError(err instanceof Error ? err.message : "Failed to create link");
} finally {
setNewRegLoading(false);
}
};
const openNewDialog = () => {
setNewRegName(""); setGeneratedLink(null); setDialogError(null); setDialogExisting(null);
setSearchQuery(""); setSearchResults([]);
setShowNewDialog(true);
};
return (
<div className="container-x py-8 space-y-6">
{/* Section Buttons */}
<div className="grid grid-cols-2 lg:grid-cols-5 gap-3">
{SECTIONS.map((sec) => {
const isActive = activeSection === sec.id;
return (
<button
key={sec.id}
onClick={() => setActiveSection(sec.id)}
className={
"px-4 py-5 rounded-lg border text-sm font-semibold text-center transition-all " +
(isActive
? "bg-accent border-accent text-accent-on shadow-sm"
: "bg-surface border-border text-fg hover:border-accent hover:text-accent")
}
>
{sec.label}
</button>
);
})}
</div>
{/* ── Director Appointment ── */}
{activeSection === "director_appointment" && (
<div className="space-y-8">
<DirectorAppointmentTab />
<hr className="border-border" />
<AppointmentTable />
</div>
)}
{/* ── Director Resignation ── */}
{activeSection === "director_resignation" && (
<div className="space-y-8">
<DirectorResignationTab />
<hr className="border-border" />
<ResignationTable />
</div>
)}
{/* ── Resolution Appointment & Resignation ── */}
{activeSection === "resolution_appointment" && (
<div className="space-y-6">
{/* Header + New button */}
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-fg">
Combined appointment &amp; resignation minutes — track every
resolution in flight.
</p>
<button
onClick={() => setShowWizard(true)}
className="px-4 py-2 bg-accent text-accent-on rounded font-semibold text-sm hover:opacity-90 transition-opacity"
>
+ New resolution
</button>
</div>
<ResolutionsTable />
</div>
)}
{/* ── Change in shareholders ── */}
{activeSection === "shareholder_change" && (
<div className="space-y-6">
{/* Header + New button */}
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-semibold text-fg">
Special resolutions of shareholders — record who sold how many shares,
and to whom.
</p>
<button
onClick={() => setShowShareWizard(true)}
className="px-4 py-2 bg-accent text-accent-on rounded font-semibold text-sm hover:opacity-90 transition-opacity flex-shrink-0"
>
+ Company change in shareholding
</button>
</div>
<ShareholderChangeTable />
</div>
)}
{/* ── Company Registration ── */}
{activeSection === "company_registration" && (
<div className="space-y-6">
{/* Header + New button */}
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-fg">
{filteredResolutions.length === 0 ? "No registrations yet" : `${filteredResolutions.length} registration${filteredResolutions.length === 1 ? "" : "s"}`}
</p>
<button
onClick={openNewDialog}
className="px-4 py-2 bg-accent text-accent-on rounded font-semibold text-sm hover:opacity-90 transition-opacity"
>
+ New registration
</button>
</div>
{/* Registration search bar */}
<div ref={searchRef} className="relative">
<label className="block text-xs font-semibold text-muted uppercase tracking-wider mb-1.5">
Search submitted registrations
</label>
<div className="flex gap-2">
<div className="relative flex-1">
<input
className="w-full bg-surface border border-border rounded px-3 py-2.5 pr-10 text-sm text-fg placeholder-muted focus:outline-none focus:border-accent transition-colors"
placeholder="Search by reference or company name..."
value={searchQuery}
onChange={handleSearchChange}
onFocus={() => setSearchFocused(true)}
/>
{searchLoading && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<svg className="w-4 h-4 animate-spin text-muted" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
</div>
)}
</div>
<a
href="/registrations"
className="flex-shrink-0 px-4 py-2 bg-surface border border-border rounded text-sm font-semibold text-fg hover:border-accent hover:text-accent no-underline transition-colors text-center"
>
View all →
</a>
</div>
{/* Search results dropdown */}
{searchFocused && searchResults.length > 0 && (
<div className="absolute z-10 top-full left-0 right-0 mt-1 bg-surface border border-border rounded-lg shadow-xl overflow-hidden max-h-72 overflow-y-auto">
{searchResults.map((reg) => (
<button
key={reg.id}
onClick={() => handleSelectExisting(reg)}
className="w-full text-left px-4 py-3 hover:bg-surface-warm transition-colors border-b border-border last:border-b-0"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-sm font-semibold text-fg truncate">{reg.reference_name || reg.id}</p>
<p className="text-xs text-muted truncate">{reg.proposed_name_1 || "No company name"}</p>
</div>
<span className={`flex-shrink-0 inline-flex px-2 py-0.5 rounded text-xs font-semibold ${
reg.status === "submitted"
? "bg-accent/10 text-accent"
: "bg-surface border border-border text-muted"
}`}>
{reg.status === "submitted" ? "Submitted" : "Draft"}
</span>
</div>
</button>
))}
</div>
)}
{searchFocused && !searchLoading && searchQuery.trim() && searchResults.length === 0 && (
<div className="absolute z-10 top-full left-0 right-0 mt-1 bg-surface border border-border rounded-lg shadow-xl px-4 py-3 text-sm text-muted">
No registrations found for &ldquo;{searchQuery}&rdquo;
</div>
)}
</div>
{/* Resolutions list (draft/pending resolutions for company_registration) */}
{filteredResolutions.length > 0 && (
<div className="bg-surface border border-border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-surface-warm">
<th className="text-left px-5 py-3 text-xs font-semibold text-muted uppercase tracking-wide">Title</th>
<th className="text-left px-5 py-3 text-xs font-semibold text-muted uppercase tracking-wide hidden md:table-cell">Client</th>
<th className="text-left px-5 py-3 text-xs font-semibold text-muted uppercase tracking-wide hidden lg:table-cell">ID / Ref</th>
<th className="text-left px-5 py-3 text-xs font-semibold text-muted uppercase tracking-wide">Status</th>
<th className="text-left px-5 py-3 text-xs font-semibold text-muted uppercase tracking-wide hidden sm:table-cell">Date</th>
</tr>
</thead>
<tbody className="divide-y divide-border-soft">
{filteredResolutions.map((r) => (
<tr key={r.id} className="hover:bg-surface-warm transition-colors">
<td className="px-5 py-4"><span className="font-semibold text-fg">{r.title ?? "—"}</span></td>
<td className="px-5 py-4 hidden md:table-cell"><span className="text-fg-2">{r.client_name ?? "—"}</span></td>
<td className="px-5 py-4 hidden lg:table-cell"><span className="font-mono text-xs text-muted">{r.client_id_no ?? "—"}</span></td>
<td className="px-5 py-4"><StatusBadge variant={statusVariant(r.status)}>{r.status}</StatusBadge></td>
<td className="px-5 py-4 hidden sm:table-cell"><span className="text-muted tabular-nums text-xs">{formatDate(r.resolution_date ?? r.created)}</span></td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{/* Every Section variant is handled above, so there is deliberately no
"under development" fallback here. Adding a sixth section without a
case should fail loudly in review, not render a blank panel. */}
{/* Resolution Wizard (Phase 3) */}
<ResolutionWizard open={showWizard} onClose={() => setShowWizard(false)} />
{/* Change-in-shareholders wizard */}
<ShareholderChangeWizard
open={showShareWizard}
onClose={() => setShowShareWizard(false)}
/>
{/* New Registration Dialog */}
{showNewDialog && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="bg-surface border border-border rounded-xl shadow-2xl w-full max-w-md mx-4 overflow-hidden">
<div className="border-b border-border bg-surface-warm px-6 py-4">
<h2 className="text-fg font-semibold text-lg">New registration</h2>
<p className="text-muted text-sm mt-0.5">Give this registration a reference name so you can find it later.</p>
</div>
<div className="p-6 space-y-4">
{!generatedLink ? (
<>
<div>
<label className="block text-sm font-semibold text-fg mb-1.5">Reference name</label>
<input
className="w-full bg-surface border border-border rounded px-3 py-2.5 text-sm text-fg placeholder-muted focus:outline-none focus:border-accent transition-colors"
placeholder="e.g. Company A — Smith family"
value={newRegName}
onChange={(e) => setNewRegName(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleCreateRegistration(); }}
autoFocus
/>
</div>
{dialogError && <p className="text-danger text-sm">{dialogError}</p>}
{dialogExisting && (
<div className="border border-warning/40 bg-warning/5 rounded-lg p-3 space-y-2">
<p className="text-xs font-semibold text-warning uppercase tracking-wide">Already used by</p>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<p className="text-sm font-semibold text-fg truncate">
{dialogExisting.proposed_name_1 || dialogExisting.reference_name}
</p>
<p className="text-xs text-muted">
Status: <span className="font-mono">{dialogExisting.status}</span>
</p>
</div>
<a
href={`/registrations/${dialogExisting.id}/step2`}
className="flex-shrink-0 px-3 py-1.5 bg-warning text-warning-on rounded text-xs font-semibold no-underline hover:opacity-90 transition-opacity whitespace-nowrap"
>
View →
</a>
</div>
<p className="text-xs text-muted">
Use the search box above to find this registration, or cancel it before creating a new one with the same name.
</p>
</div>
)}
</>
) : (
<div className="space-y-3">
<p className="text-sm text-fg font-semibold">Share this link with your client:</p>
<div className="flex gap-2">
<input readOnly className="flex-1 bg-surface border border-border rounded px-3 py-2 text-sm text-fg font-mono" value={generatedLink} />
<button
onClick={() => {
const el = document.createElement("textarea");
el.value = generatedLink ?? "";
el.style.position = "fixed";
el.style.opacity = "0";
document.body.appendChild(el);
el.focus();
el.select();
const ok = document.execCommand("copy");
document.body.removeChild(el);
if (ok) { setLinkCopied(true); setTimeout(() => setLinkCopied(false), 2000); }
}}
className="px-4 py-2 bg-accent text-accent-on rounded font-semibold text-sm flex-shrink-0"
>
{linkCopied ? "✓ Copied" : "Copy"}
</button>
{typeof navigator !== "undefined" && navigator.share ? (
<button
onClick={() => {
navigator.share({
title: "Company Registration",
text: "Please complete your company registration here:",
url: generatedLink ?? "",
}).catch(() => {});
}}
className="px-4 py-2 bg-surface border border-border text-fg rounded font-semibold text-sm flex-shrink-0 hover:bg-surface-warm transition-colors"
>
Share
</button>
) : null}
</div>
<p className="text-xs text-muted">
Send this link to your client. They will fill in the director, company and shareholder details. You will be notified on Telegram when they submit.
</p>
</div>
)}
</div>
<div className="border-t border-border px-6 py-4 flex justify-end gap-3">
{!generatedLink ? (
<>
<button onClick={() => setShowNewDialog(false)} className="px-4 py-2 text-sm font-semibold text-muted hover:text-fg transition-colors">Cancel</button>
<button
onClick={handleCreateRegistration}
disabled={!newRegName.trim() || newRegLoading}
className="px-4 py-2 bg-accent text-accent-on rounded font-semibold text-sm disabled:opacity-40 hover:opacity-90 transition-opacity"
>
{newRegLoading ? "Creating..." : "Create link"}
</button>
</>
) : (
<button onClick={() => setShowNewDialog(false)} className="px-4 py-2 bg-accent text-accent-on rounded font-semibold text-sm hover:opacity-90 transition-opacity">
Done
</button>
)}
</div>
</div>
</div>
)}
</div>
);
}