897 lines
38 KiB
TypeScript
897 lines
38 KiB
TypeScript
// components/RegistrationWizard.tsx
|
|
// Client Component. New-company registration wizard.
|
|
|
|
"use client";
|
|
|
|
import { useState } from "react";
|
|
import type { DirectorInput, ShareholderInput, CompanyInput, WizardState } from "@/lib/wizard-types";
|
|
import DirectorStep from "@/components/DirectorStep";
|
|
|
|
const MONTHS = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
|
|
|
|
const STEPS = [
|
|
{ id: "directors", label: "Directors" },
|
|
{ id: "company", label: "Company" },
|
|
{ id: "shareholders",label: "Shareholders" },
|
|
{ id: "review", label: "Review" },
|
|
] as const;
|
|
type StepId = (typeof STEPS)[number]["id"];
|
|
|
|
function uid(): string {
|
|
return typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
|
|
? crypto.randomUUID()
|
|
: Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
}
|
|
|
|
function blankDirector(): DirectorInput {
|
|
return {
|
|
ui_key: uid(),
|
|
first_names: "", surname: "", id_no: "", date_of_birth: "",
|
|
residential_address: "", postal_address: "", cell_phone: "", email: "",
|
|
section69: {
|
|
q1_probation: null, q2_court_prohibited: null,
|
|
q3_unrehabilitated_insolvent: null, q4_public_regulation: null,
|
|
q5_removed_from_office: null, q6_convicted_offence: null,
|
|
},
|
|
};
|
|
}
|
|
|
|
function blankShareholder(): ShareholderInput {
|
|
return { ui_key: uid(), is_director: false, director_index: null, name: "", id_or_entity_no: "", quantity_of_shares: 0, share_percent: 0 };
|
|
}
|
|
|
|
function emptyCompany(): CompanyInput {
|
|
return {
|
|
proposed_name_1: "", proposed_name_2: "", proposed_name_3: "", proposed_name_4: "",
|
|
trading_address: "", postal_address: "",
|
|
financial_year_end: 2, // February default
|
|
authorised_shares: "1000",
|
|
value_per_share: 1, // R1.00 default
|
|
company_cell: "", company_email: "",
|
|
public_officer: "", company_secretary: "",
|
|
};
|
|
}
|
|
|
|
// ── Company Step ──────────────────────────────────────────────────────────────
|
|
|
|
function CompanyStep({
|
|
company,
|
|
directors,
|
|
onUpdate,
|
|
}: {
|
|
company: CompanyInput;
|
|
directors: DirectorInput[];
|
|
onUpdate: (p: Partial<CompanyInput>) => void;
|
|
}) {
|
|
const set = (p: Partial<CompanyInput>) => onUpdate(p);
|
|
|
|
const filledDirectors = directors.filter((d) => d.first_names.trim() && d.surname.trim());
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Proposed names */}
|
|
<div className="bg-surface-warm border border-border rounded-lg p-5 space-y-3">
|
|
<p className="text-sm font-semibold text-fg">Proposed company names</p>
|
|
{([
|
|
{ key: "proposed_name_1" as const, placeholder: "First choice" },
|
|
{ key: "proposed_name_2" as const, placeholder: "Second choice" },
|
|
{ key: "proposed_name_3" as const, placeholder: "Third choice" },
|
|
{ key: "proposed_name_4" as const, placeholder: "Fourth choice (optional)" },
|
|
]).map(({ key, placeholder }) => (
|
|
<div key={key}>
|
|
<label className="text-xs font-semibold text-muted mb-1 block">{placeholder}</label>
|
|
<input
|
|
className="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"
|
|
placeholder="Company name"
|
|
value={company[key]}
|
|
onChange={(e) => set({ [key]: e.target.value })}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Addresses */}
|
|
<div className="bg-surface-warm border border-border rounded-lg p-5 space-y-3">
|
|
<p className="text-sm font-semibold text-fg">Addresses</p>
|
|
|
|
{/* Trading address */}
|
|
<div>
|
|
<div className="flex items-center justify-between mb-1">
|
|
<label className="text-xs font-semibold text-muted">Trading / physical address</label>
|
|
{filledDirectors.length > 0 && (
|
|
<select
|
|
className="text-xs py-1 bg-surface border border-border rounded px-2 text-accent focus:outline-none focus:border-accent"
|
|
value=""
|
|
onChange={(e) => {
|
|
const idx = Number(e.target.value);
|
|
if (!isNaN(idx)) set({ trading_address: directors[idx]?.residential_address || "" });
|
|
}}
|
|
>
|
|
<option value="">Use director address ↓</option>
|
|
{directors.map((d, i) => (
|
|
<option key={d.ui_key} value={i}>
|
|
{d.first_names} {d.surname} — residential
|
|
</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
</div>
|
|
<textarea
|
|
rows={2}
|
|
autoComplete="off"
|
|
autoCorrect="off"
|
|
autoCapitalize="off"
|
|
spellCheck={false}
|
|
data-form-type="other"
|
|
data-lpignore="true"
|
|
data-1p-ignore="true"
|
|
name={`trading_address_${Math.random().toString(36).slice(2)}`}
|
|
className="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 resize-none"
|
|
placeholder="Where the company will operate"
|
|
value={company.trading_address}
|
|
onChange={(e) => set({ trading_address: e.target.value })}
|
|
/>
|
|
</div>
|
|
|
|
{/* Postal address */}
|
|
<div>
|
|
<div className="flex items-center justify-between mb-1">
|
|
<label className="text-xs font-semibold text-muted">Postal address</label>
|
|
{filledDirectors.length > 0 && (
|
|
<select
|
|
className="text-xs py-1 bg-surface border border-border rounded px-2 text-accent focus:outline-none focus:border-accent"
|
|
value=""
|
|
onChange={(e) => {
|
|
const idx = Number(e.target.value);
|
|
if (!isNaN(idx)) set({ postal_address: directors[idx]?.postal_address || directors[idx]?.residential_address || "" });
|
|
}}
|
|
>
|
|
<option value="">Use director address ↓</option>
|
|
{directors.map((d, i) => (
|
|
<option key={d.ui_key} value={i}>
|
|
{d.first_names} {d.surname} — postal
|
|
</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
</div>
|
|
<textarea
|
|
rows={2}
|
|
autoComplete="off"
|
|
autoCorrect="off"
|
|
autoCapitalize="off"
|
|
spellCheck={false}
|
|
data-form-type="other"
|
|
data-lpignore="true"
|
|
data-1p-ignore="true"
|
|
name={`postal_address_${Math.random().toString(36).slice(2)}`}
|
|
className="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 resize-none"
|
|
placeholder="Postal address for correspondence"
|
|
value={company.postal_address}
|
|
onChange={(e) => set({ postal_address: e.target.value })}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Financial year end + share capital */}
|
|
<div className="bg-surface-warm border border-border rounded-lg p-5">
|
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
|
<div>
|
|
<label className="text-xs font-semibold text-muted mb-1 block">Financial year end</label>
|
|
<select
|
|
className="w-full bg-surface border border-border rounded px-3 py-2 text-sm text-fg focus:outline-none focus:border-accent transition-colors"
|
|
value={company.financial_year_end}
|
|
onChange={(e) => set({ financial_year_end: Number(e.target.value) })}
|
|
>
|
|
{MONTHS.map((m, i) => <option key={m} value={i + 1}>{m}</option>)}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs font-semibold text-muted mb-1 block">Authorised share capital</label>
|
|
<select
|
|
className="w-full bg-surface border border-border rounded px-3 py-2 text-sm text-fg focus:outline-none focus:border-accent transition-colors"
|
|
value={company.authorised_shares}
|
|
onChange={(e) => set({ authorised_shares: e.target.value as "100" | "1000" })}
|
|
>
|
|
<option value="100">100 Shares</option>
|
|
<option value="1000">1000 Shares</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs font-semibold text-muted mb-1 block">
|
|
Value per share (R)
|
|
</label>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm text-muted">R</span>
|
|
<input
|
|
type="number"
|
|
min={0}
|
|
step={0.01}
|
|
className="w-full bg-surface border border-border rounded px-3 py-2 text-sm text-fg focus:outline-none focus:border-accent transition-colors"
|
|
placeholder="1.00"
|
|
value={company.value_per_share === 0 ? "" : company.value_per_share}
|
|
onChange={(e) => {
|
|
const v = parseFloat(e.target.value);
|
|
set({ value_per_share: isNaN(v) ? 0 : v });
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Contact + officers */}
|
|
<div className="bg-surface-warm border border-border rounded-lg p-5 space-y-4">
|
|
<p className="text-sm font-semibold text-fg">Company contact & officers</p>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
{/* Company cell */}
|
|
<div>
|
|
<div className="flex items-center justify-between mb-1">
|
|
<label className="text-xs font-semibold text-muted">Company cell</label>
|
|
{filledDirectors.length > 0 && (
|
|
<select
|
|
className="text-xs py-1 bg-surface border border-border rounded px-2 text-accent focus:outline-none focus:border-accent"
|
|
value=""
|
|
onChange={(e) => {
|
|
const idx = Number(e.target.value);
|
|
if (!isNaN(idx)) set({ company_cell: directors[idx]?.cell_phone || "" });
|
|
}}
|
|
>
|
|
<option value="">Use director cell ↓</option>
|
|
{directors.map((d, i) => (
|
|
<option key={d.ui_key} value={i}>{d.first_names} {d.surname}</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
</div>
|
|
<input
|
|
type="tel"
|
|
className="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"
|
|
placeholder="e.g. 0871234567"
|
|
value={company.company_cell}
|
|
onChange={(e) => set({ company_cell: e.target.value })}
|
|
/>
|
|
</div>
|
|
|
|
{/* Company email */}
|
|
<div>
|
|
<div className="flex items-center justify-between mb-1">
|
|
<label className="text-xs font-semibold text-muted">Company email</label>
|
|
{filledDirectors.length > 0 && (
|
|
<select
|
|
className="text-xs py-1 bg-surface border border-border rounded px-2 text-accent focus:outline-none focus:border-accent"
|
|
value=""
|
|
onChange={(e) => {
|
|
const idx = Number(e.target.value);
|
|
if (!isNaN(idx)) set({ company_email: directors[idx]?.email || "" });
|
|
}}
|
|
>
|
|
<option value="">Use director email ↓</option>
|
|
{directors.map((d, i) => (
|
|
<option key={d.ui_key} value={i}>{d.first_names} {d.surname}</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
</div>
|
|
<input
|
|
type="email"
|
|
className="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"
|
|
placeholder="company@domain.co.za"
|
|
value={company.company_email}
|
|
onChange={(e) => set({ company_email: e.target.value })}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Public officer + secretary (not compulsory) */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="text-xs font-semibold text-muted mb-1 block">
|
|
Public officer{" "}
|
|
<span className="text-muted font-normal">(optional)</span>
|
|
</label>
|
|
<input
|
|
type="text"
|
|
className="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"
|
|
placeholder="Full name"
|
|
value={company.public_officer}
|
|
onChange={(e) => set({ public_officer: e.target.value })}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs font-semibold text-muted mb-1 block">
|
|
Company secretary{" "}
|
|
<span className="text-muted font-normal">(optional)</span>
|
|
</label>
|
|
<input
|
|
type="text"
|
|
className="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"
|
|
placeholder="Full name"
|
|
value={company.company_secretary}
|
|
onChange={(e) => set({ company_secretary: e.target.value })}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Shareholder Step ─────────────────────────────────────────────────────────
|
|
|
|
function ShareholderStep({ state, onUpdate }: { state: WizardState; onUpdate: (s: WizardState) => void }) {
|
|
const updateSH = (i: number, patch: Partial<ShareholderInput>) => {
|
|
const shareholders = [...state.shareholders];
|
|
shareholders[i] = { ...shareholders[i], ...patch };
|
|
onUpdate({ ...state, shareholders });
|
|
};
|
|
|
|
const totalPct = state.shareholders.reduce((s, sh) => s + (sh.share_percent || 0), 0);
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="bg-surface-warm border border-border rounded-lg p-5">
|
|
<p className="text-sm font-semibold text-fg mb-3">How many shareholders?</p>
|
|
<div className="flex gap-2">
|
|
{[1, 2, 3, 4, 5].map((n) => (
|
|
<button
|
|
key={n}
|
|
type="button"
|
|
onClick={() => {
|
|
let shareholders = [...state.shareholders];
|
|
if (n > shareholders.length) {
|
|
while (shareholders.length < n) shareholders.push(blankShareholder());
|
|
} else {
|
|
shareholders = shareholders.slice(0, n);
|
|
}
|
|
onUpdate({ ...state, shareholders });
|
|
}}
|
|
className={
|
|
"w-10 h-10 rounded border font-semibold text-sm transition-colors " +
|
|
(state.shareholders.length === n ? "bg-accent text-accent-on border-transparent" : "bg-surface border-border text-muted hover:text-fg")
|
|
}
|
|
>
|
|
{n}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<p className="text-sm mt-2">
|
|
Total: <strong className={totalPct === 100 ? "text-success" : "text-muted"}>{totalPct}%</strong>
|
|
{totalPct !== 100 && <span className="ml-1 text-muted text-xs">(100% recommended but optional)</span>}
|
|
</p>
|
|
</div>
|
|
|
|
{state.shareholders.map((sh, i) => (
|
|
<div key={sh.ui_key} className="border border-border rounded-lg overflow-hidden">
|
|
<div className="bg-surface-warm border-b border-border px-5 py-3">
|
|
<div className="flex items-center justify-between flex-wrap gap-2">
|
|
<p className="text-sm font-bold text-fg">Shareholder {i + 1}</p>
|
|
{state.directors.length > 0 && (
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs text-muted">Same as director:</span>
|
|
<select
|
|
className="text-xs py-1 bg-surface border border-border rounded px-2 text-fg focus:outline-none focus:border-accent"
|
|
value={sh.is_director ? String(sh.director_index ?? "") : ""}
|
|
onChange={(e) => {
|
|
const idx = Number(e.target.value);
|
|
if (!isNaN(idx) && state.directors[idx]) {
|
|
updateSH(i, {
|
|
is_director: true,
|
|
director_index: idx,
|
|
name: (state.directors[idx].first_names + " " + state.directors[idx].surname).trim(),
|
|
id_or_entity_no: state.directors[idx].id_no,
|
|
});
|
|
} else {
|
|
updateSH(i, { is_director: false, director_index: null, name: "", id_or_entity_no: "" });
|
|
}
|
|
}}
|
|
>
|
|
<option value="">— Not a director —</option>
|
|
{state.directors.map((d, j) => (
|
|
<option key={d.ui_key} value={j}>
|
|
{j + 1}. {d.first_names} {d.surname}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="p-5 space-y-4">
|
|
{!sh.is_director && (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="text-xs font-semibold text-muted mb-1 block">Full name / Entity name</label>
|
|
<input
|
|
className="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"
|
|
placeholder="Shareholder name or entity"
|
|
value={sh.name}
|
|
onChange={(e) => updateSH(i, { name: e.target.value })}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs font-semibold text-muted mb-1 block">ID or Company number</label>
|
|
<input
|
|
className="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"
|
|
placeholder="ID or CK/CC/PTY number"
|
|
value={sh.id_or_entity_no}
|
|
onChange={(e) => updateSH(i, { id_or_entity_no: e.target.value })}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div>
|
|
<label className="text-xs font-semibold text-muted mb-1 block">Quantity of shares</label>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="number"
|
|
min={0}
|
|
step={1}
|
|
className="w-24 bg-surface border border-border rounded px-3 py-2 text-sm text-fg focus:outline-none focus:border-accent transition-colors"
|
|
placeholder="0"
|
|
value={sh.quantity_of_shares || ""}
|
|
onChange={(e) => {
|
|
const qty = Math.max(0, Math.floor(Number(e.target.value) || 0));
|
|
updateSH(i, { quantity_of_shares: qty });
|
|
}}
|
|
/>
|
|
<span className="text-sm text-muted">shares</span>
|
|
{sh.share_percent > 0 && (
|
|
<span className="text-xs text-muted ml-2">({sh.share_percent.toFixed(2)}%)</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Review Step ───────────────────────────────────────────────────────────────
|
|
|
|
function ReviewStep({ state }: { state: WizardState }) {
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="bg-surface-warm border border-border rounded-lg p-5">
|
|
<p className="text-xs uppercase tracking-wider text-accent font-bold mb-3">Directors</p>
|
|
{state.directors.map((d, i) => (
|
|
<div key={d.ui_key} className="py-3 border-b border-border last:border-0">
|
|
<p className="text-sm font-semibold text-fg">{d.first_names} {d.surname}</p>
|
|
<p className="text-xs text-muted">ID: {d.id_no || "—"} · {d.cell_phone}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="bg-surface-warm border border-border rounded-lg p-5">
|
|
<p className="text-xs uppercase tracking-wider text-accent font-bold mb-3">Company</p>
|
|
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm">
|
|
<p className="text-muted">Name 1</p><p className="text-fg font-medium">{state.company.proposed_name_1 || "—"}</p>
|
|
<p className="text-muted">Name 2</p><p className="text-fg font-medium">{state.company.proposed_name_2 || "—"}</p>
|
|
<p className="text-muted">Name 3</p><p className="text-fg font-medium">{state.company.proposed_name_3 || "—"}</p>
|
|
{state.company.proposed_name_4 ? <p className="text-muted">Name 4</p> : null}
|
|
{state.company.proposed_name_4 ? <p className="text-fg font-medium">{state.company.proposed_name_4}</p> : null}
|
|
<p className="text-muted">Trading</p><p className="text-fg font-medium">{state.company.trading_address || "—"}</p>
|
|
<p className="text-muted">Postal</p><p className="text-fg font-medium">{state.company.postal_address || "—"}</p>
|
|
<p className="text-muted">Year end</p><p className="text-fg font-medium">{MONTHS[state.company.financial_year_end - 1]}</p>
|
|
<p className="text-muted">Authorised shares</p><p className="text-fg font-medium">{state.company.authorised_shares}</p>
|
|
<p className="text-muted">Value per share</p><p className="text-fg font-medium">R{Number(state.company.value_per_share || 0).toFixed(2)}</p>
|
|
<p className="text-muted">Public officer</p><p className="text-fg font-medium">{state.company.public_officer || "—"}</p>
|
|
<p className="text-muted">Secretary</p><p className="text-fg font-medium">{state.company.company_secretary || "—"}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-surface-warm border border-border rounded-lg p-5">
|
|
<p className="text-xs uppercase tracking-wider text-accent font-bold mb-3">Shareholders</p>
|
|
{state.shareholders.map((sh, i) => (
|
|
<div key={sh.ui_key} className="py-2 border-b border-border last:border-0 flex justify-between items-start">
|
|
<div>
|
|
<p className="text-sm font-semibold text-fg">{sh.name || "—"}</p>
|
|
<p className="text-xs text-muted">{sh.id_or_entity_no || "—"}</p>
|
|
</div>
|
|
<div className="text-right">
|
|
<p className="text-sm font-bold text-accent">{sh.quantity_of_shares.toLocaleString()} shares</p>
|
|
{sh.share_percent > 0 && (
|
|
<p className="text-xs text-muted">{sh.share_percent.toFixed(2)}%</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
<p className="text-xs text-muted mt-2">
|
|
Allocated: {state.shareholders.reduce((s, sh) => s + sh.quantity_of_shares, 0).toLocaleString()}
|
|
{" / "}
|
|
{state.company.authorised_shares} authorised
|
|
{state.shareholders.reduce((s, sh) => s + sh.share_percent, 0) > 0 && (
|
|
<> · {state.shareholders.reduce((s, sh) => s + sh.share_percent, 0).toFixed(2)}% total</>
|
|
)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Validation ───────────────────────────────────────────────────────────────
|
|
|
|
function canProceed(step: StepId, _state: WizardState, _isUpdate: boolean): boolean {
|
|
// All fields are optional — a blank application can be submitted.
|
|
// Continue is always enabled; Submit on Review does the actual write.
|
|
return true;
|
|
}
|
|
|
|
// ── Main component ────────────────────────────────────────────────────────────
|
|
|
|
interface PrefillData {
|
|
directors: {
|
|
first_names: string;
|
|
surname: string;
|
|
id_no: string;
|
|
date_of_birth: string;
|
|
residential_address: string;
|
|
postal_address: string;
|
|
cell_phone: string;
|
|
email: string;
|
|
section69_answers?: string;
|
|
}[];
|
|
shareholders: {
|
|
name: string;
|
|
id_or_entity_no: string;
|
|
share_percent: string;
|
|
quantity_of_shares?: string;
|
|
is_director: string;
|
|
}[];
|
|
company: {
|
|
proposed_name_1: string;
|
|
proposed_name_2: string;
|
|
proposed_name_3: string;
|
|
proposed_name_4: string;
|
|
trading_address: string;
|
|
postal_address: string;
|
|
financial_year_end: string;
|
|
authorised_shares: string;
|
|
value_per_share?: string;
|
|
company_cell: string;
|
|
company_email: string;
|
|
public_officer: string;
|
|
company_secretary: string;
|
|
};
|
|
}
|
|
|
|
export default function RegistrationWizard({
|
|
registrationId,
|
|
prefill,
|
|
token,
|
|
referenceName,
|
|
status,
|
|
}: {
|
|
registrationId: string;
|
|
prefill?: PrefillData;
|
|
token?: string;
|
|
referenceName?: string;
|
|
status?: string;
|
|
}) {
|
|
const [step, setStep] = useState<StepId>("directors");
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [submitted, setSubmitted] = useState(false);
|
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
|
const isUpdate = !!prefill;
|
|
|
|
// Initialize state — either blank or from prefill data
|
|
const [state, setState] = useState<WizardState>(() => {
|
|
if (prefill && prefill.directors.length > 0) {
|
|
return {
|
|
director_count: prefill.directors.length,
|
|
directors: prefill.directors.map((d) => {
|
|
let section69 = {
|
|
q1_probation: null as boolean | null,
|
|
q2_court_prohibited: null as boolean | null,
|
|
q3_unrehabilitated_insolvent: null as boolean | null,
|
|
q4_public_regulation: null as boolean | null,
|
|
q5_removed_from_office: null as boolean | null,
|
|
q6_convicted_offence: null as boolean | null,
|
|
};
|
|
try {
|
|
const raw = typeof d.section69_answers === "string" ? JSON.parse(d.section69_answers) : d.section69_answers;
|
|
if (raw) {
|
|
section69 = {
|
|
q1_probation: raw.q1_probation ?? null,
|
|
q2_court_prohibited: raw.q2_court_prohibited ?? null,
|
|
q3_unrehabilitated_insolvent: raw.q3_unrehabilitated_insolvent ?? null,
|
|
q4_public_regulation: raw.q4_public_regulation ?? null,
|
|
q5_removed_from_office: raw.q5_removed_from_office ?? null,
|
|
q6_convicted_offence: raw.q6_convicted_offence ?? null,
|
|
};
|
|
}
|
|
} catch { /* use defaults */ }
|
|
return {
|
|
ui_key: uid(),
|
|
first_names: d.first_names || "",
|
|
surname: d.surname || "",
|
|
id_no: d.id_no || "",
|
|
date_of_birth: d.date_of_birth || "",
|
|
residential_address: d.residential_address || "",
|
|
postal_address: d.postal_address || "",
|
|
cell_phone: d.cell_phone || "",
|
|
email: d.email || "",
|
|
section69,
|
|
};
|
|
}),
|
|
company: {
|
|
proposed_name_1: prefill.company?.proposed_name_1 || "",
|
|
proposed_name_2: prefill.company?.proposed_name_2 || "",
|
|
proposed_name_3: prefill.company?.proposed_name_3 || "",
|
|
proposed_name_4: prefill.company?.proposed_name_4 || "",
|
|
trading_address: prefill.company?.trading_address || "",
|
|
postal_address: prefill.company?.postal_address || "",
|
|
financial_year_end: (() => {
|
|
const v = parseInt(prefill.company?.financial_year_end || "2", 10);
|
|
return isNaN(v) ? 2 : (v as CompanyInput["financial_year_end"]);
|
|
})(),
|
|
authorised_shares: ((prefill.company?.authorised_shares || "1000") === "100" ? "100" : "1000") as CompanyInput["authorised_shares"],
|
|
value_per_share: parseFloat(prefill.company?.value_per_share || "1") || 1,
|
|
company_cell: prefill.company?.company_cell || "",
|
|
company_email: prefill.company?.company_email || "",
|
|
public_officer: prefill.company?.public_officer || "",
|
|
company_secretary: prefill.company?.company_secretary || "",
|
|
},
|
|
shareholders: prefill.shareholders.length > 0
|
|
? prefill.shareholders.map((s) => ({
|
|
ui_key: uid(),
|
|
is_director: s.is_director === "true",
|
|
director_index: null,
|
|
name: s.name || "",
|
|
id_or_entity_no: s.id_or_entity_no || "",
|
|
quantity_of_shares: parseInt(s.quantity_of_shares || "0", 10) || 0,
|
|
share_percent: parseFloat(s.share_percent) || 0,
|
|
}))
|
|
: [blankShareholder()],
|
|
};
|
|
}
|
|
return {
|
|
director_count: 1,
|
|
directors: [blankDirector()],
|
|
company: emptyCompany(),
|
|
shareholders: [blankShareholder()],
|
|
};
|
|
});
|
|
|
|
const stepIndex = STEPS.findIndex((s) => s.id === step);
|
|
|
|
const handleSubmit = async () => {
|
|
setSubmitting(true);
|
|
setSubmitError(null);
|
|
try {
|
|
// Strip id_file (File) from payload before sending to JSON endpoint.
|
|
const payload = {
|
|
registrationId,
|
|
...state,
|
|
directors: state.directors.map(({ id_file, ...rest }) => rest),
|
|
isUpdate,
|
|
};
|
|
const res = await fetch("/api/register/submit", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
if (!res.ok) throw new Error(await res.text());
|
|
const data = await res.json();
|
|
// After the reg is saved, upload each director's ID file (if any).
|
|
// The director's PB id isn't known to the wizard; we send the
|
|
// first_names+surname so the server can build the canonical filename.
|
|
for (const d of state.directors) {
|
|
if (!d.id_file) continue;
|
|
const fd = new FormData();
|
|
fd.append("type", "director_id");
|
|
fd.append("director_first_names", d.first_names || "");
|
|
fd.append("director_surname", d.surname || "");
|
|
fd.append("files", d.id_file);
|
|
try {
|
|
const up = await fetch(`/api/register/${registrationId}/upload`, {
|
|
method: "POST",
|
|
body: fd,
|
|
});
|
|
if (!up.ok) {
|
|
const txt = await up.text().catch(() => "");
|
|
console.error(`[register/upload] director_id failed: ${up.status} ${txt}`);
|
|
}
|
|
} catch (err) {
|
|
console.error("[register/upload] network error", err);
|
|
}
|
|
}
|
|
if (isUpdate) {
|
|
alert("Registration saved. Your changes have been recorded.");
|
|
setSubmitted(true);
|
|
} else {
|
|
setSubmitted(true);
|
|
}
|
|
void data; // unused
|
|
} catch (err: unknown) {
|
|
setSubmitError(err instanceof Error ? err.message : "Submission failed");
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
if (submitted) {
|
|
return (
|
|
<div className="container-x py-16 text-center max-w-lg mx-auto">
|
|
<div className="w-16 h-16 bg-success/10 rounded-full flex items-center justify-center mx-auto mb-4">
|
|
<svg className="w-8 h-8 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
</div>
|
|
<h2 className="text-fg font-semibold text-xl mb-2">
|
|
{isUpdate ? "Saved" : "Registration submitted"}
|
|
</h2>
|
|
<p className="text-muted text-sm">
|
|
{isUpdate
|
|
? "Your changes have been recorded. You can close this tab."
|
|
: "Your information has been received. Your registration agent will be in touch shortly."}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="container-x py-8 max-w-3xl mx-auto">
|
|
{/* Step indicator */}
|
|
<div className="flex items-center gap-0 mb-8 overflow-x-auto">
|
|
{STEPS.map((s, i) => {
|
|
const isActive = s.id === step;
|
|
const isPast = i < stepIndex;
|
|
return (
|
|
<div key={s.id} className="flex items-center flex-shrink-0">
|
|
<button
|
|
type="button"
|
|
onClick={() => { if (i <= stepIndex) setStep(s.id); }}
|
|
disabled={!isActive && !isPast}
|
|
className={
|
|
"flex items-center gap-2 px-3 py-2 text-sm font-semibold border-b-2 transition-colors " +
|
|
(isActive ? "border-accent text-accent" : isPast ? "border-success text-success cursor-pointer" : "border-transparent text-muted cursor-not-allowed")
|
|
}
|
|
>
|
|
<span className={
|
|
"w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold " +
|
|
(isActive ? "bg-accent text-accent-on" : isPast ? "bg-success text-success-on" : "bg-surface border border-border text-muted")
|
|
}>
|
|
{isPast ? "\u2713" : i + 1}
|
|
</span>
|
|
{s.label}
|
|
</button>
|
|
{i < STEPS.length - 1 && <div className="w-4 h-px bg-border mx-1" />}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Update-mode banner */}
|
|
{isUpdate && (
|
|
<div className="mb-6 px-4 py-3 rounded-lg bg-yellow-500/10 border border-yellow-500/30 text-sm text-yellow-700">
|
|
<strong>Continuing existing registration.</strong> Existing data has been loaded.
|
|
Fill in any missing fields below, then click <strong>Continue →</strong> to advance.
|
|
</div>
|
|
)}
|
|
|
|
{/* Step content */}
|
|
<div className="mb-8">
|
|
{step === "directors" && (
|
|
<div>
|
|
<div className="mb-4">
|
|
<p className="text-xs uppercase tracking-wider text-accent font-bold">Step 1</p>
|
|
<h2 className="text-fg font-semibold text-xl">Director details</h2>
|
|
</div>
|
|
<div className="bg-surface-warm border border-border rounded-lg p-5 mb-6">
|
|
<p className="text-sm font-semibold text-fg mb-3">How many directors?</p>
|
|
<div className="flex gap-2">
|
|
{[1, 2, 3, 4, 5].map((n) => (
|
|
<button
|
|
key={n}
|
|
type="button"
|
|
onClick={() => {
|
|
let directors = [...state.directors];
|
|
if (n > directors.length) {
|
|
while (directors.length < n) directors.push(blankDirector());
|
|
} else {
|
|
directors = directors.slice(0, n);
|
|
}
|
|
setState({ ...state, director_count: n, directors });
|
|
}}
|
|
className={
|
|
"w-10 h-10 rounded border font-semibold text-sm transition-colors " +
|
|
(state.director_count === n ? "bg-accent text-accent-on border-transparent" : "bg-surface border-border text-muted hover:text-fg")
|
|
}
|
|
>
|
|
{n}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<p className="text-xs text-muted mt-2">All fields below are optional. You can leave anything blank and still submit.</p>
|
|
</div>
|
|
{state.directors.map((director, i) => (
|
|
<DirectorStep
|
|
key={director.ui_key}
|
|
director={director}
|
|
index={i}
|
|
onUpdate={(patch) => {
|
|
const directors = [...state.directors];
|
|
directors[i] = { ...directors[i], ...patch };
|
|
setState({ ...state, directors });
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{step === "company" && (
|
|
<div>
|
|
<div className="mb-4">
|
|
<p className="text-xs uppercase tracking-wider text-accent font-bold">Step 2</p>
|
|
<h2 className="text-fg font-semibold text-xl">Company information</h2>
|
|
</div>
|
|
<CompanyStep
|
|
company={state.company}
|
|
directors={state.directors}
|
|
onUpdate={(p) => setState({ ...state, company: { ...state.company, ...p } })}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{step === "shareholders" && (
|
|
<div>
|
|
<div className="mb-4">
|
|
<p className="text-xs uppercase tracking-wider text-accent font-bold">Step 3</p>
|
|
<h2 className="text-fg font-semibold text-xl">Shareholders</h2>
|
|
</div>
|
|
<ShareholderStep state={state} onUpdate={setState} />
|
|
</div>
|
|
)}
|
|
|
|
{step === "review" && (
|
|
<div>
|
|
<div className="mb-4">
|
|
<p className="text-xs uppercase tracking-wider text-accent font-bold">Step 4</p>
|
|
<h2 className="text-fg font-semibold text-xl">Review & submit</h2>
|
|
</div>
|
|
<ReviewStep state={state} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{submitError && (
|
|
<div className="mb-4 p-4 bg-danger/10 border border-danger/30 rounded text-danger text-sm">{submitError}</div>
|
|
)}
|
|
|
|
<div className="flex items-center justify-between pt-4 border-t border-border">
|
|
<button
|
|
type="button"
|
|
onClick={() => { if (stepIndex > 0) setStep(STEPS[stepIndex - 1].id); }}
|
|
disabled={stepIndex === 0}
|
|
className="px-5 py-2 text-sm font-semibold text-muted hover:text-fg disabled:opacity-30 transition-colors"
|
|
>
|
|
← Back
|
|
</button>
|
|
|
|
{step !== "review" ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => { if (stepIndex < STEPS.length - 1) setStep(STEPS[stepIndex + 1].id); }}
|
|
disabled={!canProceed(step, state, isUpdate)}
|
|
className="px-6 py-2 bg-accent text-accent-on rounded font-semibold text-sm disabled:opacity-40 hover:opacity-90 transition-opacity"
|
|
>
|
|
Continue →
|
|
</button>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={handleSubmit}
|
|
disabled={submitting}
|
|
className="px-6 py-2 bg-success text-success-on rounded font-semibold text-sm disabled:opacity-40 hover:opacity-90 transition-opacity"
|
|
>
|
|
{submitting ? "Submitting..." : "Submit registration"}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|