Workflow-App/Workflow/components/ShareholderChangeWizard.tsx
sjcoetzeesj-coder 11d0178ab1 feat: commit Workflow app source; move CIPC credentials to env
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.
2026-10-18 15:21:45 +02:00

1506 lines
No EOL
61 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 | false | null | undefined>): 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 (
<label className="block">
<span className="text-xs font-semibold text-muted uppercase tracking-wide">
{label}
</span>
<div className="mt-1">{children}</div>
{hint ? <span className="text-xs text-muted">{hint}</span> : null}
{invalid ? <span className="text-xs text-danger">{hint ?? "Enter a valid value."}</span> : null}
</label>
);
}
function StepDots({ current, labels }: { current: number; labels: readonly string[] }) {
return (
<div className="hidden sm:flex items-center gap-1.5">
{labels.map((label, i) => (
<span
key={label}
title={label}
className={cn(
"h-1.5 rounded-full transition-all",
i === current ? "w-6 bg-accent" : i < current ? "w-1.5 bg-accent/50" : "w-1.5 bg-border"
)}
/>
))}
</div>
);
}
/** 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<string>;
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 (
<div className="space-y-3">
<input
className={INPUT}
placeholder={placeholder}
value={q}
onChange={(e) => setQ(e.target.value)}
/>
{q.trim() && matches.length === 0 && (
<p className="text-sm text-muted">
Nothing matches &ldquo;{q.trim()}&rdquo;.{emptyNote ?? ""}
</p>
)}
{matches.length > 0 && (
<div className="border border-border rounded-lg divide-y divide-border-soft max-h-56 overflow-y-auto">
{matches.map((o) => (
<button
key={o.key}
onClick={() => {
onPick(o);
setQ("");
}}
className="w-full text-left px-3 py-2.5 hover:bg-surface-warm transition-colors"
>
<p className="text-sm font-semibold text-fg">{o.label}</p>
<p className="text-xs text-muted truncate">
{o.source === "company" ? "Company" : "Director"}
{o.hint ? ` · ${o.hint}` : ""}
{o.id_number ? ` · ${o.id_number}` : ""}
{o.email ? ` · ${o.email}` : " · no email"}
</p>
</button>
))}
</div>
)}
</div>
);
}
// ─── 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<WizardState>(emptyState);
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [companies, setCompanies] = useState<Company[]>([]);
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<string, string>) => ({
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<string, string>) => ({
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<TransferorDraft>) => {
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 (
<div className="fixed inset-0 z-50 flex items-start justify-center bg-black/60 backdrop-blur-sm py-8">
<div className="bg-surface border border-border rounded-xl shadow-2xl w-full max-w-3xl mx-4 overflow-hidden flex flex-col max-h-[90vh]">
{/* Header */}
<div className="border-b border-border bg-surface-warm px-6 py-4 flex items-center justify-between gap-4">
<div className="min-w-0">
<h2 className="text-fg font-bold text-lg truncate">
{isEdit ? "Edit — Company Change in Shareholding" : "Company Change in Shareholding"}
</h2>
<p className="text-muted text-sm mt-0.5 truncate">
Step {state.step + 1} of {STEPS.length} — {STEPS[state.step]}
</p>
</div>
<div className="flex items-center gap-3 flex-shrink-0">
<StepDots current={state.step} labels={STEPS} />
<button
onClick={onClose}
disabled={submitting}
aria-label="Close"
className="text-muted hover:text-fg text-xl leading-none px-1 disabled:opacity-40"
>
&times;
</button>
</div>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto px-6 py-5 space-y-5">
{/* ── Step 0: company ─────────────────────────────────────────── */}
{state.step === 0 && (
<>
<p className="text-sm text-muted">
Which company&rsquo;s shareholding is changing? The shareholders and
attendees are picked from the database in the next steps.
</p>
{companies.length === 0 ? (
<p className="text-sm text-muted">No companies loaded.</p>
) : (
<>
<input
className={INPUT}
placeholder="Search by company name or registration number..."
value={companySearch}
onChange={(e) => setCompanySearch(e.target.value)}
autoFocus
/>
{companySearch.trim() && filteredCompanies.length === 0 ? (
<p className="text-sm text-muted border border-dashed border-border rounded-lg p-4 text-center">
Nothing matches &ldquo;{companySearch.trim()}&rdquo;.
</p>
) : (
<div className="border border-border rounded-lg divide-y divide-border-soft max-h-[45vh] overflow-y-auto">
{filteredCompanies.map((c) => (
<button
key={c.id}
onClick={() => setState((s) => ({ ...s, company: c }))}
className={cn(
"w-full text-left px-4 py-3 transition-colors",
state.company?.id === c.id
? "bg-accent/10"
: "hover:bg-surface-warm"
)}
>
<p className="text-sm font-semibold text-fg truncate">
{c.enterprise_name}
</p>
{c.enterprise_no && (
<p className="text-xs text-muted font-mono">{c.enterprise_no}</p>
)}
</button>
))}
</div>
)}
{companySearch.trim() && filteredCompanies.length > 0 && (
<p className="text-xs text-muted">
Showing {filteredCompanies.length} of {companies.length} companies.
</p>
)}
</>
)}
</>
)}
{/* ── Step 1: attendees ───────────────────────────────────────── */}
{state.step === 1 && (
<>
<p className="text-sm text-muted">
Who attended the meeting? Attendees are picked from the directors
list. Tick who must sign — the signature block only includes the
ticked ones.
</p>
{state.attendees.length === 0 ? (
<p className="text-sm text-muted border border-dashed border-border rounded-lg p-4 text-center">
No attendee added yet.
</p>
) : (
<div className="space-y-2">
{state.attendees.map((a, i) => (
<div
key={a.key}
className="border border-border rounded-lg p-3 flex items-start gap-3"
>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-fg truncate">
{i + 1}. {a.label}
</p>
<p className="text-xs text-muted truncate">
{a.email ?? "no email on file"}
</p>
<input
className={cn(INPUT, "mt-2")}
placeholder="Capacity, e.g. Director / Chairman / Secretary"
value={a.position}
onChange={(e) =>
setState((s) => ({
...s,
attendees: s.attendees.map((x) =>
x.key === a.key ? { ...x, position: e.target.value } : x
),
}))
}
/>
</div>
<div className="flex flex-col items-end gap-2 flex-shrink-0">
<label className="flex items-center gap-2 text-xs font-semibold text-fg cursor-pointer">
<input
type="checkbox"
checked={a.signs}
onChange={(e) =>
setState((s) => ({
...s,
attendees: s.attendees.map((x) =>
x.key === a.key ? { ...x, signs: e.target.checked } : x
),
}))
}
className="w-4 h-4 accent-[color:var(--accent)]"
/>
Signs
</label>
<button
onClick={() =>
setState((s) => ({
...s,
attendees: s.attendees.filter((x) => x.key !== a.key),
}))
}
className="text-xs font-semibold text-muted hover:text-danger px-1"
>
Remove
</button>
</div>
</div>
))}
</div>
)}
<div className="border-t border-border pt-4">
<PartySearch
options={parties.directors}
chosenKeys={new Set(state.attendees.map((a) => a.key))}
onPick={addAttendee}
placeholder="Search directors to add an attendee..."
emptyNote=" Add the person to the Directors list first."
/>
</div>
</>
)}
{/* ── Step 2: transferors ─────────────────────────────────────── */}
{state.step === 2 && (
<>
<p className="text-sm text-muted">
Who is selling shares? Any number of shareholders can sell. The
certificate details below are written into the letter&rsquo;s
WHEREAS clauses — leave a field blank and the letter prints a fill
line instead.
</p>
{state.transferors.length === 0 ? (
<p className="text-sm text-muted border border-dashed border-border rounded-lg p-4 text-center">
No transferor added yet.
</p>
) : (
<div className="space-y-3">
{state.transferors.map((p, i) => {
const sharesInvalid = p.shares !== "" && parsePositiveInt(p.shares) === null;
return (
<div
key={p.key}
className={cn(
"border rounded-lg p-3 space-y-3",
sharesInvalid ? "border-danger/50 bg-danger/5" : "border-border"
)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-semibold text-fg truncate">
{i + 1}. {p.label}
</p>
<p className="text-xs text-muted truncate">
{p.source === "company" ? "Company" : "Director"}
{p.id_number ? ` · ${p.id_number}` : ""}
</p>
</div>
<button
onClick={() =>
setState((s) => ({
...s,
transferors: s.transferors.filter((x) => x.key !== p.key),
}))
}
className="text-xs font-semibold text-muted hover:text-danger px-1 flex-shrink-0"
>
Remove
</button>
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<Field label="Shares being sold" invalid={sharesInvalid}>
<input
inputMode="numeric"
className={INPUT}
placeholder="e.g. 51"
value={p.shares}
onChange={(e) => patchTransferor(p.key, { shares: e.target.value })}
/>
</Field>
<Field label="Shares held before">
<input
inputMode="numeric"
className={INPUT}
placeholder="e.g. 100"
value={p.shares_held}
onChange={(e) => patchTransferor(p.key, { shares_held: e.target.value })}
/>
</Field>
<Field label="Percentage held">
<input
className={INPUT}
placeholder="e.g. 51%"
value={p.percentage}
onChange={(e) => patchTransferor(p.key, { percentage: e.target.value })}
/>
</Field>
<Field
label="Existing share certificate no."
hint="The certificate the seller holds now — the one this resolution cancels. Not the new one."
>
<input
className={INPUT}
placeholder="e.g. 1042"
value={p.certificate_no}
onChange={(e) => patchTransferor(p.key, { certificate_no: e.target.value })}
/>
</Field>
<Field label="Consideration (R)">
<input
className={INPUT}
placeholder="e.g. 250000"
value={p.consideration}
onChange={(e) => patchTransferor(p.key, { consideration: e.target.value })}
/>
</Field>
<Field label="Paid by">
<select
className={INPUT}
value={p.payment_method}
onChange={(e) => patchTransferor(p.key, { payment_method: e.target.value })}
>
{PAYMENT_METHODS.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
</Field>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<fieldset className="border border-border rounded p-2">
<legend className="text-xs font-semibold text-muted px-1">
Distinctive numbers — sold
</legend>
<div className="grid grid-cols-2 gap-2">
<input
className={INPUT}
placeholder="from"
value={p.distinctive_from}
onChange={(e) => patchTransferor(p.key, { distinctive_from: e.target.value })}
/>
<input
className={INPUT}
placeholder="to"
value={p.distinctive_to}
onChange={(e) => patchTransferor(p.key, { distinctive_to: e.target.value })}
/>
</div>
</fieldset>
<fieldset className="border border-border rounded p-2">
<legend className="text-xs font-semibold text-muted px-1">
Distinctive numbers — retained
</legend>
<div className="grid grid-cols-2 gap-2">
<input
className={INPUT}
placeholder="from"
value={p.retained_from}
onChange={(e) => patchTransferor(p.key, { retained_from: e.target.value })}
/>
<input
className={INPUT}
placeholder="to"
value={p.retained_to}
onChange={(e) => patchTransferor(p.key, { retained_to: e.target.value })}
/>
</div>
</fieldset>
</div>
<div>
{/*
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.
*/}
<input
inputMode="numeric"
className={INPUT}
placeholder="Shares retained after this transfer (optional)"
value={p.shares_retained}
onChange={(e) => patchTransferor(p.key, { shares_retained: e.target.value })}
/>
{p.shares_retained.trim() === "" &&
retainedGuess(p) !== null && (
<p className="text-xs text-muted mt-1">
Blank &rarr; the letter will use{" "}
<strong className="text-fg-2">{retainedGuess(p)}</strong> (
{p.shares_held || "?"} held &minus; {p.shares || "?"} sold).
</p>
)}
</div>
</div>
);
})}
</div>
)}
<div className="border-t border-border pt-4">
<PartySearch
options={allPartyOptions}
chosenKeys={new Set(state.transferors.map((p) => p.key))}
onPick={addTransferor}
placeholder="Search directors and companies to add a seller..."
/>
</div>
</>
)}
{/* ── Step 3: transferees ─────────────────────────────────────── */}
{state.step === 3 && (
<>
<p className="text-sm text-muted">
Who is buying the shares? Shares bought must add up to shares sold.
</p>
{state.transferees.length === 0 ? (
<p className="text-sm text-muted border border-dashed border-border rounded-lg p-4 text-center">
No transferee added yet.
</p>
) : (
<div className="space-y-2">
{state.transferees.map((p, i) => {
const invalid = p.shares !== "" && parsePositiveInt(p.shares) === null;
return (
<div
key={p.key}
className={cn(
"border rounded-lg p-3 flex items-center gap-3",
invalid ? "border-danger/50 bg-danger/5" : "border-border"
)}
>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-fg truncate">
{i + 1}. {p.label}
</p>
<p className="text-xs text-muted truncate">
{p.source === "company" ? "Company" : "Director"}
{p.id_number ? ` · ${p.id_number}` : ""}
{p.email ? ` · ${p.email}` : " · no email"}
</p>
</div>
{/* Labelled, not just a placeholder — an unlabelled box
on the right reads as decoration and is the reason
people get stuck on this step. */}
<label className="flex items-center gap-2 flex-shrink-0">
<span className="text-xs text-muted whitespace-nowrap">shares</span>
<input
inputMode="numeric"
className={cn(INPUT, "w-24 flex-shrink-0")}
placeholder="0"
aria-label="Shares this transferee is receiving"
value={p.shares}
onChange={(e) =>
setState((s) => ({
...s,
transferees: s.transferees.map((x) =>
x.key === p.key ? { ...x, shares: e.target.value } : x
),
}))
}
/>
</label>
<button
onClick={() =>
setState((s) => ({
...s,
transferees: s.transferees.filter((x) => x.key !== p.key),
}))
}
className="text-xs font-semibold text-muted hover:text-danger px-1 flex-shrink-0"
>
Remove
</button>
</div>
);
})}
</div>
)}
<div className="flex items-center justify-between border-t border-border pt-3 text-sm">
<span className="text-muted">Balance</span>
<span
className={cn(
"font-semibold tabular-nums",
balanceOk ? "text-success" : "text-warning"
)}
>
{fmtShares(sold)} sold / {fmtShares(acquired)} acquired
</span>
</div>
<div className="border-t border-border pt-4">
<PartySearch
options={allPartyOptions}
chosenKeys={new Set(state.transferees.map((p) => p.key))}
onPick={addTransferee}
placeholder="Search directors and companies to add a buyer..."
/>
</div>
</>
)}
{/* ── Step 4: date / voting / path / notes ────────────────────── */}
{state.step === 4 && (
<>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Meeting date">
<input
type="date"
className={INPUT}
value={state.meeting_date}
onChange={(e) =>
setState((s) => ({ ...s, meeting_date: e.target.value }))
}
/>
</Field>
<Field
label="Voting rights in favour (%)"
hint="Recorded in the Voting Rights clause of the letter."
>
<input
className={INPUT}
placeholder="e.g. 100"
value={state.votes_percent}
onChange={(e) =>
setState((s) => ({ ...s, votes_percent: e.target.value }))
}
/>
</Field>
</div>
<div>
<span className="text-xs font-semibold text-muted uppercase tracking-wide">
How was this resolved?
</span>
<div className="mt-2 grid gap-2 sm:grid-cols-2">
{(
[
[
"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]) => (
<button
key={value}
onClick={() => setState((s) => ({ ...s, path: value }))}
className={cn(
"text-left px-3 py-3 rounded-lg border transition-colors",
state.path === value
? "border-accent bg-accent/10"
: "border-border bg-surface hover:border-accent/50"
)}
>
<p className="text-sm font-semibold text-fg">{label}</p>
<p className="text-xs text-muted mt-0.5">{hint}</p>
</button>
))}
</div>
</div>
<Field label="Notes (optional)">
<textarea
rows={3}
className={cn(INPUT, "resize-y")}
placeholder="Anything worth remembering about this transfer."
value={state.notes}
onChange={(e) => setState((s) => ({ ...s, notes: e.target.value }))}
/>
</Field>
</>
)}
{/* ── Step 5: review ──────────────────────────────────────────── */}
{state.step === 5 && (
<>
<div>
<h3 className="text-sm font-semibold text-fg mb-1">Company</h3>
<p className="text-sm text-fg-2">
{state.company?.enterprise_name}
{state.company?.enterprise_no ? (
<span className="font-mono text-xs text-muted">
{" "}
· {state.company.enterprise_no}
</span>
) : null}
</p>
</div>
<div>
<h3 className="text-sm font-semibold text-fg mb-2">
Attendees ({state.attendees.length})
</h3>
<div className="border border-border rounded-lg divide-y divide-border-soft">
{state.attendees.map((a) => (
<div key={a.key} className="px-3 py-2 flex justify-between gap-3 text-sm">
<span className="text-fg truncate">{a.label}</span>
<span className="text-muted flex-shrink-0">
{a.position || "Shareholder"}
{a.signs ? " · signs" : ""}
</span>
</div>
))}
</div>
</div>
<div>
<h3 className="text-sm font-semibold text-fg mb-2">
Transferors ({fmtShares(sold)} shares)
</h3>
<div className="border border-border rounded-lg divide-y divide-border-soft">
{state.transferors.map((p) => (
<div key={p.key} className="px-3 py-2 flex justify-between gap-3 text-sm">
<span className="text-fg truncate">{p.label}</span>
<span className="text-muted tabular-nums flex-shrink-0">
sells {fmtShares(parsePositiveInt(p.shares) ?? 0)}
{p.consideration ? ` for R${p.consideration}` : ""}
</span>
</div>
))}
</div>
</div>
<div>
<h3 className="text-sm font-semibold text-fg mb-2">
Transferees ({fmtShares(acquired)} shares)
</h3>
<div className="border border-border rounded-lg divide-y divide-border-soft">
{state.transferees.map((p) => (
<div key={p.key} className="px-3 py-2 flex justify-between gap-3 text-sm">
<span className="text-fg truncate">{p.label}</span>
<span className="text-muted tabular-nums flex-shrink-0">
acquires {fmtShares(parsePositiveInt(p.shares) ?? 0)}
</span>
</div>
))}
</div>
</div>
<div className="flex items-center justify-between border-t border-border pt-3 text-sm">
<span className="text-muted">Balance</span>
<span
className={cn(
"font-semibold tabular-nums",
balanceOk ? "text-success" : "text-danger"
)}
>
{fmtShares(sold)} sold / {fmtShares(acquired)} acquired
{balanceOk ? " ✓" : " ✗"}
</span>
</div>
<div className="text-sm text-muted">
<p>
Meeting date <span className="text-fg-2">{state.meeting_date}</span>
{state.votes_percent ? (
<>
{" "}
· voting rights{" "}
<span className="text-fg-2">{state.votes_percent}%</span>
</>
) : null}
</p>
{state.notes && <p className="mt-1">Notes: {state.notes}</p>}
</div>
{problems.length > 0 && (
<ul className="text-sm text-danger space-y-1">
{problems.map((p) => (
<li key={p}>• {p}</li>
))}
</ul>
)}
{submitError && (
<p className="text-sm text-danger border border-danger/40 bg-danger/5 rounded px-3 py-2">
{submitError}
</p>
)}
<p className="text-xs text-muted border border-border rounded-lg p-3">
{isEdit ? (
<>
This will <strong>update</strong> the existing draft and keep its
row, its files and its position in the list. Go back to any step
and correct only what is wrong.
</>
) : (
<>
Creating this will save a <strong>draft</strong> only. Nothing is
emailed &mdash; you will preview it and mail it from the row&rsquo;s
Actions menu.
</>
)}
</p>
</>
)}
</div>
{/* Footer */}
<div className="border-t border-border bg-surface-warm px-6 py-4">
{!canAdvance && !submitting && state.step < STEPS.length - 1 && (
<p className="text-xs text-muted mb-3">
{state.step === 0
? "Pick a company to continue."
: (stepProblems[0] ?? "Complete this step to continue.")}
</p>
)}
<div className="flex items-center justify-between gap-3">
<button
onClick={goBack}
disabled={state.step === 0 || submitting}
className="px-4 py-2 rounded border border-border text-sm font-semibold text-fg disabled:opacity-40 hover:border-accent transition-colors"
>
Back
</button>
{state.step < STEPS.length - 1 ? (
<button onClick={goNext} disabled={!canAdvance} className={primaryBtn}>
Next →
</button>
) : (
<button
onClick={submit}
disabled={!canAdvance || submitting}
className={primaryBtn}
>
{submitting
? isEdit
? "Saving…"
: "Creating…"
: isEdit
? "Save changes"
: "Create draft resolution"}
</button>
)}
</div>
</div>
</div>
</div>
);
}