// components/ShareholderChangeTable.tsx // // Section 5 — "Change in shareholders": the list of shareholder-change // resolutions and the actions available on each. // // WORKFLOW THIS ENCODES // --------------------- // 1. Operator fills in the wizard → draft resolution is created, nothing sent // 2. [⋯] Preview letter → renders a fresh PDF in a new tab // 3. [⋯] Email to all parties → renders, files and emails that PDF; // parties print, sign by hand, return it // 4. [⋯] Upload document → choose the slot FIRST: the signed // resolution, or the signed share // certificates, which are made outside // the app and come back separately // 5. [⋯] Mark as complete → status closes the loop // // This mirrors section 4 exactly: every action lives in the row's kebab, not as // inline buttons, so the row stays readable and there is one target to hit. // Creating a draft sends nothing — mailing is always a separate, explicit click. // // Steps 4 and 5 are separate on purpose. Section 5 has no browser signing flow, // so the signed copy arrives out of band and the operator files it; being able // to fix a status without re-uploading a file is the point. // // A note on sending: the API returns 200 once it has PROCESSED the send. // Per-recipient delivery is in the response body (`emailed[].ok`, `skipped`) and // this component renders that result rather than assuming success — a green tick // that is not backed by a real send is worse than a visible failure. // // DELIBERATE NON-SHARING: `ResolutionsTable.tsx` (section 4) has a very similar // portalled kebab. It is duplicated rather than extracted because section 4's // component is frozen — refactoring it risks changing the legal output and the // section-4 flow that is already in daily use. // // The upload destination picker below is kept deliberately equivalent to // section 4's so an operator moving between the two tabs finds the same menu. // The one difference is the slot list: section 5 has TWO slots on the resolution // itself (signed resolution, signed share certificates), where section 4 has // one on the resolution plus one per director. "use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useRouter } from "next/navigation"; import StatusBadge from "@/components/StatusBadge"; import ShareholderChangeWizard, { type EditableResolution, } from "@/components/ShareholderChangeWizard"; import type { ComponentProps } from "react"; type BadgeVariant = ComponentProps["variant"]; // ─── Types ────────────────────────────────────────────────────────────────── interface Attendee { id: string; name: string; surname: string; email: string | null; role: string; position: string; shares: number | null; } interface Item { id: string; meeting_date: string | null; status: string; path: string; generated_letter: string; signed_letter: string; /** New share certificates, produced outside the app and signed by hand. */ signed_certificates: string; company: { id: string; enterprise_name: string; enterprise_no: string; authorised_shares: number | null; } | null; attendees: Attendee[]; totals: { sold: number; acquired: number; sold_label: string; acquired_label: string; balances: boolean; parties: number; }; } interface SendOutcome { ok: boolean; message: string; detail?: string; } // ─── Helpers ──────────────────────────────────────────────────────────────── function cn(...parts: Array): string { return parts.filter(Boolean).join(" "); } function fmtDate(iso: string | null): string { if (!iso) return "—"; const d = new Date(iso); if (Number.isNaN(d.getTime())) return "—"; return d.toLocaleDateString("en-ZA", { day: "2-digit", month: "short", year: "numeric" }); } function statusVariant(status: string): BadgeVariant { switch (status) { case "signed": return "success"; case "pending_signatures": return "warn"; case "cancelled": return "danger"; case "draft": default: return "muted"; } } function statusLabel(status: string): string { switch (status) { case "pending_signatures": return "Awaiting signatures"; case "draft": return "Draft"; default: return status.charAt(0).toUpperCase() + status.slice(1); } } function partyLabel(a: Attendee): string { const full = [a.name, a.surname].filter(Boolean).join(" ").trim(); if (!full) return "—"; return full .split(/\s+/) .map((w) => (w ? w[0].toUpperCase() + w.slice(1).toLowerCase() : w)) .join(" "); } /** * Preview the letter exactly as the parties will receive it — regenerated from * the stored detail, served in a new tab, **nothing is emailed**. * * This is the operator's approval step: read it, then email it. */ function previewLetter(resolutionId: string) { // Open immediately: browsers block window.open() after an await. const url = `/api/shareholder-changes/${resolutionId}/preview`; const win = window.open("", "_blank"); if (!win) { window.open(url, "_blank", "noopener"); return; } win.document.title = "Preparing resolution…"; win.document.body.innerHTML = '

