Workflow-App/Workflow/components/ShareholderChangeTable.tsx

919 lines
34 KiB
TypeScript
Raw Normal View History

// 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&hellip;
</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 &ldquo;+ Company change in shareholding&rdquo; 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();
}
}}
/>
</>
);
}