// components/ShareholderChangeWizard.tsx // // Multi-step modal for section 5 — "Company change in shareholding". // Triggered from the section-5 header's "+ Company change in shareholding" // button. The button names the COMPANY whose shareholding changes, not the // shareholder being added: the same parties may already appear on other // companies' resolutions, and "add a shareholder" read as though it were a // register entry. // // Steps: // 0 — Pick the company whose shares change hands // 1 — Attendees: who was present (directors), their capacity, who signs // 2 — Transferors (sellers): certificate, percentage, shares, consideration // 3 — Transferees (buyers) // 4 — Meeting date, voting rights, path, notes // 5 — Review & create // // THREE SEPARATE LISTS, deliberately not merged: // attendees who was present to approve (directors only) // transferors who is selling (director or company, 1+) // transferees who is buying (director or company, 1+) // A director can appear in more than one; they become separate entries because // the letter states both facts about them. // // The wizard creates a DRAFT and sends nothing. Mailing is a separate, explicit // action from the row's menu, matching section 4. // // On success the modal closes and the parent refreshes so the new draft row // appears in the table immediately. "use client"; import { useEffect, useMemo, useState, useCallback } from "react"; import { useRouter } from "next/navigation"; import { PAYMENT_METHODS } from "@/lib/shareholder-changes"; // ─── Types ────────────────────────────────────────────────────────────────── interface Company { id: string; enterprise_name: string; enterprise_no?: string; } type Source = "director" | "company"; type Role = "transferor" | "transferee"; interface PartyOption { /** `${source}:${id}` — unique across both lists. */ key: string; source: Source; source_id: string; name: string; surname: string; email: string | null; /** ID number for a director, enterprise number for a company. */ id_number: string | null; /** Cell number, printed in the signature block like section 4 does. */ cell: string | null; label: string; hint?: string; } /** A meeting attendee — directors only, with capacity and a signing tick. */ interface AttendeeDraft extends PartyOption { position: string; signs: boolean; } /** A seller — every certificate-level field the letter's WHEREAS recites. */ interface TransferorDraft extends PartyOption { shares_held: string; shares: string; certificate_no: string; percentage: string; distinctive_from: string; distinctive_to: string; shares_retained: string; retained_from: string; retained_to: string; consideration: string; payment_method: string; } interface TransfereeDraft extends PartyOption { shares: string; } interface WizardState { step: number; company: Company | null; attendees: AttendeeDraft[]; transferors: TransferorDraft[]; transferees: TransfereeDraft[]; meeting_date: string; votes_percent: string; path: "letter_generated" | "letters_received"; notes: string; } // ─── Helpers ──────────────────────────────────────────────────────────────── const STEPS = [ "Pick company", "Attendees", "Transferors (sellers)", "Transferees (buyers)", "Date and details", "Review", ] as const; function todayISO(): string { return new Date().toISOString().split("T")[0]; } function cn(...parts: Array): string { return parts.filter(Boolean).join(" "); } function parsePositiveInt(raw: string): number | null { const t = (raw ?? "").trim(); if (!t) return null; if (!/^\d+$/.test(t)) return null; const n = Number(t); return Number.isSafeInteger(n) && n > 0 ? n : null; } function parseNonNegativeInt(raw: string): number | null { const t = (raw ?? "").trim(); if (!t) return null; if (!/^\d+$/.test(t)) return null; const n = Number(t); return Number.isSafeInteger(n) && n >= 0 ? n : null; } function fmtShares(n: number): string { return new Intl.NumberFormat("en-ZA").format(n); } let draftSeq = 0; function nextKey(prefix: string): string { draftSeq += 1; return `${prefix}-${Date.now()}-${draftSeq}`; } const INPUT = "w-full bg-surface border border-border rounded px-3 py-2 text-sm text-fg placeholder-muted focus:outline-none focus:border-accent transition-colors"; function Field({ label, children, hint, invalid, }: { label: string; children: React.ReactNode; hint?: string; invalid?: boolean; }) { return ( ); } function StepDots({ current, labels }: { current: number; labels: readonly string[] }) { return (
{labels.map((label, i) => ( ))}
); } /** Search box + results list, shared by all three party pickers. */ function PartySearch({ options, onPick, placeholder, chosenKeys, emptyNote, }: { options: PartyOption[]; onPick: (o: PartyOption) => void; placeholder: string; chosenKeys: Set; emptyNote?: string; }) { const [q, setQ] = useState(""); const matches = useMemo(() => { const needle = q.trim().toLowerCase(); if (!needle) return []; return options .filter((o) => !chosenKeys.has(o.key)) .filter( (o) => o.label.toLowerCase().includes(needle) || (o.hint ?? "").toLowerCase().includes(needle) || (o.id_number ?? "").toLowerCase().includes(needle) ) .slice(0, 25); }, [q, options, chosenKeys]); return (
setQ(e.target.value)} /> {q.trim() && matches.length === 0 && (

Nothing matches “{q.trim()}”.{emptyNote ?? ""}

)} {matches.length > 0 && (
{matches.map((o) => ( ))}
)}
); } // ─── Component ────────────────────────────────────────────────────────────── /** * A saved draft handed back in, so the wizard can re-open on the same six * steps filled from what was stored. The operator corrects one field and saves; * they do not re-enter the whole resolution. */ export interface EditableResolution { id: string; company_id: string; meeting_date: string; path: "letter_generated" | "letters_received"; votes_percent: string; note: string; attendees: Array<{ director_id: string; name: string; surname: string; email: string | null; id_number: string | null; cell: string | null; position: string; signs: boolean; }>; transferors: Array<{ source: "director" | "company"; source_id: string; name: string; surname: string; email: string | null; id_number: string | null; cell: string | null; shares_held: number | null; shares: number; certificate_no: string; percentage: string; distinctive_from: string; distinctive_to: string; shares_retained: number; retained_from: string; retained_to: string; consideration: string; payment_method: string; }>; transferees: Array<{ source: "director" | "company"; source_id: string; name: string; surname: string; email: string | null; id_number: string | null; cell: string | null; shares: number; }>; } interface Props { open: boolean; onClose: () => void; /** Present = edit mode: same steps, pre-filled, saves back to the same row. */ editing?: EditableResolution | null; } export default function ShareholderChangeWizard({ open, onClose, editing = null }: Props) { const router = useRouter(); const isEdit = !!editing; const emptyState = (): WizardState => ({ step: 0, company: null, attendees: [], transferors: [], transferees: [], meeting_date: todayISO(), votes_percent: "", path: "letter_generated", notes: "", }); /** * Rebuild wizard state from a saved resolution. * * Parties are reconstructed from the STORED values, not re-derived from the * companies/directors lists — an edit must not quietly change someone's name * because a record was renamed since the draft was created. */ const stateFromEdit = (d: EditableResolution, company: Company | null): WizardState => ({ step: 0, company, attendees: d.attendees.map((a) => ({ key: `director:${a.director_id}`, source: "director" as const, source_id: a.director_id, label: `${a.name} ${a.surname}`.trim(), name: a.name, surname: a.surname, email: a.email, id_number: a.id_number ?? null, cell: a.cell ?? null, hint: "", position: a.position, signs: a.signs, })), transferors: d.transferors.map((p) => ({ key: `${p.source}:${p.source_id}`, source: p.source, source_id: p.source_id, label: `${p.name} ${p.surname}`.trim() || p.name, name: p.name, surname: p.surname, email: p.email, id_number: p.id_number, cell: p.cell ?? null, hint: "", shares_held: p.shares_held == null ? "" : String(p.shares_held), shares: String(p.shares ?? 0), certificate_no: p.certificate_no ?? "", percentage: p.percentage ?? "", distinctive_from: p.distinctive_from ?? "", distinctive_to: p.distinctive_to ?? "", shares_retained: String(p.shares_retained ?? 0), retained_from: p.retained_from ?? "", retained_to: p.retained_to ?? "", consideration: p.consideration ?? "", payment_method: p.payment_method || PAYMENT_METHODS[1], })), transferees: d.transferees.map((p) => ({ key: `${p.source}:${p.source_id}`, source: p.source, source_id: p.source_id, label: `${p.name} ${p.surname}`.trim() || p.name, name: p.name, surname: p.surname, email: p.email, id_number: p.id_number, cell: p.cell ?? null, hint: "", shares: String(p.shares ?? 0), })), meeting_date: d.meeting_date, votes_percent: d.votes_percent ?? "", path: d.path ?? "letter_generated", notes: d.note ?? "", }); const [state, setState] = useState(emptyState); const [submitting, setSubmitting] = useState(false); const [submitError, setSubmitError] = useState(null); const [companies, setCompanies] = useState([]); const [parties, setParties] = useState<{ directors: PartyOption[]; companies: PartyOption[]; }>({ directors: [], companies: [] }); const [companySearch, setCompanySearch] = useState(""); useEffect(() => { if (!open) return; setSubmitError(null); setSubmitting(false); setCompanySearch(""); setState(editing ? stateFromEdit(editing, null) : emptyState()); // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, editing]); // In edit mode the company object is needed for step 1's summary, but the // companies list loads after the wizard opens. Resolve it once it arrives // rather than making the operator re-pick the company they already chose. useEffect(() => { if (!open || !editing || state.company) return; const match = companies.find((c) => c.id === editing.company_id); if (match) { setState((s) => (s.company ? s : { ...s, company: match })); } }, [open, editing, companies, state.company]); useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape" && !submitting) onClose(); }; document.addEventListener("keydown", onKey); return () => document.removeEventListener("keydown", onKey); }, [open, submitting, onClose]); useEffect(() => { if (!open) return; let cancelled = false; fetch("/api/companies/list") .then((r) => r.json()) .then((d) => { if (!cancelled) setCompanies(d.items || []); }) .catch(() => { if (!cancelled) setCompanies([]); }); return () => { cancelled = true; }; }, [open]); // Party options depend on the chosen company: its own record is excluded from // the company list, and its directors are flagged so the obvious candidates // surface first. useEffect(() => { if (!open || !state.company) { setParties({ directors: [], companies: [] }); return; } let cancelled = false; fetch(`/api/shareholder-changes/parties?company_id=${encodeURIComponent(state.company.id)}`) .then((r) => r.json()) .then((d) => { if (cancelled) return; const raw = d.items || {}; const directorOpts: PartyOption[] = (raw.directors || []).map((x: Record) => ({ key: `director:${x.id}`, source: "director" as const, source_id: x.id, name: x.name ?? "", surname: x.surname ?? "", email: x.email ?? null, id_number: x.id_no ?? null, cell: x.telephone ?? null, label: [x.name, x.surname].filter(Boolean).join(" ").trim() || "Unnamed director", hint: x.linked ? "Director of this company" : undefined, })); const companyOpts: PartyOption[] = (raw.companies || []).map((x: Record) => ({ key: `company:${x.id}`, source: "company" as const, source_id: x.id, name: x.name ?? "", surname: "", email: x.email ?? null, id_number: x.enterprise_no ?? null, cell: null, label: x.name || "Unnamed company", hint: undefined, })); directorOpts.sort((a, b) => { const la = a.hint === "Director of this company" ? 0 : 1; const lb = b.hint === "Director of this company" ? 0 : 1; return la - lb || a.label.localeCompare(b.label); }); setParties({ directors: directorOpts, companies: companyOpts }); }) .catch(() => { if (!cancelled) setParties({ directors: [], companies: [] }); }); return () => { cancelled = true; }; }, [open, state.company]); // ─── Derived ────────────────────────────────────────────────────────────── const allPartyOptions = useMemo( () => [...parties.directors, ...parties.companies], [parties] ); const filteredCompanies = useMemo(() => { const q = companySearch.trim().toLowerCase(); if (!q) return companies; const needle = q.replace(/\s+/g, ""); return companies.filter((c) => { const name = (c.enterprise_name ?? "").toLowerCase().replace(/\s+/g, ""); const number = (c.enterprise_no ?? "").toLowerCase().replace(/\s+/g, ""); return name.includes(needle) || number.includes(needle); }); }, [companies, companySearch]); const sold = useMemo( () => state.transferors.reduce((n, p) => n + (parsePositiveInt(p.shares) ?? 0), 0), [state.transferors] ); const acquired = useMemo( () => state.transferees.reduce((n, p) => n + (parsePositiveInt(p.shares) ?? 0), 0), [state.transferees] ); const balanceOk = sold === acquired; // Per-step validation. Each picking step is judged only on its own list — // gating "Next" on the combined result deadlocked the wizard, because leaving // the sellers step required a buyer to already exist. const attendeeProblems = useMemo(() => { const out: string[] = []; if (state.attendees.length === 0) { out.push("Add at least one attendee — someone has to pass the resolution."); } if (!state.attendees.some((a) => a.signs)) { out.push("Tick at least one attendee as signing, or the resolution has no signature."); } return out; }, [state.attendees]); const transferorProblems = useMemo(() => { const out: string[] = []; if (state.transferors.length === 0) { out.push("Add at least one transferor — the shareholder selling the shares."); } if (state.transferors.some((p) => parsePositiveInt(p.shares) === null)) { out.push("Every transferor needs a share count."); } return out; }, [state.transferors]); const transfereeProblems = useMemo(() => { const out: string[] = []; if (state.transferees.length === 0) { out.push("Add at least one transferee — the shareholder buying the shares."); } if (state.transferees.some((p) => parsePositiveInt(p.shares) === null)) { out.push("Every transferee needs a share count."); } return out; }, [state.transferees]); const detailProblems = useMemo(() => { const out: string[] = []; const v = state.votes_percent.trim(); if (!v) { // Optional here once meant the letter shipped "shareholders holding ___% // of the voting rights". A blank in a signed resolution is worse than // being asked one extra question here. out.push("Enter the voting rights in favour — the letter's Voting Rights clause prints it."); } else if (!/^\d{1,3}(\.\d+)?%?$/.test(v)) { out.push("Voting rights should be a percentage, e.g. 100 or 100%."); } return out; }, [state.votes_percent]); const crossProblems = useMemo(() => { const out: string[] = []; const sellerKeys = new Set(state.transferors.map((p) => p.key)); if (state.transferees.some((p) => sellerKeys.has(p.key))) { out.push("Someone cannot sell to themselves on the same resolution."); } if (state.transferors.length > 0 && state.transferees.length > 0 && !balanceOk) { out.push( `Shares sold (${fmtShares(sold)}) must equal shares acquired (${fmtShares(acquired)}).` ); } return out; }, [state.transferors, state.transferees, sold, acquired, balanceOk]); const problems = useMemo( () => [ ...attendeeProblems, ...transferorProblems, ...transfereeProblems, ...detailProblems, ...crossProblems, ], [attendeeProblems, transferorProblems, transfereeProblems, detailProblems, crossProblems] ); const stepProblems = useMemo(() => { switch (state.step) { case 1: return attendeeProblems; case 2: return transferorProblems; case 3: return transfereeProblems; case 4: return detailProblems; case 5: return problems; default: return []; } }, [ state.step, attendeeProblems, transferorProblems, transfereeProblems, detailProblems, problems, ]); const canAdvance = useMemo(() => { switch (state.step) { case 0: return !!state.company; case 1: case 2: case 3: return stepProblems.length === 0; case 4: return /^\d{4}-\d{2}-\d{2}$/.test(state.meeting_date); case 5: return problems.length === 0; default: return false; } }, [state, stepProblems, problems]); const goNext = () => { if (!canAdvance) return; setState((s) => ({ ...s, step: Math.min(s.step + 1, STEPS.length - 1) })); }; const goBack = () => setState((s) => ({ ...s, step: Math.max(s.step - 1, 0) })); // ─── Mutations ──────────────────────────────────────────────────────────── const addAttendee = (o: PartyOption) => { setState((s) => ({ ...s, attendees: [ ...s.attendees, { ...o, position: o.hint === "Director of this company" ? "Director" : "", signs: true }, ], })); }; const addTransferor = (o: PartyOption) => { setState((s) => { const other = s.transferees.filter((p) => p.key !== o.key); return { ...s, transferees: other, transferors: [ ...s.transferors, { ...o, shares_held: "", shares: "", certificate_no: "", percentage: "", distinctive_from: "", distinctive_to: "", shares_retained: "", retained_from: "", retained_to: "", consideration: "", payment_method: PAYMENT_METHODS[1], }, ], }; }); }; const addTransferee = (o: PartyOption) => { setState((s) => { const other = s.transferors.filter((p) => p.key !== o.key); // One seller, one buyer: the balance rule already fixes the number, so it // is not a guess — pre-filling it stops the operator staring at a // disabled "Next" with only a grey sentence explaining why. With several // buyers the split is genuinely their decision, so leave it blank. const remainingSold = other.reduce( (n, p) => n + (parsePositiveInt(p.shares) ?? 0), 0 ); const prefill = s.transferees.length === 0 && other.length === 1 && remainingSold > 0 ? String(remainingSold) : ""; return { ...s, transferors: other, transferees: [...s.transferees, { ...o, shares: prefill }], }; }); }; /** Edit one certificate-level field on a single seller. */ const patchTransferor = (key: string, patch: Partial) => { setState((s) => ({ ...s, transferors: s.transferors.map((x) => (x.key === key ? { ...x, ...patch } : x)), })); }; /** What the letter will print for "shares retained" when the box is blank. */ const retainedGuess = (p: TransferorDraft): number | null => { if (p.shares_retained.trim() !== "") return null; const held = parseNonNegativeInt(p.shares_held); const sold = parsePositiveInt(p.shares); if (held === null || held === undefined || sold === null) return null; return Math.max(0, held - sold); }; // ─── Submit ─────────────────────────────────────────────────────────────── const submit = useCallback(async () => { if (!state.company) { setSubmitError("Pick a company first."); return; } if (problems.length > 0) { setSubmitError(problems[0]); return; } setSubmitting(true); setSubmitError(null); try { const base = (o: PartyOption) => ({ source: o.source, source_id: o.source_id, name: o.name, surname: o.surname, email: o.email, // The letter recites this ("... (ID 8001234567)"), so it travels with // the party rather than being looked up again at render time. id_number: o.id_number, cell: o.cell, }); // Same six steps, same body — only the verb changes. Edit rewrites the // existing row so its id, uploaded files and row position survive. const url = isEdit && editing ? `/api/shareholder-changes/${editing.id}` : "/api/shareholder-changes/create"; const res = await fetch(url, { method: isEdit ? "PATCH" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ company_id: state.company.id, meeting_date: state.meeting_date, path: state.path, votes_percent: state.votes_percent || undefined, notes: state.notes || undefined, attendees: state.attendees.map((a) => ({ ...base(a), director_id: a.source_id, position: a.position, signs: a.signs, })), transferors: state.transferors.map((p) => ({ ...base(p), role: "transferor" as const, shares_held: parseNonNegativeInt(p.shares_held), shares: parsePositiveInt(p.shares), certificate_no: p.certificate_no, percentage: p.percentage, distinctive_from: p.distinctive_from, distinctive_to: p.distinctive_to, // null (not 0) when blank: the server reads null as "work it out from // held − sold". Sending 0 here would assert the seller keeps nothing. shares_retained: p.shares_retained.trim() === "" ? null : parseNonNegativeInt(p.shares_retained), retained_from: p.retained_from, retained_to: p.retained_to, consideration: p.consideration, payment_method: p.payment_method, })), transferees: state.transferees.map((p) => ({ ...base(p), role: "transferee" as const, shares: parsePositiveInt(p.shares), })), }), }); const json = await res.json().catch(() => ({})); if (!res.ok) throw new Error(json.error || `HTTP ${res.status}`); onClose(); router.refresh(); } catch (err) { setSubmitError( err instanceof Error ? err.message : isEdit ? "Failed to save changes" : "Failed to create resolution" ); } finally { setSubmitting(false); } }, [state, problems, onClose, router, isEdit, editing]); if (!open) return null; const primaryBtn = "px-5 py-2 bg-accent text-accent-on rounded font-semibold text-sm disabled:opacity-40 hover:opacity-90 transition-opacity"; return (
{/* Header */}

