925 lines
36 KiB
TypeScript
925 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>
|
|||
|
|
);
|
|||
|
|
}
|