// components/ResolutionsTable.tsx // Phase 4 — displays every entry in `ysterbaard_resolutions` for the // "4 — Resolution appointment and resignation" tab. // // Columns: company · meeting date · resigned/appointed counts · path · // status · signature progress · actions. // // EVERY action lives in the row's kebab (⋯) menu — Preview, Send/Resend, // Cancel, View details — rather than as inline buttons. Inline buttons made // the Actions column wide enough to squeeze the other columns on smaller // screens, and each row ended up with four separate targets to hit. // // Cancel + Resend are both wired to real endpoints: // POST /api/resolutions//cancel — soft cancel (status → cancelled, // pending signers → expired). Signed resolutions are refused with 409. // PATCH /api/resolutions//attendees {send_for_signature:true} — first // send: creates signers, emails everyone marked "must sign" with the // letter attached, flips to "pending_signatures". // POST /api/resolutions//resend — re-sends to signers still pending. // // Cancel is a SOFT cancel, not a delete: the resolution and its minutes stay on // record for audit. Only "draft" and "pending_signatures" can be cancelled. // // THE INTENDED FLOW (nothing is emailed on creation): // 1. "+ New resolution" → POST /create → status "draft", NOT sent. // 2. [Preview letter] → renders the letter inline in a new tab. Sends nothing. // 3. [Send for signature] → creates signers, emails everyone marked // "must sign" with the letter attached, flips to "pending_signatures". // A partially-failed send reports exactly which addresses failed instead // of claiming success. // 4. [Resend to signers] → re-sends only to signers still pending. // 5. [Cancel resolution] → soft cancel; pending signers become "expired". "use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import Link from "next/link"; import { useRouter } from "next/navigation"; import type { PBList, YsterbaardNewResolution, NewResolutionStatus, ResolutionPath, } from "@/lib/types"; import StatusBadge from "@/components/StatusBadge"; type StatusVariant = "muted" | "warn" | "success" | "danger"; /** One place a document can be filed. Each slot holds exactly one file. */ type UploadTarget = { collection: string; field: string; label: string; /** Required when collection is a director collection: whose record to file it * against. The route resolves (company + director) to the real record. */ directorId?: string; /** True when the slot already holds a file, so the operator is warned. */ filled?: boolean; }; /** An expanded director, as PocketBase actually returns it. */ type DirectorRef = { id: string; first_names?: string; surname?: string }; function statusVariant(s: NewResolutionStatus): StatusVariant { switch (s) { case "draft": return "muted"; case "pending_signatures": return "warn"; case "signed": return "success"; case "cancelled": return "danger"; } } function statusLabel(s: NewResolutionStatus): string { switch (s) { case "draft": return "Draft"; case "pending_signatures": return "Pending signatures"; case "signed": return "Signed"; case "cancelled": return "Cancelled"; } } function formatDate(iso: string | null | undefined): string { if (!iso) return "—"; return new Date(iso).toLocaleDateString("en-ZA", { day: "2-digit", month: "short", year: "numeric", }); } function fullName(d: { first_names?: string; surname?: string } | undefined): string { if (!d) return "—"; return [d.first_names, d.surname].filter(Boolean).join(" ").trim() || "—"; } interface ActionMenuProps { resolutionId: string; status: NewResolutionStatus; /** True once a signed document has been attached — drives the upload label. */ hasDocument?: boolean; /** Every slot this resolution's paperwork can be filed into. */ uploadTargets?: UploadTarget[]; /** Reload the table after a mutation (router.refresh() alone won't re-run this * component's own fetch, so the badge would stay stale). */ onChanged?: () => void; } /** A resolution can only be cancelled while it is still open. */ function canCancelStatus(status: NewResolutionStatus): boolean { return status !== "cancelled" && status !== "signed"; } /** Shared cancel call so the visible button and the ⋯ menu behave identically. */ async function cancelResolution(resolutionId: string): Promise { const res = await fetch(`/api/resolutions/${resolutionId}/cancel`, { method: "POST" }); if (!res.ok) { let msg = `HTTP ${res.status}`; try { const body = await res.json(); if (body?.error) msg = body.error; } catch { /* non-JSON error body — keep the HTTP status */ } throw new Error(msg); } } /** * Preview the letter exactly as the signers will receive it — regenerated from * current attendees/signers, served inline, and **nothing is emailed**. * * This is the operator's approval step: read it, then hit Send. */ function previewLetter(resolutionId: string) { // Open immediately: browsers block window.open() after an await. const url = `/api/resolutions/${resolutionId}/letter?preview=true&download=false`; const win = window.open("", "_blank"); if (!win) { window.open(url, "_blank", "noopener"); return; } win.document.title = "Preparing letter…"; win.document.body.innerHTML = '