{isEdit ? "Edit — Company Change in Shareholding" : "Company Change in Shareholding"}

Step {state.step + 1} of {STEPS.length} — {STEPS[state.step]}

{/* Body */}
{/* ── Step 0: company ─────────────────────────────────────────── */} {state.step === 0 && ( <>

Which company’s shareholding is changing? The shareholders and attendees are picked from the database in the next steps.

{companies.length === 0 ? (

No companies loaded.

) : ( <> setCompanySearch(e.target.value)} autoFocus /> {companySearch.trim() && filteredCompanies.length === 0 ? (

Nothing matches “{companySearch.trim()}”.

) : (
{filteredCompanies.map((c) => ( ))}
)} {companySearch.trim() && filteredCompanies.length > 0 && (

Showing {filteredCompanies.length} of {companies.length} companies.

)} )} )} {/* ── Step 1: attendees ───────────────────────────────────────── */} {state.step === 1 && ( <>

Who attended the meeting? Attendees are picked from the directors list. Tick who must sign — the signature block only includes the ticked ones.

{state.attendees.length === 0 ? (

No attendee added yet.

) : (
{state.attendees.map((a, i) => (

{i + 1}. {a.label}

{a.email ?? "no email on file"}

setState((s) => ({ ...s, attendees: s.attendees.map((x) => x.key === a.key ? { ...x, position: e.target.value } : x ), })) } />
))}
)}
a.key))} onPick={addAttendee} placeholder="Search directors to add an attendee..." emptyNote=" Add the person to the Directors list first." />
)} {/* ── Step 2: transferors ─────────────────────────────────────── */} {state.step === 2 && ( <>

Who is selling shares? Any number of shareholders can sell. The certificate details below are written into the letter’s WHEREAS clauses — leave a field blank and the letter prints a fill line instead.

{state.transferors.length === 0 ? (

No transferor added yet.

) : (
{state.transferors.map((p, i) => { const sharesInvalid = p.shares !== "" && parsePositiveInt(p.shares) === null; return (

{i + 1}. {p.label}

{p.source === "company" ? "Company" : "Director"} {p.id_number ? ` · ${p.id_number}` : ""}

patchTransferor(p.key, { shares: e.target.value })} /> patchTransferor(p.key, { shares_held: e.target.value })} /> patchTransferor(p.key, { percentage: e.target.value })} /> patchTransferor(p.key, { certificate_no: e.target.value })} /> patchTransferor(p.key, { consideration: e.target.value })} />
Distinctive numbers — sold
patchTransferor(p.key, { distinctive_from: e.target.value })} /> patchTransferor(p.key, { distinctive_to: e.target.value })} />
Distinctive numbers — retained
patchTransferor(p.key, { retained_from: e.target.value })} /> patchTransferor(p.key, { retained_to: e.target.value })} />
{/* Leave this blank and the letter works it out as "held − sold". It used to resolve to 0, which printed "shall retain the remaining 0 shares (Distinctive numbers: 52 to 100)" — self-contradictory, and in a signed document. */} patchTransferor(p.key, { shares_retained: e.target.value })} /> {p.shares_retained.trim() === "" && retainedGuess(p) !== null && (

Blank → the letter will use{" "} {retainedGuess(p)} ( {p.shares_held || "?"} held − {p.shares || "?"} sold).

)}
); })}
)}
p.key))} onPick={addTransferor} placeholder="Search directors and companies to add a seller..." />
)} {/* ── Step 3: transferees ─────────────────────────────────────── */} {state.step === 3 && ( <>

Who is buying the shares? Shares bought must add up to shares sold.

{state.transferees.length === 0 ? (

No transferee added yet.

) : (
{state.transferees.map((p, i) => { const invalid = p.shares !== "" && parsePositiveInt(p.shares) === null; return (

{i + 1}. {p.label}

{p.source === "company" ? "Company" : "Director"} {p.id_number ? ` · ${p.id_number}` : ""} {p.email ? ` · ${p.email}` : " · no email"}

{/* Labelled, not just a placeholder — an unlabelled box on the right reads as decoration and is the reason people get stuck on this step. */}
); })}
)}
Balance {fmtShares(sold)} sold / {fmtShares(acquired)} acquired
p.key))} onPick={addTransferee} placeholder="Search directors and companies to add a buyer..." />
)} {/* ── Step 4: date / voting / path / notes ────────────────────── */} {state.step === 4 && ( <>
setState((s) => ({ ...s, meeting_date: e.target.value })) } /> setState((s) => ({ ...s, votes_percent: e.target.value })) } />
How was this resolved?
{( [ [ "letter_generated", "Letter generated by us", "We prepare the resolution from the template.", ], [ "letters_received", "Letters already received", "The parties have already sent through paperwork.", ], ] as const ).map(([value, label, hint]) => ( ))}