// components/ResolutionWizard.tsx // Phase 3 — Combined Resolution of Directors wizard. // // Multi-step modal for creating a new resolution. Triggered from // ResolutionExplorer's "+ New resolution" button. // // Steps: // 0 — Pick a company // 1 — Choose path (Letters already received / Letter to be generated) // 2 — Pick resigned directors (each with effective date) // 3 — Pick appointed directors (each with effective date) // 4 — Meeting date + notes // 5 — Review & Create (auto-populated attendees table + submit) // // After successful create, the modal closes and the parent re-renders the // table (router.refresh()) so the new draft row appears immediately. // // On dismiss/close: emits nothing, state resets. "use client"; import { useEffect, useMemo, useState, useCallback } from "react"; import { useRouter } from "next/navigation"; // ─── Types ──────────────────────────────────────────────────────────────── type PathChoice = "letters_received" | "letter_generated"; interface Company { id: string; enterprise_name: string; enterprise_no?: string; } interface Director { id: string; first_names: string; surname: string; email: string | null; cell: string | null; } interface DirectorPick { director_id: string; effective_date: string; } interface WizardState { step: number; company: Company | null; path: PathChoice | null; resigned: DirectorPick[]; appointed: DirectorPick[]; meeting_date: string; notes: string; } interface AttendeePreview { director_id: string; name: string; surname: string; email: string | null; role: "existing" | "resigned" | "appointed"; must_sign: boolean; attended: boolean; effective_date: string; } // ─── Helpers ────────────────────────────────────────────────────────────── const STEPS = [ "Pick company", "Choose path", "Resigning directors (optional)", "Appointing directors (optional)", "Meeting date", "Review", ] as const; function todayISO(): string { return new Date().toISOString().split("T")[0]; } function fullName(d: { first_names?: string; surname?: string }): string { return [d.first_names, d.surname].filter(Boolean).join(" ").trim() || "—"; } function classNames(...parts: Array): string { return parts.filter(Boolean).join(" "); } // ─── Component ──────────────────────────────────────────────────────────── interface Props { open: boolean; onClose: () => void; } export default function ResolutionWizard({ open, onClose }: Props) { const router = useRouter(); const [state, setState] = useState({ step: 0, company: null, path: null, resigned: [], appointed: [], meeting_date: todayISO(), notes: "", }); const [submitting, setSubmitting] = useState(false); const [submitError, setSubmitError] = useState(null); // Reset on open/close useEffect(() => { if (!open) return; setState({ step: 0, company: null, path: null, resigned: [], appointed: [], meeting_date: todayISO(), notes: "", }); setSubmitError(null); setSubmitting(false); }, [open]); // Close on Escape 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]); const canAdvance = useMemo(() => { switch (state.step) { case 0: return !!state.company; case 1: return !!state.path; // Steps 2 (resigning) and 3 (appointing) are independently optional: // you can pass a meeting with no resignations (only appointments), // only resignations, or neither (just existing directors). case 2: return true; case 3: return true; case 4: return !!state.meeting_date && /\d{4}-\d{2}-\d{2}/.test(state.meeting_date); case 5: return true; default: return false; } }, [state]); 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) })); }; const submit = useCallback(async () => { if (!state.company || !state.path) { setSubmitError("Company and path are required."); return; } setSubmitting(true); setSubmitError(null); try { const res = await fetch("/api/resolutions/create", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ company_id: state.company.id, path: state.path, meeting_date: state.meeting_date, resigned: state.resigned, appointed: state.appointed, notes: state.notes || undefined, }), }); const json = await res.json().catch(() => ({})); if (!res.ok) throw new Error(json.error || `HTTP ${res.status}`); // success — close + refresh onClose(); router.refresh(); } catch (err) { setSubmitError(err instanceof Error ? err.message : "Failed to create resolution"); } finally { setSubmitting(false); } }, [state, onClose, router]); if (!open) return null; return (
{/* Header */}

New Resolution of Directors

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

{/* Progress bar */}
{STEPS.map((label, i) => (
))}
{/* Body */}
{state.step === 0 && ( setState((s) => ({ ...s, company: c }))} /> )} {state.step === 1 && ( setState((s) => ({ ...s, path: p }))} /> )} {state.step === 2 && state.company && ( setState((s) => ({ ...s, resigned: list }))} /> )} {state.step === 3 && state.company && ( r.director_id)} onChange={(list) => setState((s) => ({ ...s, appointed: list }))} /> )} {state.step === 4 && ( setState((s) => ({ ...s, meeting_date: d, notes: n })) } /> )} {state.step === 5 && state.company && ( )} {submitError && (

Failed to create resolution

{submitError}

)}
{/* Footer */}
{state.step < STEPS.length - 1 ? ( ) : ( )}
); } // ─── Step 0: Company picker ─────────────────────────────────────────────── interface CompanyStepProps { selected: Company | null; onPick: (c: Company) => void; } function CompanyStep({ selected, onPick }: CompanyStepProps) { const [search, setSearch] = useState(""); const [companies, setCompanies] = useState([]); const [loading, setLoading] = useState(true); useEffect(() => { let cancelled = false; setLoading(true); fetch("/api/companies/list") .then((r) => r.json()) .then((d) => { if (!cancelled) setCompanies(d.items || []); }) .catch(() => { if (!cancelled) setCompanies([]); }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, []); const filtered = useMemo(() => { if (!search.trim()) return companies; const q = search.toLowerCase(); return companies.filter( (c) => c.enterprise_name?.toLowerCase().includes(q) || c.enterprise_no?.toLowerCase().includes(q) ); }, [companies, search]); return (

