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.
1506 lines
No EOL
61 KiB
TypeScript
1506 lines
No EOL
61 KiB
TypeScript
// 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 “{q.trim()}”.{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"
|
||
>
|
||
×
|
||
</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’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 “{companySearch.trim()}”.
|
||
</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’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 → the letter will use{" "}
|
||
<strong className="text-fg-2">{retainedGuess(p)}</strong> (
|
||
{p.shares_held || "?"} held − {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 — you will preview it and mail it from the row’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>
|
||
);
|
||
} |