Workflow-App/Workflow/components/RegistrationWizard.tsx

1099 lines
51 KiB
TypeScript
Raw Permalink Normal View History

// 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";
import ActionButton from "@/components/ActionButton";
const SA_PROVINCES = [
"Eastern Cape", "Free State", "Gauteng", "KwaZulu-Natal",
"Limpopo", "Mpumalanga", "Northern Cape", "North West", "Western Cape",
];
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" },
{ id: "step2", label: "Step 2" },
] 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_line_1: "", residential_address_line_2: "",
residential_town_city: "", residential_province: "", residential_postal_code: "",
postal_address_line_1: "", postal_address_line_2: "",
postal_town_city: "", postal_province: "", postal_postal_code: "",
cell_phone: "", email: "",
residential_address: "", postal_address: "",
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: "",
company_type: "",
physical_address_line_1: "", physical_address_line_2: "",
physical_town_city: "", physical_province: "", physical_postal_code: "",
postal_address_line_1: "", postal_address_line_2: "",
postal_town_city: "", postal_province: "", postal_postal_code: "",
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>
{/* Company type */}
<div className="bg-surface-warm border border-border rounded-lg p-5">
<label className="text-xs font-semibold text-muted mb-2 block">Type of company</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 cursor-pointer"
value={company.company_type}
onChange={(e) => set({ company_type: e.target.value as CompanyInput["company_type"] })}
>
<option value="">— Select type —</option>
<option value="pty_ltd">Private Company — (PTY) LTD</option>
<option value="npc">Non Profit Company — NPC</option>
</select>
</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>
{/* Physical / trading address */}
<div className="bg-surface border border-border rounded p-4 space-y-3">
<div className="flex items-center justify-between">
<p className="text-xs font-bold text-accent uppercase tracking-wider">Physical / trading address</p>
{filledDirectors.length > 0 && (
<select
className="text-xs py-1 bg-surface-warm border border-border rounded px-2 text-accent focus:outline-none focus:border-accent cursor-pointer"
value=""
onChange={(e) => {
const idx = Number(e.target.value);
if (!isNaN(idx)) {
const d = directors[idx];
set({
physical_address_line_1: d.residential_address_line_1,
physical_address_line_2: d.residential_address_line_2,
physical_town_city: d.residential_town_city,
physical_province: d.residential_province,
physical_postal_code: d.residential_postal_code,
});
}
}}
>
<option value="">Same as director ↓</option>
{directors.map((d, i) => (
<option key={d.ui_key} value={i}>
{d.first_names} {d.surname} — residential
</option>
))}
</select>
)}
</div>
<div>
<label className="text-xs font-semibold text-muted mb-1 block">Address line 1</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="Street number and name" value={company.physical_address_line_1} onChange={(e) => set({ physical_address_line_1: e.target.value })} />
</div>
<div>
<label className="text-xs font-semibold text-muted mb-1 block">Address line 2 <span className="text-muted font-normal">(optional)</span></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="Suburb, building, unit" value={company.physical_address_line_2} onChange={(e) => set({ physical_address_line_2: e.target.value })} />
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div>
<label className="text-xs font-semibold text-muted mb-1 block">Town / City</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="e.g. Centurion" value={company.physical_town_city} onChange={(e) => set({ physical_town_city: e.target.value })} />
</div>
<div>
<label className="text-xs font-semibold text-muted mb-1 block">Province</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.physical_province}
onChange={(e) => set({ physical_province: e.target.value })}
>
<option value="">— Select province —</option>
{SA_PROVINCES.map((p) => <option key={p} value={p}>{p}</option>)}
</select>
</div>
<div>
<label className="text-xs font-semibold text-muted mb-1 block">Postal code</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="e.g. 0157" value={company.physical_postal_code} onChange={(e) => set({ physical_postal_code: e.target.value })} />
</div>
</div>
</div>
{/* Postal address */}
<div className="bg-surface border border-border rounded p-4 space-y-3">
<div className="flex items-center justify-between">
<p className="text-xs font-bold text-accent uppercase tracking-wider">Postal address</p>
<div className="flex gap-2">
<button type="button" className="text-xs text-accent hover:underline"
onClick={() => set({
postal_address_line_1: company.physical_address_line_1,
postal_address_line_2: company.physical_address_line_2,
postal_town_city: company.physical_town_city,
postal_province: company.physical_province,
postal_postal_code: company.physical_postal_code,
})}>
Copy physical
</button>
{filledDirectors.length > 0 && (
<select
className="text-xs py-1 bg-surface-warm border border-border rounded px-2 text-accent focus:outline-none focus:border-accent cursor-pointer"
value=""
onChange={(e) => {
const idx = Number(e.target.value);
if (!isNaN(idx)) {
const d = directors[idx];
set({
postal_address_line_1: d.postal_address_line_1 || d.residential_address_line_1,
postal_address_line_2: d.postal_address_line_2 || d.residential_address_line_2,
postal_town_city: d.postal_town_city || d.residential_town_city,
postal_province: d.postal_province || d.residential_province,
postal_postal_code: d.postal_postal_code || d.residential_postal_code,
});
}
}}
>
<option value="">Same as director ↓</option>
{directors.map((d, i) => (
<option key={d.ui_key} value={i}>
{d.first_names} {d.surname} — postal
</option>
))}
</select>
)}
</div>
</div>
<div>
<label className="text-xs font-semibold text-muted mb-1 block">Address line 1</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="PO Box or street" value={company.postal_address_line_1} onChange={(e) => set({ postal_address_line_1: e.target.value })} />
</div>
<div>
<label className="text-xs font-semibold text-muted mb-1 block">Address line 2 <span className="text-muted font-normal">(optional)</span></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="PO Box number, suburb" value={company.postal_address_line_2} onChange={(e) => set({ postal_address_line_2: e.target.value })} />
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div>
<label className="text-xs font-semibold text-muted mb-1 block">Town / City</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="e.g. Pretoria" value={company.postal_town_city} onChange={(e) => set({ postal_town_city: e.target.value })} />
</div>
<div>
<label className="text-xs font-semibold text-muted mb-1 block">Province</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.postal_province}
onChange={(e) => set({ postal_province: e.target.value })}
>
<option value="">— Select province —</option>
{SA_PROVINCES.map((p) => <option key={p} value={p}>{p}</option>)}
</select>
</div>
<div>
<label className="text-xs font-semibold text-muted mb-1 block">Postal code</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="e.g. 0001" value={company.postal_postal_code} onChange={(e) => set({ postal_postal_code: e.target.value })} />
</div>
</div>
</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 &amp; 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 || "—"} &middot; {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">Type</p>
<p className="text-fg font-medium">
{state.company.company_type === "pty_ltd" ? "Private Company — (PTY) LTD"
: state.company.company_type === "npc" ? "Non Profit Company — NPC"
: "—"}
</p>
<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><p className="text-fg font-medium">{state.company.proposed_name_4}</p></> : null}
<p className="text-muted">Physical address</p>
<p className="text-fg font-medium">
{state.company.physical_address_line_1
? `${state.company.physical_address_line_1}${state.company.physical_address_line_2 ? `, ${state.company.physical_address_line_2}` : ""}, ${state.company.physical_town_city} ${state.company.physical_postal_code}`
: "—"}
</p>
<p className="text-muted">Postal address</p>
<p className="text-fg font-medium">
{state.company.postal_address_line_1
? `${state.company.postal_address_line_1}${state.company.postal_address_line_2 ? `, ${state.company.postal_address_line_2}` : ""}, ${state.company.postal_town_city} ${state.company.postal_postal_code}`
: "—"}
</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; // legacy PB field
residential_address_line_1: string;
residential_address_line_2: string;
residential_town_city: string;
residential_province: string;
residential_postal_code: string;
postal_address: string; // legacy PB field
postal_address_line_1: string;
postal_address_line_2: string;
postal_town_city: string;
postal_province: string;
postal_postal_code: 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;
company_type: string;
trading_address: string; // legacy PB field
postal_address: string; // legacy PB field
physical_address_line_1: string;
physical_address_line_2: string;
physical_town_city: string;
physical_province: string;
physical_postal_code: string;
postal_address_line_1: string;
postal_address_line_2: string;
postal_town_city: string;
postal_province: string;
postal_postal_code: 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_line_1: d.residential_address_line_1 || d.residential_address || "",
residential_address_line_2: d.residential_address_line_2 || "",
residential_town_city: d.residential_town_city || "",
residential_province: d.residential_province || "",
residential_postal_code: d.residential_postal_code || "",
postal_address_line_1: d.postal_address_line_1 || d.postal_address || "",
postal_address_line_2: d.postal_address_line_2 || "",
postal_town_city: d.postal_town_city || "",
postal_province: d.postal_province || "",
postal_postal_code: d.postal_postal_code || "",
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 || "",
company_type: (prefill.company as Record<string,unknown>)?.company_type as string || "",
physical_address_line_1: prefill.company?.physical_address_line_1 || prefill.company?.trading_address || "",
physical_address_line_2: prefill.company?.physical_address_line_2 || "",
physical_town_city: prefill.company?.physical_town_city || "",
physical_province: prefill.company?.physical_province || "",
physical_postal_code: prefill.company?.physical_postal_code || "",
postal_address_line_1: prefill.company?.postal_address_line_1 || prefill.company?.postal_address || "",
postal_address_line_2: prefill.company?.postal_address_line_2 || "",
postal_town_city: prefill.company?.postal_town_city || "",
postal_province: prefill.company?.postal_province || "",
postal_postal_code: prefill.company?.postal_postal_code || "",
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()],
} as WizardState;
}
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 &amp; submit</h2>
</div>
<ReviewStep state={state} />
</div>
)}
{step === "step2" && (
<div>
<div className="mb-6">
<p className="text-xs uppercase tracking-wider text-accent font-bold">Step 5 — Actions</p>
<h2 className="text-2xl font-bold text-fg mt-1">BizPortal Actions</h2>
<p className="text-sm text-muted mt-1">Update the status as you work through BizPortal.</p>
</div>
{/* Action Buttons */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<ActionButton
id={registrationId}
label="Name Reservation only"
description="Mark as: Name reservation only in progress"
status="Name reservation only in Progress"
variant="blue"
/>
<ActionButton
id={registrationId}
label="Apply for name reservation on BizPortal"
description="Mark as: Name reservation in progress"
status="Name reservation in Progress"
variant="blue"
/>
<ActionButton
id={registrationId}
label="Name Already Approved"
description="Mark as: Awaiting Client Approval"
status="Awaiting Client Approval"
variant="green"
/>
<ActionButton
id={registrationId}
label="K-number"
description="Mark as: K-number registration"
status="K-number registration"
variant="purple"
/>
</div>
{/* Navigation */}
<div className="mt-8 flex justify-between">
<button
type="button"
onClick={() => setStep("review")}
className="px-4 py-2 border border-border rounded text-sm font-semibold text-muted hover:border-accent hover:text-accent transition-colors"
>
← Back to Review
</button>
<button
type="button"
onClick={handleSubmit}
className="px-6 py-2 rounded bg-accent text-accent-on text-sm font-semibold hover:opacity-90 transition-opacity"
>
Save & Continue
</button>
</div>
</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"
>
&larr; Back
</button>
{step !== "review" && step !== "step2" ? (
<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 &rarr;
</button>
) : (
<button
type="button"
onClick={step === "step2" ? () => alert("Step 2 — submit handler not yet built") : handleSubmit}
disabled={submitting}
className="px-6 py-2 bg-accent text-accent-on rounded font-semibold text-sm disabled:opacity-40 hover:opacity-90 transition-opacity"
>
{step === "step2" ? "Submit" : "Submit registration"}
</button>
)}
</div>
</div>
);
}