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

924 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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/<id>/cancel — soft cancel (status → cancelled,
// pending signers → expired). Signed resolutions are refused with 409.
// PATCH /api/resolutions/<id>/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/<id>/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<void> {
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 =
'<p style="font:14px system-ui;padding:24px;color:#555">Rendering the resolution letter…</p>';
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<void> {
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<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(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 <body>, 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 <body>, 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<HTMLInputElement>(null);
// Destination chosen for the NEXT upload, and whether the picker is showing.
const [pendingTarget, setPendingTarget] = useState<UploadTarget | null>(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 (
<div className="relative inline-block" data-actionmenu={resolutionId}>
<button
ref={btnRef}
onClick={() => setOpen((v) => !v)}
disabled={acting}
className="px-2.5 py-1.5 rounded-md border border-border bg-surface text-fg text-sm font-semibold hover:border-accent hover:text-accent transition-colors disabled:opacity-40"
aria-label="Actions"
>
⋯
</button>
{open && mounted && pos &&
createPortal(
<div
ref={menuRef}
style={{ top: pos.top, left: pos.left }}
className="fixed z-[100] w-48 bg-surface border border-border rounded-lg shadow-xl overflow-hidden"
>
<Link
href={`/resolutions/${resolutionId}`}
className="block px-4 py-2.5 text-sm text-fg hover:bg-surface-warm transition-colors no-underline"
>
View details →
</Link>
<div className="border-t border-border-soft" />
{/* 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. */}
<Link
href={`/resolutions/${resolutionId}/documents`}
className="block px-4 py-2.5 text-sm text-fg hover:bg-surface-warm transition-colors no-underline"
title="Every document uploaded for this resolution and its directors"
>
View documents
</Link>
<button
onClick={() => {
previewLetter(resolutionId);
setOpen(false);
}}
className="w-full text-left px-4 py-2.5 text-sm text-fg hover:bg-surface-warm transition-colors"
title="Preview the letter — nothing is emailed"
>
Preview letter
</button>
{/* Same action, same wording as section 5's kebab, so an operator
moving between the two tabs finds it in the same place. An <a>
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. */}
<a
href={`/api/resolutions/${resolutionId}/letter?format=docx`}
className="block w-full text-left px-4 py-2.5 text-sm text-fg hover:bg-surface-warm transition-colors no-underline"
title="Download the editable Word document"
>
Download as Word (.docx)
</a>
{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. */
<>
<div className="px-4 py-2 text-[11px] font-semibold uppercase tracking-wide text-muted border-b border-border-soft">
File this document to
</div>
{targets.map((t) => (
<button
key={`${t.collection}.${t.field}`}
onClick={() => {
setPendingTarget(t);
uploadRef.current?.click();
}}
disabled={uploading}
className="w-full text-left px-4 py-2.5 text-sm text-fg hover:bg-surface-warm transition-colors disabled:opacity-40"
title={t.filled ? "This slot already has a file — uploading will replace it" : "Empty slot"}
>
<span className="block">{t.label}</span>
{t.filled && (
<span className="block text-[11px] text-danger font-semibold">
already has a file — will be replaced
</span>
)}
</button>
))}
<div className="border-t border-border-soft" />
<button
onClick={() => setShowTargets(false)}
className="w-full text-left px-4 py-2.5 text-sm text-muted hover:bg-surface-warm transition-colors"
>
← Back
</button>
</>
) : (
<>
<button
onClick={() => setShowTargets(true)}
disabled={uploading}
className="w-full text-left px-4 py-2.5 text-sm text-fg hover:bg-surface-warm transition-colors disabled:opacity-40"
title="Choose where this document should be filed"
>
{uploading ? "Uploading…" : "Upload document"}
</button>
</>
)}
{canSend && (
<button
onClick={async () => {
setOpen(false);
setActing(true);
try {
await sendForSignature(resolutionId, status, onChanged);
} finally {
setActing(false);
router.refresh();
}
}}
disabled={acting}
className="w-full text-left px-4 py-2.5 text-sm text-accent font-semibold hover:bg-surface-warm transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
title={
isDraft
? "Send the letter to all must-sign attendees"
: "Resend to signers who have not signed yet"
}
>
{acting ? "Sending…" : isDraft ? "Send for signature" : "Resend to signers"}
</button>
)}
{canComplete && (
<button
onClick={async () => {
if (
!confirm(
"Mark this resolution as complete?\n\n" +
"The status becomes Signed and everyone still outstanding " +
"is counted as signed, so the progress bar reads in full."
)
) {
return;
}
setOpen(false);
setActing(true);
try {
const res = await fetch(
`/api/resolutions/${resolutionId}/complete`,
{ method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" }
);
const body = await res.json().catch(() => null);
if (!res.ok) {
throw new Error(body?.error || `HTTP ${res.status}`);
}
alert(
body?.already_signed
? "This resolution was already marked complete."
: `Marked complete. ${body?.signers_closed ?? 0} outstanding signer(s) closed out.`
);
onChanged?.();
} catch (err) {
alert(
`Could not complete: ${err instanceof Error ? err.message : "unknown"}`
);
} finally {
setActing(false);
router.refresh();
}
}}
disabled={acting}
className="w-full text-left px-4 py-2.5 text-sm text-success font-semibold hover:bg-surface-warm transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
title="Mark as complete — for resolutions signed outside the app"
>
Mark as complete
</button>
)}
{canCancel && (
<button
onClick={async () => {
if (
!confirm(
"Cancel this resolution?\n\n" +
"Pending signers will be marked expired and will no longer " +
"be able to sign. The resolution and its minutes stay on record.\n\n" +
"This cannot be undone."
)
) {
return;
}
setOpen(false);
setActing(true);
try {
await cancelResolution(resolutionId);
onChanged?.();
} catch (err) {
alert(
`Cancel failed: ${err instanceof Error ? err.message : "unknown"}`
);
} finally {
setActing(false);
router.refresh();
}
}}
disabled={acting}
className="w-full text-left px-4 py-2.5 text-sm text-danger hover:bg-surface-warm transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
title="Cancel this resolution"
>
Cancel resolution
</button>
)}
</div>,
document.body
)}
{/* The picker must NOT live inside the portal above.
The portal only renders while the menu is open, so an <input type=file>
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. */}
<input
ref={uploadRef}
type="file"
accept=".pdf,application/pdf"
className="hidden"
onChange={(e) => {
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);
}}
/>
</div>
);
}
interface SignatureProgressProps {
total: number;
signed: number;
status: NewResolutionStatus;
}
function SignatureProgress({ total, signed, status }: SignatureProgressProps) {
if (status === "draft") {
return (
<span className="text-xs text-muted italic">Not yet sent</span>
);
}
if (total === 0) {
return <span className="text-xs text-muted italic">No signers</span>;
}
const pct = Math.round((signed / total) * 100);
const complete = status === "signed";
return (
<div className="flex items-center gap-2">
<div className="flex-1 min-w-[80px] h-2 rounded-full bg-surface-warm overflow-hidden border border-border-soft">
<div
className={`h-full transition-all ${
complete ? "bg-success" : "bg-accent"
}`}
style={{ width: `${pct}%` }}
/>
</div>
<span className="text-xs font-semibold tabular-nums text-fg whitespace-nowrap">
{signed} / {total}
</span>
</div>
);
}
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 <span className="text-xs text-muted italic">No directors</span>;
}
return (
<div className="flex flex-wrap gap-1">
{rN > 0 && (
<span
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-semibold bg-danger/10 text-danger"
title={resigned.map(fullName).join(", ")}
>
−{rN} resign{rN === 1 ? "" : "ed"}
</span>
)}
{aN > 0 && (
<span
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-semibold bg-success/10 text-success"
title={appointed.map(fullName).join(", ")}
>
+{aN} appoint{aN === 1 ? "ed" : "ed"}
</span>
)}
</div>
);
}
function pathLabel(p: ResolutionPath): string {
return p === "letter_generated"
? "Generated"
: "Letters received";
}
export default function ResolutionsTable() {
const [data, setData] = useState<PBList<YsterbaardNewResolution> | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<YsterbaardNewResolution>;
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 (
<div className="bg-surface border border-border rounded-lg p-8 text-center text-muted text-sm">
Loading resolutions…
</div>
);
}
if (error) {
return (
<div className="bg-surface border border-danger/40 rounded-lg p-6 text-sm">
<p className="text-danger font-semibold mb-1">Failed to load resolutions</p>
<p className="text-muted">{error}</p>
<button
onClick={load}
className="mt-3 px-3 py-1.5 bg-accent text-accent-on rounded text-sm font-semibold"
>
Retry
</button>
</div>
);
}
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 (
<div className="bg-surface border border-border rounded-lg p-12 text-center">
<p className="text-fg font-semibold">
{allCancelled ? "No active resolutions" : "No resolutions yet"}
</p>
<p className="text-muted text-sm mt-1">
{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 <span className="font-mono">+ New resolution</span> above to
create one.
</>
)}
</p>
</div>
);
}
return (
<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-4 py-3 text-xs font-semibold text-muted uppercase tracking-wide">
Company
</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-muted uppercase tracking-wide hidden md:table-cell">
Meeting date
</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-muted uppercase tracking-wide">
Directors
</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-muted uppercase tracking-wide hidden lg:table-cell">
Path
</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-muted uppercase tracking-wide">
Status
</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-muted uppercase tracking-wide">
Signatures
</th>
<th className="text-right px-4 py-3 text-xs font-semibold text-muted uppercase tracking-wide">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-border-soft">
{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 (
<tr key={r.id} className="hover:bg-surface-warm transition-colors">
{/* Company */}
<td className="px-4 py-3.5">
<div className="flex flex-col">
<span className="font-semibold text-fg truncate max-w-[260px]">
{companyName}
</span>
{companyNo && (
<span className="text-xs text-muted font-mono">
{companyNo}
</span>
)}
</div>
</td>
{/* Meeting date */}
<td className="px-4 py-3.5 hidden md:table-cell">
<span className="text-fg-2 tabular-nums">
{formatDate(r.meeting_date)}
</span>
</td>
{/* Directors (resigned / appointed) */}
<td className="px-4 py-3.5">
<DirectorsPill resigned={resigned} appointed={appointed} />
</td>
{/* Path */}
<td className="px-4 py-3.5 hidden lg:table-cell">
<span className="text-xs text-muted">{pathLabel(r.path)}</span>
</td>
{/* Status */}
<td className="px-4 py-3.5">
<StatusBadge variant={statusVariant(r.status)}>
{statusLabel(r.status)}
</StatusBadge>
</td>
{/* Signatures */}
<td className="px-4 py-3.5">
<SignatureProgress
total={sig.total}
signed={sig.signed}
status={r.status}
/>
</td>
{/* Actions */}
<td className="px-4 py-3.5 text-right">
<ActionMenu
resolutionId={r.id}
status={r.status}
hasDocument={Boolean(r.signed_letter)}
uploadTargets={uploadTargets}
onChanged={load}
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}