Choose the company this resolution applies to.

setSearch(e.target.value)} className="w-full bg-surface border border-border rounded px-3 py-2.5 text-sm text-fg placeholder-muted focus:outline-none focus:border-accent" />
{loading ? (

Loading companies…

) : filtered.length === 0 ? (

{search.trim() ? "No companies match." : "No companies on file."}

) : ( filtered.map((c) => { const isSelected = selected?.id === c.id; return ( ); }) )}
{selected && (
Selected: {selected.enterprise_name} {selected.enterprise_no && ( ({selected.enterprise_no}) )}
)}
); } // ─── Step 1: Path picker ────────────────────────────────────────────────── interface PathStepProps { value: PathChoice | null; onChange: (p: PathChoice) => void; } function PathStep({ value, onChange }: PathStepProps) { return (

How will the resolution letter be sourced?

onChange("letters_received")} title="Letters already received" description="Resignation / appointment letters are already on file as PDFs. The minute of meeting will reference them." /> onChange("letter_generated")} title="Letter to be generated" description="Render the combined minute from your master .docx template. Path B — Phase 6 will plug the generator in." />
); } interface PathCardProps { active: boolean; onClick: () => void; title: string; description: string; } function PathCard({ active, onClick, title, description }: PathCardProps) { return ( ); } // ─── Steps 2/3: Director pickers (resigned / appointed) ─────────────────── interface DirectorsStepProps { companyId: string; role: "resigned" | "appointed"; picked: DirectorPick[]; defaultDate: string; excludeIds?: string[]; onChange: (list: DirectorPick[]) => void; } function DirectorsStep({ companyId, role, picked, defaultDate, excludeIds = [], onChange, }: DirectorsStepProps) { const [directors, setDirectors] = useState([]); const [loading, setLoading] = useState(true); const [search, setSearch] = useState(""); useEffect(() => { let cancelled = false; setLoading(true); fetch(`/api/resolutions/company-directors/${companyId}`) .then((r) => r.json()) .then((d) => { if (!cancelled) setDirectors(d.items || []); }) .catch(() => { if (!cancelled) setDirectors([]); }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, [companyId]); const pickedIds = new Set(picked.map((p) => p.director_id)); const available = useMemo(() => { const lower = search.toLowerCase(); return directors .filter((d) => !excludeIds.includes(d.id)) .filter((d) => { if (!search.trim()) return true; return ( d.first_names?.toLowerCase().includes(lower) || d.surname?.toLowerCase().includes(lower) || (d.email ?? "").toLowerCase().includes(lower) ); }); }, [directors, search, excludeIds]); const toggle = (d: Director) => { if (pickedIds.has(d.id)) { onChange(picked.filter((p) => p.director_id !== d.id)); } else { onChange([...picked, { director_id: d.id, effective_date: defaultDate }]); } }; const setEffectiveDate = (directorId: string, date: string) => { onChange(picked.map((p) => (p.director_id === directorId ? { ...p, effective_date: date } : p))); }; const isResigned = role === "resigned"; const accentRing = isResigned ? "border-danger/30 bg-danger/5" : "border-success/30 bg-success/5"; return (

{isResigned ? "Pick any directors resigning on this resolution. Each gets an independent effective date." : "Pick any directors being appointed on this resolution. Each gets an independent effective date."}

Optional — leave empty to skip
{/* Skip-this-step affordance */} {picked.length === 0 && (
No {isResigned ? "resigning" : "appointing"} directors selected. You can advance to the next step — this is fine if there are simply no {" "}{isResigned ? "resignations" : "appointments"} on this meeting.
)} {/* Picked chips */} {picked.length > 0 && (
{picked.map((p) => { const d = directors.find((x) => x.id === p.director_id); return (
{d ? fullName(d) : p.director_id} setEffectiveDate(p.director_id, e.target.value)} className="bg-surface border border-border rounded px-2 py-1.5 text-xs font-mono text-fg focus:outline-none focus:border-accent" />
); })}
)} {/* Director search + list */} setSearch(e.target.value)} 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" />
{loading ? (

Loading directors…

) : available.length === 0 ? (

{search.trim() ? "No matching directors." : "No more directors available."}

) : ( available.map((d) => { const isPicked = pickedIds.has(d.id); return ( ); }) )}

{picked.length} director{picked.length === 1 ? "" : "s"} selected. {!isResigned && " Directors in the resigned list are hidden from this list."}

); } // ─── Step 4: Meeting date + notes ───────────────────────────────────────── interface MeetingDateStepProps { meetingDate: string; notes: string; onChange: (d: string, n: string) => void; } function MeetingDateStep({ meetingDate, notes, onChange }: MeetingDateStepProps) { return (

When did (or will) the meeting take place? This date appears on the resolution minute.

onChange(e.target.value, notes)} className="w-full bg-surface border border-border rounded px-3 py-2.5 text-sm text-fg font-mono focus:outline-none focus:border-accent" />