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.
919 lines
No EOL
34 KiB
TypeScript
919 lines
No EOL
34 KiB
TypeScript
// components/ShareholderChangeTable.tsx
|
|
//
|
|
// Section 5 — "Change in shareholders": the list of shareholder-change
|
|
// resolutions and the actions available on each.
|
|
//
|
|
// WORKFLOW THIS ENCODES
|
|
// ---------------------
|
|
// 1. Operator fills in the wizard → draft resolution is created, nothing sent
|
|
// 2. [⋯] Preview letter → renders a fresh PDF in a new tab
|
|
// 3. [⋯] Email to all parties → renders, files and emails that PDF;
|
|
// parties print, sign by hand, return it
|
|
// 4. [⋯] Upload document → choose the slot FIRST: the signed
|
|
// resolution, or the signed share
|
|
// certificates, which are made outside
|
|
// the app and come back separately
|
|
// 5. [⋯] Mark as complete → status closes the loop
|
|
//
|
|
// This mirrors section 4 exactly: every action lives in the row's kebab, not as
|
|
// inline buttons, so the row stays readable and there is one target to hit.
|
|
// Creating a draft sends nothing — mailing is always a separate, explicit click.
|
|
//
|
|
// Steps 4 and 5 are separate on purpose. Section 5 has no browser signing flow,
|
|
// so the signed copy arrives out of band and the operator files it; being able
|
|
// to fix a status without re-uploading a file is the point.
|
|
//
|
|
// A note on sending: the API returns 200 once it has PROCESSED the send.
|
|
// Per-recipient delivery is in the response body (`emailed[].ok`, `skipped`) and
|
|
// this component renders that result rather than assuming success — a green tick
|
|
// that is not backed by a real send is worse than a visible failure.
|
|
//
|
|
// DELIBERATE NON-SHARING: `ResolutionsTable.tsx` (section 4) has a very similar
|
|
// portalled kebab. It is duplicated rather than extracted because section 4's
|
|
// component is frozen — refactoring it risks changing the legal output and the
|
|
// section-4 flow that is already in daily use.
|
|
//
|
|
// The upload destination picker below is kept deliberately equivalent to
|
|
// section 4's so an operator moving between the two tabs finds the same menu.
|
|
// The one difference is the slot list: section 5 has TWO slots on the resolution
|
|
// itself (signed resolution, signed share certificates), where section 4 has
|
|
// one on the resolution plus one per director.
|
|
|
|
"use client";
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { useRouter } from "next/navigation";
|
|
import StatusBadge from "@/components/StatusBadge";
|
|
import ShareholderChangeWizard, {
|
|
type EditableResolution,
|
|
} from "@/components/ShareholderChangeWizard";
|
|
import type { ComponentProps } from "react";
|
|
|
|
type BadgeVariant = ComponentProps<typeof StatusBadge>["variant"];
|
|
|
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
|
|
interface Attendee {
|
|
id: string;
|
|
name: string;
|
|
surname: string;
|
|
email: string | null;
|
|
role: string;
|
|
position: string;
|
|
shares: number | null;
|
|
}
|
|
|
|
interface Item {
|
|
id: string;
|
|
meeting_date: string | null;
|
|
status: string;
|
|
path: string;
|
|
generated_letter: string;
|
|
signed_letter: string;
|
|
/** New share certificates, produced outside the app and signed by hand. */
|
|
signed_certificates: string;
|
|
company: {
|
|
id: string;
|
|
enterprise_name: string;
|
|
enterprise_no: string;
|
|
authorised_shares: number | null;
|
|
} | null;
|
|
attendees: Attendee[];
|
|
totals: {
|
|
sold: number;
|
|
acquired: number;
|
|
sold_label: string;
|
|
acquired_label: string;
|
|
balances: boolean;
|
|
parties: number;
|
|
};
|
|
}
|
|
|
|
interface SendOutcome {
|
|
ok: boolean;
|
|
message: string;
|
|
detail?: string;
|
|
}
|
|
|
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
|
|
function cn(...parts: Array<string | false | null | undefined>): string {
|
|
return parts.filter(Boolean).join(" ");
|
|
}
|
|
|
|
function fmtDate(iso: string | null): string {
|
|
if (!iso) return "—";
|
|
const d = new Date(iso);
|
|
if (Number.isNaN(d.getTime())) return "—";
|
|
return d.toLocaleDateString("en-ZA", { day: "2-digit", month: "short", year: "numeric" });
|
|
}
|
|
|
|
function statusVariant(status: string): BadgeVariant {
|
|
switch (status) {
|
|
case "signed":
|
|
return "success";
|
|
case "pending_signatures":
|
|
return "warn";
|
|
case "cancelled":
|
|
return "danger";
|
|
case "draft":
|
|
default:
|
|
return "muted";
|
|
}
|
|
}
|
|
|
|
function statusLabel(status: string): string {
|
|
switch (status) {
|
|
case "pending_signatures":
|
|
return "Awaiting signatures";
|
|
case "draft":
|
|
return "Draft";
|
|
default:
|
|
return status.charAt(0).toUpperCase() + status.slice(1);
|
|
}
|
|
}
|
|
|
|
function partyLabel(a: Attendee): string {
|
|
const full = [a.name, a.surname].filter(Boolean).join(" ").trim();
|
|
if (!full) return "—";
|
|
return full
|
|
.split(/\s+/)
|
|
.map((w) => (w ? w[0].toUpperCase() + w.slice(1).toLowerCase() : w))
|
|
.join(" ");
|
|
}
|
|
|
|
/**
|
|
* Preview the letter exactly as the parties will receive it — regenerated from
|
|
* the stored detail, served in a new tab, **nothing is emailed**.
|
|
*
|
|
* This is the operator's approval step: read it, then email it.
|
|
*/
|
|
function previewLetter(resolutionId: string) {
|
|
// Open immediately: browsers block window.open() after an await.
|
|
const url = `/api/shareholder-changes/${resolutionId}/preview`;
|
|
const win = window.open("", "_blank");
|
|
if (!win) {
|
|
window.open(url, "_blank", "noopener");
|
|
return;
|
|
}
|
|
win.document.title = "Preparing resolution…";
|
|
win.document.body.innerHTML =
|
|
'<p style="font:14px system-ui;padding:24px;color:#555">Rendering the special resolution…</p>';
|
|
win.location.href = url;
|
|
}
|
|
|
|
// ─── Action kebab ───────────────────────────────────────────────────────────
|
|
|
|
/** One place a document can be filed. Each slot holds exactly one file. */
|
|
interface UploadTarget {
|
|
collection: string;
|
|
field: string;
|
|
label: string;
|
|
/** True when the slot already holds a file, so the operator is warned. */
|
|
filled?: boolean;
|
|
}
|
|
|
|
interface ActionMenuProps {
|
|
item: Item;
|
|
busy: boolean;
|
|
onEdit: (item: Item) => void;
|
|
onSend: (item: Item) => void;
|
|
onUpload: (item: Item, file: File, target: UploadTarget) => void;
|
|
onStatus: (item: Item, status: string) => void;
|
|
}
|
|
|
|
function ShareholderActionMenu({
|
|
item,
|
|
busy,
|
|
onEdit,
|
|
onSend,
|
|
onUpload,
|
|
onStatus,
|
|
}: ActionMenuProps) {
|
|
const [open, setOpen] = useState(false);
|
|
const [mounted, setMounted] = useState(false);
|
|
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
|
const btnRef = useRef<HTMLButtonElement>(null);
|
|
const menuRef = useRef<HTMLDivElement>(null);
|
|
const uploadRef = useRef<HTMLInputElement>(null);
|
|
// Destination chosen for the NEXT upload, and whether the picker is showing.
|
|
// Same shape as section 4: every slot holds exactly ONE file, so uploading
|
|
// twice to the same slot silently destroys the first document. Making the
|
|
// operator pick the destination first makes that impossible by accident.
|
|
const [showTargets, setShowTargets] = useState(false);
|
|
const [pendingTarget, setPendingTarget] = useState<UploadTarget | null>(null);
|
|
|
|
const targets: UploadTarget[] = [
|
|
{
|
|
collection: "ysterbaard_resolutions",
|
|
field: "signed_letter",
|
|
label: "This resolution — signed resolution",
|
|
filled: !!item.signed_letter,
|
|
},
|
|
{
|
|
collection: "ysterbaard_resolutions",
|
|
field: "signed_certificates",
|
|
label: "This resolution — signed share certificates",
|
|
filled: !!item.signed_certificates,
|
|
},
|
|
];
|
|
|
|
useEffect(() => setMounted(true), []);
|
|
|
|
// The menu is PORTALLED to <body> so no ancestor can clip it. The card is
|
|
// overflow-hidden, which sliced an in-card dropdown off the last row.
|
|
const place = useCallback(() => {
|
|
const el = btnRef.current;
|
|
if (!el) return;
|
|
const r = el.getBoundingClientRect();
|
|
const MENU_W = 224; // must match the w-56 class below
|
|
const MENU_H = 340; // generous: picker view is the tallest (2 slots + back)
|
|
const GAP = 4;
|
|
let top = r.bottom + GAP;
|
|
if (top + MENU_H > window.innerHeight - 8 && r.top - MENU_H - GAP > 8) {
|
|
top = r.top - MENU_H - GAP;
|
|
}
|
|
const left = Math.max(
|
|
8,
|
|
Math.min(r.right - MENU_W, window.innerWidth - MENU_W - 8)
|
|
);
|
|
setPos({ top, left });
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
place();
|
|
const reposition = () => place();
|
|
window.addEventListener("scroll", reposition, true);
|
|
window.addEventListener("resize", reposition);
|
|
return () => {
|
|
window.removeEventListener("scroll", reposition, true);
|
|
window.removeEventListener("resize", reposition);
|
|
};
|
|
}, [open, place]);
|
|
|
|
// Close on outside click. Both the trigger and the portalled panel must match,
|
|
// because the menu no longer lives inside the trigger's DOM subtree.
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const handler = (e: MouseEvent) => {
|
|
const t = e.target as HTMLElement | null;
|
|
if (t && typeof t.closest === "function" && t.closest(`[data-sh-menu="${item.id}"]`)) {
|
|
return;
|
|
}
|
|
if (menuRef.current && t && typeof t.closest === "function" && menuRef.current.contains(t)) {
|
|
return;
|
|
}
|
|
setOpen(false);
|
|
};
|
|
document.addEventListener("mousedown", handler);
|
|
return () => document.removeEventListener("mousedown", handler);
|
|
}, [open, item.id]);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") setOpen(false);
|
|
};
|
|
document.addEventListener("keydown", onKey);
|
|
return () => document.removeEventListener("keydown", onKey);
|
|
}, [open]);
|
|
|
|
const menuItem =
|
|
"w-full text-left px-4 py-2.5 text-sm text-fg hover:bg-surface-warm transition-colors disabled:opacity-40 disabled:cursor-not-allowed";
|
|
|
|
const isSigned = item.status === "signed";
|
|
const isCancelled = item.status === "cancelled";
|
|
|
|
return (
|
|
<div className="relative inline-block" data-sh-menu={item.id}>
|
|
<button
|
|
ref={btnRef}
|
|
onClick={() => setOpen((v) => !v)}
|
|
disabled={busy}
|
|
aria-label="Actions"
|
|
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"
|
|
>
|
|
⋯
|
|
</button>
|
|
|
|
{open && mounted && pos &&
|
|
createPortal(
|
|
<div
|
|
ref={menuRef}
|
|
style={{ top: pos.top, left: pos.left }}
|
|
className="fixed z-[100] w-56 bg-surface border border-border rounded-lg shadow-xl overflow-hidden max-h-[75vh] overflow-y-auto"
|
|
>
|
|
<button
|
|
onClick={() => {
|
|
previewLetter(item.id);
|
|
setOpen(false);
|
|
}}
|
|
className={menuItem}
|
|
title="Open the letter in a new tab — nothing is emailed"
|
|
>
|
|
Preview letter
|
|
</button>
|
|
|
|
<a
|
|
href={`/api/shareholder-changes/${item.id}/preview?format=docx`}
|
|
className={cn(menuItem, "block no-underline")}
|
|
title="Download the editable Word document"
|
|
>
|
|
Download as Word (.docx)
|
|
</a>
|
|
|
|
{/* Only a draft can be corrected. Once mailed or signed it is a
|
|
record of what the parties hold. */}
|
|
{!isSigned && !isCancelled && (
|
|
<button
|
|
onClick={() => {
|
|
setOpen(false);
|
|
onEdit(item);
|
|
}}
|
|
disabled={busy}
|
|
className={menuItem}
|
|
title="Re-open the six steps, filled in, to correct a mistake"
|
|
>
|
|
Edit draft…
|
|
</button>
|
|
)}
|
|
|
|
{/* Shows EVERY document on this resolution — the emailed letter, the
|
|
signed resolution and the signed share certificates. Replaces the
|
|
old pair of per-field "View the … copy" links, which meant the
|
|
operator had to know which slot a file was in. Always rendered:
|
|
the page itself states plainly when nothing has been filed yet. */}
|
|
<a
|
|
href={`/resolutions/${item.id}/documents`}
|
|
className={cn(menuItem, "block no-underline")}
|
|
title="Every document uploaded for this resolution"
|
|
>
|
|
View documents
|
|
</a>
|
|
|
|
{showTargets ? (
|
|
/* Destination picker — identical to section 4's.
|
|
|
|
Both slots hold exactly ONE file, so uploading twice to the same
|
|
slot silently destroyed the first document. Choosing the
|
|
destination first makes that impossible by accident: each option
|
|
is named after what it files, and an occupied slot says so
|
|
before the operator commits to it. */
|
|
<>
|
|
<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={busy}
|
|
className={menuItem}
|
|
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={cn(menuItem, "text-muted")}
|
|
>
|
|
← Back
|
|
</button>
|
|
</>
|
|
) : (
|
|
<button
|
|
onClick={() => setShowTargets(true)}
|
|
disabled={busy}
|
|
className={menuItem}
|
|
title="Choose where this document should be filed"
|
|
>
|
|
Upload document
|
|
</button>
|
|
)}
|
|
|
|
<div className="border-t border-border-soft" />
|
|
|
|
<button
|
|
onClick={() => {
|
|
if (
|
|
!confirm(
|
|
"Email this resolution to all parties?\n\n" +
|
|
"Everyone on the resolution gets the letter as a PDF attachment. " +
|
|
"They print it, sign by hand and send it back.\n\n" +
|
|
"Preview it first if you have not already."
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
setOpen(false);
|
|
onSend(item);
|
|
}}
|
|
disabled={busy || isCancelled}
|
|
className={cn(menuItem, "text-accent font-semibold")}
|
|
title="Render the letter and email it to every attendee"
|
|
>
|
|
{busy ? "Working…" : item.generated_letter ? "Email again" : "Email to all parties"}
|
|
</button>
|
|
|
|
{/* Section 4 offers this for any draft or awaiting-signatures row, and section 5
|
|
does the same so the two tabs behave identically. It is NOT gated on
|
|
a file being present: a resolution can legitimately come back
|
|
signed on paper with the scan still on its way, and hiding the
|
|
action would strand that row. The badge on the card shows what is
|
|
actually on file. */}
|
|
{!isSigned && !isCancelled && (
|
|
<button
|
|
onClick={() => {
|
|
if (
|
|
!confirm(
|
|
"Mark this resolution as complete?\n\n" +
|
|
"The status becomes Signed and the row closes.\n\n" +
|
|
(item.signed_letter
|
|
? "The signed resolution is on file."
|
|
: "No signed resolution has been uploaded yet — make sure you " +
|
|
"still have the paper copy before you close this.") +
|
|
"\n\nYou can reopen it as a draft afterwards."
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
setOpen(false);
|
|
onStatus(item, "signed");
|
|
}}
|
|
disabled={busy}
|
|
className={cn(menuItem, "text-success font-semibold")}
|
|
title="Mark as complete — for resolutions signed outside the app"
|
|
>
|
|
Mark as complete
|
|
</button>
|
|
)}
|
|
|
|
{isSigned && (
|
|
<button
|
|
onClick={() => {
|
|
if (!confirm("Reopen this resolution as a draft?")) return;
|
|
setOpen(false);
|
|
onStatus(item, "draft");
|
|
}}
|
|
disabled={busy}
|
|
className={menuItem}
|
|
>
|
|
Reopen as draft
|
|
</button>
|
|
)}
|
|
|
|
{!isSigned && !isCancelled && (
|
|
<button
|
|
onClick={() => {
|
|
if (
|
|
!confirm(
|
|
"Cancel this resolution?\n\n" +
|
|
"It stays on record, but is marked cancelled and will not be " +
|
|
"emailed again.\n\nThis cannot be undone."
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
setOpen(false);
|
|
onStatus(item, "cancelled");
|
|
}}
|
|
disabled={busy}
|
|
className={cn(menuItem, "text-danger")}
|
|
>
|
|
Cancel resolution
|
|
</button>
|
|
)}
|
|
</div>,
|
|
document.body
|
|
)}
|
|
|
|
{/* The file 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 a file is chosen in the OS dialog. The resulting
|
|
`change` event fired on a detached node and `onChange` silently never
|
|
ran: the button appeared to do nothing.
|
|
|
|
Hoisting it here keeps it mounted for the row's whole lifetime, so the
|
|
file lands no matter what the menu does. */}
|
|
<input
|
|
ref={uploadRef}
|
|
type="file"
|
|
accept=".pdf,application/pdf"
|
|
className="hidden"
|
|
onChange={(e) => {
|
|
const f = e.target.files?.[0];
|
|
// Reset first so picking the same file twice still fires.
|
|
e.target.value = "";
|
|
const target = pendingTarget;
|
|
setPendingTarget(null);
|
|
setShowTargets(false);
|
|
if (f && target) onUpload(item, f, target);
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Component ──────────────────────────────────────────────────────────────
|
|
|
|
export default function ShareholderChangeTable() {
|
|
const router = useRouter();
|
|
const [items, setItems] = useState<Item[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [busyId, setBusyId] = useState<string | null>(null);
|
|
const [outcomes, setOutcomes] = useState<Record<string, SendOutcome>>({});
|
|
const [editing, setEditing] = useState<EditableResolution | null>(null);
|
|
const [editLoading, setEditLoading] = useState(false);
|
|
|
|
// ─── Edit ──────────────────────────────────────────────────────────────────
|
|
// The saved draft is fetched and handed to the same six-step wizard, so a
|
|
// correction is one field, not a full re-entry.
|
|
const handleEdit = useCallback(async (item: Item) => {
|
|
setEditLoading(true);
|
|
try {
|
|
const res = await fetch(`/api/shareholder-changes/${item.id}`);
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok) {
|
|
setOutcome(item.id, {
|
|
ok: false,
|
|
message: data.error || `Could not open for editing (HTTP ${res.status})`,
|
|
});
|
|
return;
|
|
}
|
|
if (!data.editable) {
|
|
setOutcome(item.id, {
|
|
ok: false,
|
|
message:
|
|
data.error ||
|
|
`This resolution is ${String(item.status).replace(/_/g, " ")} and can no longer be edited.`,
|
|
});
|
|
return;
|
|
}
|
|
setEditing(data as EditableResolution);
|
|
} catch (err) {
|
|
setOutcome(item.id, {
|
|
ok: false,
|
|
message: err instanceof Error ? err.message : "Could not open for editing",
|
|
});
|
|
} finally {
|
|
setEditLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
const res = await fetch("/api/shareholder-changes/list");
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
|
setItems(data.items || []);
|
|
setError(null);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Could not load resolutions");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
load();
|
|
}, [load]);
|
|
|
|
const setOutcome = (id: string, outcome: SendOutcome) => {
|
|
setOutcomes((prev) => ({ ...prev, [id]: outcome }));
|
|
};
|
|
|
|
// ─── Send ─────────────────────────────────────────────────────────────────
|
|
const handleSend = useCallback(
|
|
async (item: Item) => {
|
|
setBusyId(item.id);
|
|
setOutcome(item.id, { ok: false, message: "Rendering the resolution and emailing…" });
|
|
try {
|
|
const res = await fetch(`/api/shareholder-changes/${item.id}/send`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({}),
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok) {
|
|
setOutcome(item.id, {
|
|
ok: false,
|
|
message: data.error || `Send failed (HTTP ${res.status})`,
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Report what actually happened per party rather than a blanket "sent".
|
|
const sent: string[] = (data.emailed || [])
|
|
.filter((e: { ok: boolean }) => e.ok)
|
|
.map((e: { name: string }) => e.name);
|
|
const failed: Array<{ name: string; error?: string }> = (data.emailed || []).filter(
|
|
(e: { ok: boolean }) => !e.ok
|
|
);
|
|
const skipped: Array<{ name: string; reason: string }> = data.skipped || [];
|
|
|
|
const bits: string[] = [];
|
|
if (sent.length) bits.push(`Emailed ${sent.join(", ")}`);
|
|
if (skipped.length)
|
|
bits.push(`Not emailed (${skipped.map((s) => `${s.name}: ${s.reason}`).join("; ")})`);
|
|
if (failed.length)
|
|
bits.push(`Failed (${failed.map((f) => `${f.name}: ${f.error ?? "unknown error"}`).join("; ")})`);
|
|
|
|
const allGood = failed.length === 0 && skipped.length === 0;
|
|
setOutcome(item.id, {
|
|
ok: allGood,
|
|
message:
|
|
bits.join(" · ") ||
|
|
(data.sent === 0
|
|
? "Nothing was sent — check the parties have email addresses."
|
|
: "Done."),
|
|
detail: data.letter_error
|
|
? `Letter problem: ${data.letter_error}`
|
|
: data.unfilled_placeholders?.length
|
|
? `Unfilled placeholders: ${data.unfilled_placeholders.join(", ")}`
|
|
: undefined,
|
|
});
|
|
|
|
await load();
|
|
router.refresh();
|
|
} catch (err) {
|
|
setOutcome(item.id, {
|
|
ok: false,
|
|
message: err instanceof Error ? err.message : "Send failed",
|
|
});
|
|
} finally {
|
|
setBusyId(null);
|
|
}
|
|
},
|
|
[load, router]
|
|
);
|
|
|
|
// ─── Upload signed ────────────────────────────────────────────────────────
|
|
const handleUpload = useCallback(
|
|
async (item: Item, file: File, target: UploadTarget) => {
|
|
setBusyId(item.id);
|
|
setOutcome(item.id, {
|
|
ok: false,
|
|
message: `Filing "${file.name}" against ${target.label}…`,
|
|
});
|
|
try {
|
|
const form = new FormData();
|
|
form.append("file", file);
|
|
const q = new URLSearchParams({ collection: target.collection, field: target.field });
|
|
const res = await fetch(`/api/resolutions/${item.id}/upload?${q.toString()}`, {
|
|
method: "POST",
|
|
body: form,
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok) {
|
|
setOutcome(item.id, {
|
|
ok: false,
|
|
message: data.error || `Upload failed (HTTP ${res.status})`,
|
|
});
|
|
return;
|
|
}
|
|
// Say WHERE it went and whether anything was displaced. The two slots
|
|
// are genuinely different documents, so "uploaded" alone leaves the
|
|
// operator unsure which one they just filled.
|
|
const displaced = data.replaced && target.filled;
|
|
setOutcome(item.id, {
|
|
ok: true,
|
|
message:
|
|
`Filed to ${target.label} — "${data.filename}"` +
|
|
(displaced ? ` (replaced the file already in that slot)` : "") +
|
|
(target.field === "signed_letter"
|
|
? ". Use ⋯ → Mark as complete when you are ready."
|
|
: "."),
|
|
});
|
|
await load();
|
|
router.refresh();
|
|
} catch (err) {
|
|
setOutcome(item.id, {
|
|
ok: false,
|
|
message: err instanceof Error ? err.message : "Upload failed",
|
|
});
|
|
} finally {
|
|
setBusyId(null);
|
|
}
|
|
},
|
|
[load, router]
|
|
);
|
|
|
|
// ─── Status ───────────────────────────────────────────────────────────────
|
|
const handleStatus = useCallback(
|
|
async (item: Item, status: string) => {
|
|
setBusyId(item.id);
|
|
try {
|
|
const res = await fetch(`/api/shareholder-changes/${item.id}/status`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ status }),
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok) {
|
|
setOutcome(item.id, { ok: false, message: data.error || `HTTP ${res.status}` });
|
|
return;
|
|
}
|
|
setOutcome(item.id, { ok: true, message: `Marked ${status.replace(/_/g, " ")}.` });
|
|
await load();
|
|
router.refresh();
|
|
} catch (err) {
|
|
setOutcome(item.id, {
|
|
ok: false,
|
|
message: err instanceof Error ? err.message : "Could not update status",
|
|
});
|
|
} finally {
|
|
setBusyId(null);
|
|
}
|
|
},
|
|
[load, router]
|
|
);
|
|
|
|
// ─── Render ───────────────────────────────────────────────────────────────
|
|
if (loading) {
|
|
return (
|
|
<div className="bg-surface border border-border rounded-lg p-8 text-center text-muted text-sm">
|
|
Loading change-in-shareholders resolutions…
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="bg-surface border border-danger/40 rounded-lg p-6 text-sm text-danger">
|
|
{error}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (items.length === 0) {
|
|
return (
|
|
<div className="bg-surface border border-border rounded-lg p-10 text-center">
|
|
<p className="text-sm font-semibold text-fg">No company shareholding changes yet</p>
|
|
<p className="text-sm text-muted mt-1">
|
|
Use “+ Company change in shareholding” to record a transfer of shares.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div className="space-y-3">
|
|
{items.map((item) => {
|
|
const busy = busyId === item.id;
|
|
const outcome = outcomes[item.id];
|
|
const transferors = item.attendees.filter((a) => a.role === "transferor");
|
|
const transferees = item.attendees.filter((a) => a.role === "transferee");
|
|
const attendees = item.attendees.filter((a) => a.role === "existing");
|
|
|
|
return (
|
|
<div key={item.id} className="bg-surface border border-border rounded-lg overflow-hidden">
|
|
{/* Header row */}
|
|
<div className="px-5 py-4 flex flex-wrap items-start justify-between gap-3 border-b border-border-soft">
|
|
<div className="min-w-0">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<span className="text-sm font-semibold text-fg">
|
|
{item.company?.enterprise_name || "Unknown company"}
|
|
</span>
|
|
<StatusBadge variant={statusVariant(item.status)}>
|
|
{statusLabel(item.status)}
|
|
</StatusBadge>
|
|
{!item.totals.balances && (
|
|
<StatusBadge variant="danger">Does not balance</StatusBadge>
|
|
)}
|
|
{item.signed_letter && item.status !== "signed" && (
|
|
<StatusBadge variant="warn">Signed copy on file</StatusBadge>
|
|
)}
|
|
{item.signed_certificates && (
|
|
<StatusBadge variant="success">Share certificates on file</StatusBadge>
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-muted mt-1">
|
|
{item.company?.enterprise_no ? (
|
|
<span className="font-mono">{item.company.enterprise_no}</span>
|
|
) : null}
|
|
{item.company?.enterprise_no ? " · " : ""}
|
|
Resolution date {fmtDate(item.meeting_date)}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex items-start gap-3 flex-shrink-0">
|
|
<div className="text-right">
|
|
<p className="text-sm font-semibold text-fg tabular-nums">
|
|
{item.totals.sold_label} shares
|
|
</p>
|
|
<p className="text-xs text-muted">
|
|
{transferors.length} selling → {transferees.length} buying
|
|
</p>
|
|
</div>
|
|
<ShareholderActionMenu
|
|
item={item}
|
|
busy={busy}
|
|
onEdit={handleEdit}
|
|
onSend={handleSend}
|
|
onUpload={handleUpload}
|
|
onStatus={handleStatus}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Parties */}
|
|
<div className="px-5 py-4 grid gap-4 sm:grid-cols-3 border-b border-border-soft">
|
|
<div>
|
|
<p className="text-xs font-semibold text-muted uppercase tracking-wide mb-1.5">
|
|
Transferors
|
|
</p>
|
|
<ul className="space-y-1">
|
|
{transferors.map((a) => (
|
|
<li key={a.id} className="text-sm text-fg-2 flex justify-between gap-2">
|
|
<span className="truncate">{partyLabel(a)}</span>
|
|
<span className="tabular-nums text-muted flex-shrink-0">{a.shares ?? 0}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs font-semibold text-muted uppercase tracking-wide mb-1.5">
|
|
Transferees
|
|
</p>
|
|
<ul className="space-y-1">
|
|
{transferees.map((a) => (
|
|
<li key={a.id} className="text-sm text-fg-2 flex justify-between gap-2">
|
|
<span className="truncate">{partyLabel(a)}</span>
|
|
<span className="tabular-nums text-muted flex-shrink-0">{a.shares ?? 0}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs font-semibold text-muted uppercase tracking-wide mb-1.5">
|
|
Present at the meeting
|
|
</p>
|
|
<ul className="space-y-1">
|
|
{attendees.map((a) => (
|
|
<li key={a.id} className="text-sm text-fg-2 truncate">
|
|
{partyLabel(a)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Outcome of the last action */}
|
|
{outcome && (
|
|
<div
|
|
className={cn(
|
|
"px-5 py-2.5 text-xs border-t",
|
|
outcome.ok
|
|
? "text-success border-border-soft bg-surface-warm"
|
|
: "text-danger border-border-soft bg-danger/5"
|
|
)}
|
|
>
|
|
{outcome.message}
|
|
{outcome.detail ? (
|
|
<span className="block mt-0.5 text-muted">{outcome.detail}</span>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Edit re-opens the SAME six-step wizard, filled from the saved draft. */}
|
|
<ShareholderChangeWizard
|
|
open={!!editing}
|
|
editing={editing}
|
|
onClose={() => {
|
|
const wasEditing = editing;
|
|
setEditing(null);
|
|
// The wizard saved through its own request; refresh so the row shows
|
|
// the corrected figures rather than the stale ones.
|
|
if (wasEditing) {
|
|
load();
|
|
router.refresh();
|
|
}
|
|
}}
|
|
/>
|
|
</>
|
|
);
|
|
} |