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.
924 lines
36 KiB
TypeScript
924 lines
36 KiB
TypeScript
// 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>
|
||
);
|
||
}
|