Rendering the special resolution…

'; win.location.href = url; } // ─── Action kebab ─────────────────────────────────────────────────────────── /** One place a document can be filed. Each slot holds exactly one file. */ interface UploadTarget { collection: string; field: string; label: string; /** True when the slot already holds a file, so the operator is warned. */ filled?: boolean; } interface ActionMenuProps { item: Item; busy: boolean; onEdit: (item: Item) => void; onSend: (item: Item) => void; onUpload: (item: Item, file: File, target: UploadTarget) => void; onStatus: (item: Item, status: string) => void; } function ShareholderActionMenu({ item, busy, onEdit, onSend, onUpload, onStatus, }: ActionMenuProps) { const [open, setOpen] = useState(false); const [mounted, setMounted] = useState(false); const [pos, setPos] = useState<{ top: number; left: number } | null>(null); const btnRef = useRef(null); const menuRef = useRef(null); const uploadRef = useRef(null); // Destination chosen for the NEXT upload, and whether the picker is showing. // Same shape as section 4: every slot holds exactly ONE file, so uploading // twice to the same slot silently destroys the first document. Making the // operator pick the destination first makes that impossible by accident. const [showTargets, setShowTargets] = useState(false); const [pendingTarget, setPendingTarget] = useState(null); const targets: UploadTarget[] = [ { collection: "ysterbaard_resolutions", field: "signed_letter", label: "This resolution — signed resolution", filled: !!item.signed_letter, }, { collection: "ysterbaard_resolutions", field: "signed_certificates", label: "This resolution — signed share certificates", filled: !!item.signed_certificates, }, ]; useEffect(() => setMounted(true), []); // The menu is PORTALLED to so no ancestor can clip it. The card is // overflow-hidden, which sliced an in-card dropdown off the last row. const place = useCallback(() => { const el = btnRef.current; if (!el) return; const r = el.getBoundingClientRect(); const MENU_W = 224; // must match the w-56 class below const MENU_H = 340; // generous: picker view is the tallest (2 slots + back) const GAP = 4; let top = r.bottom + GAP; if (top + MENU_H > window.innerHeight - 8 && r.top - MENU_H - GAP > 8) { top = r.top - MENU_H - GAP; } const left = Math.max( 8, Math.min(r.right - MENU_W, window.innerWidth - MENU_W - 8) ); setPos({ top, left }); }, []); useEffect(() => { if (!open) return; place(); const reposition = () => place(); window.addEventListener("scroll", reposition, true); window.addEventListener("resize", reposition); return () => { window.removeEventListener("scroll", reposition, true); window.removeEventListener("resize", reposition); }; }, [open, place]); // Close on outside click. Both the trigger and the portalled panel must match, // because the menu no longer lives inside the trigger's DOM subtree. useEffect(() => { if (!open) return; const handler = (e: MouseEvent) => { const t = e.target as HTMLElement | null; if (t && typeof t.closest === "function" && t.closest(`[data-sh-menu="${item.id}"]`)) { return; } if (menuRef.current && t && typeof t.closest === "function" && menuRef.current.contains(t)) { return; } setOpen(false); }; document.addEventListener("mousedown", handler); return () => document.removeEventListener("mousedown", handler); }, [open, item.id]); useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); }; document.addEventListener("keydown", onKey); return () => document.removeEventListener("keydown", onKey); }, [open]); const menuItem = "w-full text-left px-4 py-2.5 text-sm text-fg hover:bg-surface-warm transition-colors disabled:opacity-40 disabled:cursor-not-allowed"; const isSigned = item.status === "signed"; const isCancelled = item.status === "cancelled"; return (
{open && mounted && pos && createPortal(
Download as Word (.docx) {/* Only a draft can be corrected. Once mailed or signed it is a record of what the parties hold. */} {!isSigned && !isCancelled && ( )} {/* Shows EVERY document on this resolution — the emailed letter, the signed resolution and the signed share certificates. Replaces the old pair of per-field "View the … copy" links, which meant the operator had to know which slot a file was in. Always rendered: the page itself states plainly when nothing has been filed yet. */} View documents {showTargets ? ( /* Destination picker — identical to section 4's. Both slots hold exactly ONE file, so uploading twice to the same slot silently destroyed the first document. Choosing the destination first makes that impossible by accident: each option is named after what it files, and an occupied slot says so before the operator commits to it. */ <>
File this document to
{targets.map((t) => ( ))}
) : ( )}
{/* Section 4 offers this for any draft or awaiting-signatures row, and section 5 does the same so the two tabs behave identically. It is NOT gated on a file being present: a resolution can legitimately come back signed on paper with the scan still on its way, and hiding the action would strand that row. The badge on the card shows what is actually on file. */} {!isSigned && !isCancelled && ( )} {isSigned && ( )} {!isSigned && !isCancelled && ( )}
, document.body )} {/* The file picker must NOT live inside the portal above. The portal only renders while the menu is open, so an placed inside it was unmounted the moment the menu closed — which happens before a file is chosen in the OS dialog. The resulting `change` event fired on a detached node and `onChange` silently never ran: the button appeared to do nothing. Hoisting it here keeps it mounted for the row's whole lifetime, so the file lands no matter what the menu does. */} { const f = e.target.files?.[0]; // Reset first so picking the same file twice still fires. e.target.value = ""; const target = pendingTarget; setPendingTarget(null); setShowTargets(false); if (f && target) onUpload(item, f, target); }} />
); } // ─── Component ────────────────────────────────────────────────────────────── export default function ShareholderChangeTable() { const router = useRouter(); const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [busyId, setBusyId] = useState(null); const [outcomes, setOutcomes] = useState>({}); const [editing, setEditing] = useState(null); const [editLoading, setEditLoading] = useState(false); // ─── Edit ────────────────────────────────────────────────────────────────── // The saved draft is fetched and handed to the same six-step wizard, so a // correction is one field, not a full re-entry. const handleEdit = useCallback(async (item: Item) => { setEditLoading(true); try { const res = await fetch(`/api/shareholder-changes/${item.id}`); const data = await res.json().catch(() => ({})); if (!res.ok) { setOutcome(item.id, { ok: false, message: data.error || `Could not open for editing (HTTP ${res.status})`, }); return; } if (!data.editable) { setOutcome(item.id, { ok: false, message: data.error || `This resolution is ${String(item.status).replace(/_/g, " ")} and can no longer be edited.`, }); return; } setEditing(data as EditableResolution); } catch (err) { setOutcome(item.id, { ok: false, message: err instanceof Error ? err.message : "Could not open for editing", }); } finally { setEditLoading(false); } }, []); const load = useCallback(async () => { try { const res = await fetch("/api/shareholder-changes/list"); const data = await res.json(); if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); setItems(data.items || []); setError(null); } catch (err) { setError(err instanceof Error ? err.message : "Could not load resolutions"); } finally { setLoading(false); } }, []); useEffect(() => { load(); }, [load]); const setOutcome = (id: string, outcome: SendOutcome) => { setOutcomes((prev) => ({ ...prev, [id]: outcome })); }; // ─── Send ───────────────────────────────────────────────────────────────── const handleSend = useCallback( async (item: Item) => { setBusyId(item.id); setOutcome(item.id, { ok: false, message: "Rendering the resolution and emailing…" }); try { const res = await fetch(`/api/shareholder-changes/${item.id}/send`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}), }); const data = await res.json().catch(() => ({})); if (!res.ok) { setOutcome(item.id, { ok: false, message: data.error || `Send failed (HTTP ${res.status})`, }); return; } // Report what actually happened per party rather than a blanket "sent". const sent: string[] = (data.emailed || []) .filter((e: { ok: boolean }) => e.ok) .map((e: { name: string }) => e.name); const failed: Array<{ name: string; error?: string }> = (data.emailed || []).filter( (e: { ok: boolean }) => !e.ok ); const skipped: Array<{ name: string; reason: string }> = data.skipped || []; const bits: string[] = []; if (sent.length) bits.push(`Emailed ${sent.join(", ")}`); if (skipped.length) bits.push(`Not emailed (${skipped.map((s) => `${s.name}: ${s.reason}`).join("; ")})`); if (failed.length) bits.push(`Failed (${failed.map((f) => `${f.name}: ${f.error ?? "unknown error"}`).join("; ")})`); const allGood = failed.length === 0 && skipped.length === 0; setOutcome(item.id, { ok: allGood, message: bits.join(" · ") || (data.sent === 0 ? "Nothing was sent — check the parties have email addresses." : "Done."), detail: data.letter_error ? `Letter problem: ${data.letter_error}` : data.unfilled_placeholders?.length ? `Unfilled placeholders: ${data.unfilled_placeholders.join(", ")}` : undefined, }); await load(); router.refresh(); } catch (err) { setOutcome(item.id, { ok: false, message: err instanceof Error ? err.message : "Send failed", }); } finally { setBusyId(null); } }, [load, router] ); // ─── Upload signed ──────────────────────────────────────────────────────── const handleUpload = useCallback( async (item: Item, file: File, target: UploadTarget) => { setBusyId(item.id); setOutcome(item.id, { ok: false, message: `Filing "${file.name}" against ${target.label}…`, }); try { const form = new FormData(); form.append("file", file); const q = new URLSearchParams({ collection: target.collection, field: target.field }); const res = await fetch(`/api/resolutions/${item.id}/upload?${q.toString()}`, { method: "POST", body: form, }); const data = await res.json().catch(() => ({})); if (!res.ok) { setOutcome(item.id, { ok: false, message: data.error || `Upload failed (HTTP ${res.status})`, }); return; } // Say WHERE it went and whether anything was displaced. The two slots // are genuinely different documents, so "uploaded" alone leaves the // operator unsure which one they just filled. const displaced = data.replaced && target.filled; setOutcome(item.id, { ok: true, message: `Filed to ${target.label} — "${data.filename}"` + (displaced ? ` (replaced the file already in that slot)` : "") + (target.field === "signed_letter" ? ". Use ⋯ → Mark as complete when you are ready." : "."), }); await load(); router.refresh(); } catch (err) { setOutcome(item.id, { ok: false, message: err instanceof Error ? err.message : "Upload failed", }); } finally { setBusyId(null); } }, [load, router] ); // ─── Status ─────────────────────────────────────────────────────────────── const handleStatus = useCallback( async (item: Item, status: string) => { setBusyId(item.id); try { const res = await fetch(`/api/shareholder-changes/${item.id}/status`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status }), }); const data = await res.json().catch(() => ({})); if (!res.ok) { setOutcome(item.id, { ok: false, message: data.error || `HTTP ${res.status}` }); return; } setOutcome(item.id, { ok: true, message: `Marked ${status.replace(/_/g, " ")}.` }); await load(); router.refresh(); } catch (err) { setOutcome(item.id, { ok: false, message: err instanceof Error ? err.message : "Could not update status", }); } finally { setBusyId(null); } }, [load, router] ); // ─── Render ─────────────────────────────────────────────────────────────── if (loading) { return (
Loading change-in-shareholders resolutions…
); } if (error) { return (
{error}
); } if (items.length === 0) { return (

No company shareholding changes yet

Use “+ Company change in shareholding” to record a transfer of shares.

); } return ( <>
{items.map((item) => { const busy = busyId === item.id; const outcome = outcomes[item.id]; const transferors = item.attendees.filter((a) => a.role === "transferor"); const transferees = item.attendees.filter((a) => a.role === "transferee"); const attendees = item.attendees.filter((a) => a.role === "existing"); return (
{/* Header row */}
{item.company?.enterprise_name || "Unknown company"} {statusLabel(item.status)} {!item.totals.balances && ( Does not balance )} {item.signed_letter && item.status !== "signed" && ( Signed copy on file )} {item.signed_certificates && ( Share certificates on file )}

{item.company?.enterprise_no ? ( {item.company.enterprise_no} ) : null} {item.company?.enterprise_no ? " · " : ""} Resolution date {fmtDate(item.meeting_date)}

{item.totals.sold_label} shares

{transferors.length} selling → {transferees.length} buying

{/* Parties */}

Transferors

    {transferors.map((a) => (
  • {partyLabel(a)} {a.shares ?? 0}
  • ))}

Transferees

    {transferees.map((a) => (
  • {partyLabel(a)} {a.shares ?? 0}
  • ))}

Present at the meeting

    {attendees.map((a) => (
  • {partyLabel(a)}
  • ))}
{/* Outcome of the last action */} {outcome && (
{outcome.message} {outcome.detail ? ( {outcome.detail} ) : null}
)}
); })}
{/* Edit re-opens the SAME six-step wizard, filled from the saved draft. */} { const wasEditing = editing; setEditing(null); // The wizard saved through its own request; refresh so the row shows // the corrected figures rather than the stale ones. if (wasEditing) { load(); router.refresh(); } }} /> ); }