Rendering the resolution letter…

'; win.location.href = url; } /** * First send (draft → pending_signatures) or resend (pending_signatures). * Both attach the rendered PDF; neither is available once signed/cancelled. */ async function sendForSignature( resolutionId: string, status: NewResolutionStatus, onChanged?: () => void ): Promise { const isDraft = status === "draft"; const label = isDraft ? "Send this resolution for signature?\n\n" + "Every attendee marked “must sign” will be emailed the letter. " + "Preview it first if you have not already." : "Resend the letter to everyone who has not signed yet?"; if (!confirm(label)) return; try { // NOTE: /attendees is a PATCH endpoint (it also handles attendee edits and // external adds). POSTing to it returns 405 and the send silently never // happens — which is exactly what it did the first time. const res = await fetch( isDraft ? `/api/resolutions/${resolutionId}/attendees` : `/api/resolutions/${resolutionId}/resend`, { method: isDraft ? "PATCH" : "POST", headers: { "Content-Type": "application/json" }, body: isDraft ? JSON.stringify({ send_for_signature: true }) : "{}", } ); const body = await res.json().catch(() => null); if (!res.ok) { throw new Error(body?.error || `HTTP ${res.status}`); } // Surface partial failures instead of claiming success. const sent: Array<{ to: string; ok: boolean; error?: string }> = body?.emails_sent || body?.emailed || []; const failed = sent.filter((e) => !e.ok); if (failed.length > 0) { alert( `${sent.length - failed.length} of ${sent.length} email(s) sent.\n\n` + "FAILED:\n" + failed.map((e) => `• ${e.to || "(no address)"} — ${e.error}`).join("\n") ); } else { alert( isDraft ? `Sent for signature to ${sent.length || "all"} signer(s).` : `Resent to ${sent.length} pending signer(s).` ); } onChanged?.(); } catch (err) { alert(`Send failed: ${err instanceof Error ? err.message : "unknown"}`); } } function ActionMenu({ resolutionId, status, hasDocument, uploadTargets, onChanged }: ActionMenuProps) { const router = useRouter(); const [open, setOpen] = useState(false); const [acting, setActing] = 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); // Fall back to the resolution's own slot when the row supplied no list. const targets: UploadTarget[] = uploadTargets && uploadTargets.length > 0 ? uploadTargets : [ { collection: "ysterbaard_resolutions", field: "signed_letter", label: "This resolution — signed resolution", filled: hasDocument, }, ]; useEffect(() => setMounted(true), []); // The menu is PORTALLED to , not rendered inside the table. // // The card wrapping the table is `overflow-hidden` (it needs that for the // rounded corners on the header). An absolutely-positioned dropdown inside // it is therefore clipped: on the LAST row there is no room below, so the // menu was sliced off at the card edge and effectively invisible. Rows above // happened to work only because another row sat beneath them to draw over. // // Portalling renders the menu in body coordinates, so no ancestor can clip it. const place = useCallback(() => { const el = btnRef.current; if (!el) return; const r = el.getBoundingClientRect(); const MENU_W = 192; // must match the w-48 class below // Tallest menu is draft status: View details, View documents, Preview letter, // Download as Word, Upload document, Send, Mark complete, Cancel — plus the // dividers. Under-counting here makes the flip-above test fire too late and // the bottom of the menu lands off-screen. const MENU_H = 380; const GAP = 4; let top = r.bottom + GAP; // Flip above the button when there isn't room below. 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. The menu lives in , so BOTH the trigger and // the portalled panel have to be checked. useEffect(() => { if (!open) return; const handler = (e: MouseEvent) => { const t = e.target as HTMLElement | null; // e.target is not always an Element (e.g. document, or a synthetic event // dispatched on a non-Node) — calling .closest() blindly threw // "target.closest is not a function". if (t && typeof t.closest === "function" && t.closest(`[data-actionmenu="${resolutionId}"]`)) { 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, resolutionId]); 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 isDraft = status === "draft"; const canCancel = canCancelStatus(status); const canSend = isDraft || status === "pending_signatures"; const canComplete = status === "pending_signatures" || isDraft; const [uploading, setUploading] = useState(false); const uploadRef = useRef(null); // Destination chosen for the NEXT upload, and whether the picker is showing. const [pendingTarget, setPendingTarget] = useState(null); const [showTargets, setShowTargets] = useState(false); const handleUpload = async (file: File, target: UploadTarget) => { setUploading(true); try { const form = new FormData(); form.append("file", file); const q = new URLSearchParams({ collection: target.collection, field: target.field }); if (target.directorId) q.set("director", target.directorId); const res = await fetch( `/api/resolutions/${resolutionId}/upload?${q.toString()}`, { method: "POST", body: form } ); const body = await res.json().catch(() => null); if (!res.ok) { throw new Error(body?.error || `HTTP ${res.status}`); } // Say WHERE it went, and whether anything was displaced. A bare // "uploading again replaces it" was ambiguous enough that people uploaded // a second document and lost the first without realising. const displaced = body?.replaced && target.filled; alert( `Uploaded to ${target.label}:\n${body?.filename ?? file.name} (${Math.round( (body?.bytes ?? file.size) / 1024 )} KB)` + (displaced ? `\n\nThis replaced the document already stored in that slot.` : "") ); onChanged?.(); router.refresh(); } catch (err) { alert(`Upload failed: ${err instanceof Error ? err.message : "unknown"}`); } finally { setUploading(false); } }; return (
{open && mounted && pos && createPortal(
View details →
{/* Shows EVERY document attached to this resolution — the resolution's own copy plus each director's resignation / appointment paperwork. This replaces the old single-field "View signed document" link, which read one file off the resolution record and hid everything stored on the directors' records. Always rendered: the paperwork may sit on the director's record even when the resolution has nothing on it. */} View documents {/* Same action, same wording as section 5's kebab, so an operator moving between the two tabs finds it in the same place. An with an href, not a button + fetch: the browser then owns the download, and a plain link keeps working if JS is slow or the row re-renders mid-click. `format=docx` skips the PDF conversion, so this lands in about a second. */} Download as Word (.docx) {showTargets ? ( /* Destination picker. Every slot holds exactly ONE file, so uploading twice to the same slot silently destroyed the first document. Picking the destination first makes that impossible by accident — each option is named after the record it files against, and an occupied slot says so before you commit to it. */ <>
File this document to
{targets.map((t) => ( ))}
) : ( <> )} {canSend && ( )} {canComplete && ( )} {canCancel && ( )}
, document.body )} {/* The 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 the user has chosen a file in the OS dialog. The resulting `change` event fired on a detached node, never bubbled to the React root, and `onChange` silently never ran: the button appeared to do nothing and nothing was ever uploaded. Hoisting it here keeps it mounted for the whole lifetime of the row, so the file lands no matter what the menu does. */} { const f = e.target.files?.[0]; // Reset immediately so picking the same file twice still fires. e.target.value = ""; const target = pendingTarget; setPendingTarget(null); setShowTargets(false); if (f && target) void handleUpload(f, target); }} />
); } interface SignatureProgressProps { total: number; signed: number; status: NewResolutionStatus; } function SignatureProgress({ total, signed, status }: SignatureProgressProps) { if (status === "draft") { return ( Not yet sent ); } if (total === 0) { return No signers; } const pct = Math.round((signed / total) * 100); const complete = status === "signed"; return (
{signed} / {total}
); } interface DirectorsPillProps { resigned: Array<{ id: string; first_names?: string; surname?: string }>; appointed: Array<{ id: string; first_names?: string; surname?: string }>; } function DirectorsPill({ resigned, appointed }: DirectorsPillProps) { const rN = resigned.length; const aN = appointed.length; if (rN === 0 && aN === 0) { return No directors; } return (
{rN > 0 && ( −{rN} resign{rN === 1 ? "" : "ed"} )} {aN > 0 && ( +{aN} appoint{aN === 1 ? "ed" : "ed"} )}
); } function pathLabel(p: ResolutionPath): string { return p === "letter_generated" ? "Generated" : "Letters received"; } export default function ResolutionsTable() { const [data, setData] = useState | null>(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const load = async () => { setLoading(true); setError(null); try { const res = await fetch("/api/resolutions/list", { cache: "no-store" }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const json = (await res.json()) as PBList; setData(json); } catch (err) { setError(err instanceof Error ? err.message : "Failed to load resolutions"); } finally { setLoading(false); } }; useEffect(() => { load(); }, []); const items = useMemo(() => { if (!data) return []; // Cancelled resolutions are hidden entirely. They stay in PocketBase (the // record and its attendees are an audit trail) but they are noise in the // working list — you cancelled it, you don't need to keep seeing it. const visible = data.items.filter((r) => r.status !== "cancelled"); const time = (v: string | null | undefined) => v ? new Date(v).getTime() : Number.NEGATIVE_INFINITY; return visible.sort((a, b) => { // 1. Signed (complete) resolutions sink to the bottom. const aDone = a.status === "signed" ? 1 : 0; const bDone = b.status === "signed" ? 1 : 0; if (aDone !== bDone) return aDone - bDone; // 2. Newest meeting date first within each group. const byMeeting = time(b.meeting_date) - time(a.meeting_date); if (byMeeting !== 0) return byMeeting; // 3. Tie-break on created — several resolutions often share one meeting // date (e.g. repeated tests), and without this the order is arbitrary. return time(b.created) - time(a.created); }); }, [data]); if (loading) { return (
Loading resolutions…
); } if (error) { return (

Failed to load resolutions

{error}

); } if (items.length === 0) { // Distinguish "you have never made one" from "they're all cancelled" — // the second case otherwise nags you to create one you already created. const allCancelled = (data?.items.length ?? 0) > 0 && data!.items.every((r) => r.status === "cancelled"); return (

{allCancelled ? "No active resolutions" : "No resolutions yet"}

{allCancelled ? ( <> All {data!.items.length} resolution {data!.items.length === 1 ? "" : "s"} have been cancelled and are hidden from this list. They remain in the database as a record. ) : ( <> Click + New resolution above to create one. )}

); } return (
{items.map((r) => { const company = r.expand?.company; const companyName = company?.enterprise_name || (typeof r.company === "string" ? r.company : "—"); const companyNo = company?.enterprise_no || ""; const resignedRaw = r.expand?.resigned_directors || []; const appointedRaw = r.expand?.appointed_directors || []; // The declared expand type only carries `id`, but PocketBase returns // whole director records, so first_names/surname are present at // runtime and used to label the upload targets. const resigned = resignedRaw as unknown as DirectorRef[]; const appointed = appointedRaw as unknown as DirectorRef[]; const sig = r.signature_progress || { total: 0, signed: 0 }; // Every slot this resolution's paperwork can be filed into. Uploading // twice to ONE slot replaces the first file, so the operator picks // the destination explicitly instead of guessing. const uploadTargets: UploadTarget[] = [ { collection: "ysterbaard_resolutions", field: "signed_letter", label: "This resolution — signed resolution", filled: Boolean(r.signed_letter), }, ...resigned.flatMap((d) => [ { collection: "ysterbaard_director_resignations", field: "letter", label: `${fullName(d)} — resignation letter`, directorId: d.id, }, { collection: "ysterbaard_director_resignations", field: "signature", label: `${fullName(d)} — signed resignation`, directorId: d.id, }, ]), ...appointed.flatMap((d) => [ { collection: "ysterbaard_director_appointments", field: "letter", label: `${fullName(d)} — appointment letter`, directorId: d.id, }, { collection: "ysterbaard_director_appointments", field: "signature", label: `${fullName(d)} — signed appointment`, directorId: d.id, }, ]), ]; return ( {/* Company */} {/* Meeting date */} {/* Directors (resigned / appointed) */} {/* Path */} {/* Status */} {/* Signatures */} {/* Actions */} ); })}
Company Meeting date Directors Path Status Signatures Actions
{companyName} {companyNo && ( {companyNo} )}
{formatDate(r.meeting_date)} {pathLabel(r.path)} {statusLabel(r.status)}
); }