874 lines
30 KiB
TypeScript
874 lines
30 KiB
TypeScript
|
|
// 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 | false | null>): 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<WizardState>({
|
|||
|
|
step: 0,
|
|||
|
|
company: null,
|
|||
|
|
path: null,
|
|||
|
|
resigned: [],
|
|||
|
|
appointed: [],
|
|||
|
|
meeting_date: todayISO(),
|
|||
|
|
notes: "",
|
|||
|
|
});
|
|||
|
|
const [submitting, setSubmitting] = useState(false);
|
|||
|
|
const [submitError, setSubmitError] = useState<string | null>(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 (
|
|||
|
|
<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">
|
|||
|
|
<div>
|
|||
|
|
<h2 className="text-fg font-bold text-lg">
|
|||
|
|
New Resolution of Directors
|
|||
|
|
</h2>
|
|||
|
|
<p className="text-muted text-sm mt-0.5">
|
|||
|
|
Step {state.step + 1} of {STEPS.length} — {STEPS[state.step]}
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
<button
|
|||
|
|
onClick={onClose}
|
|||
|
|
disabled={submitting}
|
|||
|
|
aria-label="Close"
|
|||
|
|
className="w-9 h-9 rounded-md text-muted hover:text-fg hover:bg-surface transition-colors text-xl disabled:opacity-40"
|
|||
|
|
>
|
|||
|
|
×
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Progress bar */}
|
|||
|
|
<div className="px-6 py-3 border-b border-border bg-surface">
|
|||
|
|
<div className="flex items-center gap-1.5">
|
|||
|
|
{STEPS.map((label, i) => (
|
|||
|
|
<div
|
|||
|
|
key={label}
|
|||
|
|
className={classNames(
|
|||
|
|
"h-1.5 flex-1 rounded-full transition-colors",
|
|||
|
|
i <= state.step ? "bg-accent" : "bg-surface-warm border border-border-soft"
|
|||
|
|
)}
|
|||
|
|
title={label}
|
|||
|
|
/>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Body */}
|
|||
|
|
<div className="flex-1 overflow-y-auto px-6 py-6">
|
|||
|
|
{state.step === 0 && (
|
|||
|
|
<CompanyStep
|
|||
|
|
selected={state.company}
|
|||
|
|
onPick={(c) => setState((s) => ({ ...s, company: c }))}
|
|||
|
|
/>
|
|||
|
|
)}
|
|||
|
|
{state.step === 1 && (
|
|||
|
|
<PathStep
|
|||
|
|
value={state.path}
|
|||
|
|
onChange={(p) => setState((s) => ({ ...s, path: p }))}
|
|||
|
|
/>
|
|||
|
|
)}
|
|||
|
|
{state.step === 2 && state.company && (
|
|||
|
|
<DirectorsStep
|
|||
|
|
companyId={state.company.id}
|
|||
|
|
role="resigned"
|
|||
|
|
picked={state.resigned}
|
|||
|
|
defaultDate={state.meeting_date}
|
|||
|
|
onChange={(list) => setState((s) => ({ ...s, resigned: list }))}
|
|||
|
|
/>
|
|||
|
|
)}
|
|||
|
|
{state.step === 3 && state.company && (
|
|||
|
|
<DirectorsStep
|
|||
|
|
companyId={state.company.id}
|
|||
|
|
role="appointed"
|
|||
|
|
picked={state.appointed}
|
|||
|
|
defaultDate={state.meeting_date}
|
|||
|
|
excludeIds={state.resigned.map((r) => r.director_id)}
|
|||
|
|
onChange={(list) => setState((s) => ({ ...s, appointed: list }))}
|
|||
|
|
/>
|
|||
|
|
)}
|
|||
|
|
{state.step === 4 && (
|
|||
|
|
<MeetingDateStep
|
|||
|
|
meetingDate={state.meeting_date}
|
|||
|
|
notes={state.notes}
|
|||
|
|
onChange={(d, n) =>
|
|||
|
|
setState((s) => ({ ...s, meeting_date: d, notes: n }))
|
|||
|
|
}
|
|||
|
|
/>
|
|||
|
|
)}
|
|||
|
|
{state.step === 5 && state.company && (
|
|||
|
|
<ReviewStep
|
|||
|
|
state={state}
|
|||
|
|
companyId={state.company.id}
|
|||
|
|
/>
|
|||
|
|
)}
|
|||
|
|
{submitError && (
|
|||
|
|
<div className="mt-4 border border-danger/40 bg-danger/5 rounded-lg p-4">
|
|||
|
|
<p className="text-danger text-sm font-semibold">Failed to create resolution</p>
|
|||
|
|
<p className="text-muted text-sm mt-1">{submitError}</p>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Footer */}
|
|||
|
|
<div className="border-t border-border px-6 py-4 flex items-center justify-between bg-surface-warm">
|
|||
|
|
<button
|
|||
|
|
onClick={goBack}
|
|||
|
|
disabled={state.step === 0 || submitting}
|
|||
|
|
className="px-4 py-2 text-sm font-semibold text-muted hover:text-fg transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
|||
|
|
>
|
|||
|
|
← Back
|
|||
|
|
</button>
|
|||
|
|
<div className="flex items-center gap-3">
|
|||
|
|
<button
|
|||
|
|
onClick={onClose}
|
|||
|
|
disabled={submitting}
|
|||
|
|
className="px-4 py-2 text-sm font-semibold text-muted hover:text-fg transition-colors disabled:opacity-40"
|
|||
|
|
>
|
|||
|
|
Cancel
|
|||
|
|
</button>
|
|||
|
|
{state.step < STEPS.length - 1 ? (
|
|||
|
|
<button
|
|||
|
|
onClick={goNext}
|
|||
|
|
disabled={!canAdvance}
|
|||
|
|
className="px-5 py-2 bg-accent text-accent-on rounded font-semibold text-sm disabled:opacity-30 disabled:cursor-not-allowed hover:opacity-90 transition-opacity"
|
|||
|
|
>
|
|||
|
|
Next →
|
|||
|
|
</button>
|
|||
|
|
) : (
|
|||
|
|
<button
|
|||
|
|
onClick={submit}
|
|||
|
|
disabled={submitting || !canAdvance}
|
|||
|
|
className="px-5 py-2 bg-accent text-accent-on rounded font-semibold text-sm disabled:opacity-30 disabled:cursor-not-allowed hover:opacity-90 transition-opacity"
|
|||
|
|
>
|
|||
|
|
{submitting ? "Creating…" : "Create resolution"}
|
|||
|
|
</button>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ─── 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<Company[]>([]);
|
|||
|
|
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 (
|
|||
|
|
<div className="space-y-3">
|
|||
|
|
<p className="text-sm text-muted">
|
|||
|
|
Choose the company this resolution applies to.
|
|||
|
|
</p>
|
|||
|
|
<input
|
|||
|
|
autoFocus
|
|||
|
|
placeholder="Search by name or enterprise number…"
|
|||
|
|
value={search}
|
|||
|
|
onChange={(e) => 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"
|
|||
|
|
/>
|
|||
|
|
<div className="border border-border rounded-lg overflow-hidden max-h-72 overflow-y-auto">
|
|||
|
|
{loading ? (
|
|||
|
|
<p className="p-4 text-sm text-muted">Loading companies…</p>
|
|||
|
|
) : filtered.length === 0 ? (
|
|||
|
|
<p className="p-4 text-sm text-muted italic">
|
|||
|
|
{search.trim() ? "No companies match." : "No companies on file."}
|
|||
|
|
</p>
|
|||
|
|
) : (
|
|||
|
|
filtered.map((c) => {
|
|||
|
|
const isSelected = selected?.id === c.id;
|
|||
|
|
return (
|
|||
|
|
<button
|
|||
|
|
key={c.id}
|
|||
|
|
onClick={() => onPick(c)}
|
|||
|
|
className={classNames(
|
|||
|
|
"w-full text-left px-4 py-2.5 text-sm transition-colors border-b border-border last:border-b-0",
|
|||
|
|
isSelected
|
|||
|
|
? "bg-accent/10 text-accent font-semibold"
|
|||
|
|
: "hover:bg-surface-warm text-fg"
|
|||
|
|
)}
|
|||
|
|
>
|
|||
|
|
<span className="font-semibold">{c.enterprise_name}</span>
|
|||
|
|
{c.enterprise_no && (
|
|||
|
|
<span className="text-xs text-muted ml-2 font-mono">{c.enterprise_no}</span>
|
|||
|
|
)}
|
|||
|
|
</button>
|
|||
|
|
);
|
|||
|
|
})
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
{selected && (
|
|||
|
|
<div className="flex items-center gap-2 px-3 py-2 bg-accent/5 border border-accent/30 rounded-md text-sm">
|
|||
|
|
<span className="text-muted">Selected:</span>
|
|||
|
|
<span className="font-semibold text-fg">{selected.enterprise_name}</span>
|
|||
|
|
{selected.enterprise_no && (
|
|||
|
|
<span className="text-xs text-muted font-mono">({selected.enterprise_no})</span>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ─── Step 1: Path picker ──────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
interface PathStepProps {
|
|||
|
|
value: PathChoice | null;
|
|||
|
|
onChange: (p: PathChoice) => void;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function PathStep({ value, onChange }: PathStepProps) {
|
|||
|
|
return (
|
|||
|
|
<div className="space-y-3">
|
|||
|
|
<p className="text-sm text-muted">
|
|||
|
|
How will the resolution letter be sourced?
|
|||
|
|
</p>
|
|||
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|||
|
|
<PathCard
|
|||
|
|
active={value === "letters_received"}
|
|||
|
|
onClick={() => onChange("letters_received")}
|
|||
|
|
title="Letters already received"
|
|||
|
|
description="Resignation / appointment letters are already on file as PDFs. The minute of meeting will reference them."
|
|||
|
|
/>
|
|||
|
|
<PathCard
|
|||
|
|
active={value === "letter_generated"}
|
|||
|
|
onClick={() => 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."
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface PathCardProps {
|
|||
|
|
active: boolean;
|
|||
|
|
onClick: () => void;
|
|||
|
|
title: string;
|
|||
|
|
description: string;
|
|||
|
|
}
|
|||
|
|
function PathCard({ active, onClick, title, description }: PathCardProps) {
|
|||
|
|
return (
|
|||
|
|
<button
|
|||
|
|
onClick={onClick}
|
|||
|
|
className={classNames(
|
|||
|
|
"text-left p-4 rounded-lg border-2 transition-all",
|
|||
|
|
active
|
|||
|
|
? "border-accent bg-accent/10"
|
|||
|
|
: "border-border bg-surface hover:border-accent/50"
|
|||
|
|
)}
|
|||
|
|
>
|
|||
|
|
<p className={classNames("font-semibold", active ? "text-accent" : "text-fg")}>{title}</p>
|
|||
|
|
<p className="text-xs text-muted mt-1.5 leading-relaxed">{description}</p>
|
|||
|
|
</button>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ─── 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<Director[]>([]);
|
|||
|
|
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 (
|
|||
|
|
<div className="space-y-4">
|
|||
|
|
<div className="flex items-baseline justify-between gap-3">
|
|||
|
|
<p className="text-sm text-muted">
|
|||
|
|
{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."}
|
|||
|
|
</p>
|
|||
|
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted bg-surface-warm border border-border rounded px-2 py-0.5">
|
|||
|
|
Optional — leave empty to skip
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Skip-this-step affordance */}
|
|||
|
|
{picked.length === 0 && (
|
|||
|
|
<div className="text-xs text-muted border border-dashed border-border rounded-md px-3 py-2 bg-surface-warm">
|
|||
|
|
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.
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* Picked chips */}
|
|||
|
|
{picked.length > 0 && (
|
|||
|
|
<div className={classNames("border rounded-lg p-3 space-y-2", accentRing)}>
|
|||
|
|
{picked.map((p) => {
|
|||
|
|
const d = directors.find((x) => x.id === p.director_id);
|
|||
|
|
return (
|
|||
|
|
<div key={p.director_id} className="flex items-center gap-3">
|
|||
|
|
<span className="flex-1 text-sm font-semibold text-fg">
|
|||
|
|
{d ? fullName(d) : p.director_id}
|
|||
|
|
</span>
|
|||
|
|
<label className="text-xs text-muted">Effective date</label>
|
|||
|
|
<input
|
|||
|
|
type="date"
|
|||
|
|
value={p.effective_date}
|
|||
|
|
onChange={(e) => 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"
|
|||
|
|
/>
|
|||
|
|
<button
|
|||
|
|
onClick={() => toggle(d!)}
|
|||
|
|
className="px-2 py-1 text-xs text-danger hover:bg-danger/10 rounded transition-colors"
|
|||
|
|
title="Remove"
|
|||
|
|
>
|
|||
|
|
Remove
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* Director search + list */}
|
|||
|
|
<input
|
|||
|
|
placeholder="Search directors by name or email…"
|
|||
|
|
value={search}
|
|||
|
|
onChange={(e) => 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"
|
|||
|
|
/>
|
|||
|
|
<div className="border border-border rounded-lg overflow-hidden max-h-72 overflow-y-auto">
|
|||
|
|
{loading ? (
|
|||
|
|
<p className="p-4 text-sm text-muted">Loading directors…</p>
|
|||
|
|
) : available.length === 0 ? (
|
|||
|
|
<p className="p-4 text-sm text-muted italic">
|
|||
|
|
{search.trim() ? "No matching directors." : "No more directors available."}
|
|||
|
|
</p>
|
|||
|
|
) : (
|
|||
|
|
available.map((d) => {
|
|||
|
|
const isPicked = pickedIds.has(d.id);
|
|||
|
|
return (
|
|||
|
|
<button
|
|||
|
|
key={d.id}
|
|||
|
|
onClick={() => toggle(d)}
|
|||
|
|
className={classNames(
|
|||
|
|
"w-full text-left px-4 py-2.5 text-sm transition-colors border-b border-border last:border-b-0",
|
|||
|
|
isPicked
|
|||
|
|
? "bg-accent/10 text-accent font-semibold"
|
|||
|
|
: "hover:bg-surface-warm text-fg"
|
|||
|
|
)}
|
|||
|
|
>
|
|||
|
|
<div className="flex items-center justify-between gap-3">
|
|||
|
|
<span>{fullName(d)}</span>
|
|||
|
|
<span className="text-xs text-muted font-mono">
|
|||
|
|
{d.email || "no email"}
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
</button>
|
|||
|
|
);
|
|||
|
|
})
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<p className="text-xs text-muted italic">
|
|||
|
|
{picked.length} director{picked.length === 1 ? "" : "s"} selected.
|
|||
|
|
{!isResigned && " Directors in the resigned list are hidden from this list."}
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ─── Step 4: Meeting date + notes ─────────────────────────────────────────
|
|||
|
|
|
|||
|
|
interface MeetingDateStepProps {
|
|||
|
|
meetingDate: string;
|
|||
|
|
notes: string;
|
|||
|
|
onChange: (d: string, n: string) => void;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function MeetingDateStep({ meetingDate, notes, onChange }: MeetingDateStepProps) {
|
|||
|
|
return (
|
|||
|
|
<div className="space-y-4">
|
|||
|
|
<p className="text-sm text-muted">
|
|||
|
|
When did (or will) the meeting take place? This date appears on the
|
|||
|
|
resolution minute.
|
|||
|
|
</p>
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-xs font-semibold text-muted uppercase mb-1.5">
|
|||
|
|
Meeting date <span className="text-danger">*</span>
|
|||
|
|
</label>
|
|||
|
|
<input
|
|||
|
|
type="date"
|
|||
|
|
value={meetingDate}
|
|||
|
|
onChange={(e) => 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"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-xs font-semibold text-muted uppercase mb-1.5">
|
|||
|
|
Internal notes (optional)
|
|||
|
|
</label>
|
|||
|
|
<textarea
|
|||
|
|
rows={3}
|
|||
|
|
value={notes}
|
|||
|
|
onChange={(e) => onChange(meetingDate, e.target.value)}
|
|||
|
|
placeholder="Anything SJ wants remembered about this resolution…"
|
|||
|
|
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 resize-y"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ─── Step 5: Review ───────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
interface ReviewStepProps {
|
|||
|
|
state: WizardState;
|
|||
|
|
companyId: string;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function ReviewStep({ state }: ReviewStepProps) {
|
|||
|
|
const [existingDirectors, setActiveDirectors] = useState<Director[]>([]);
|
|||
|
|
const [loading, setLoading] = useState(true);
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
let cancelled = false;
|
|||
|
|
setLoading(true);
|
|||
|
|
fetch(`/api/resolutions/company-directors/${state.company?.id}`)
|
|||
|
|
.then((r) => r.json())
|
|||
|
|
.then((d) => { if (!cancelled) setActiveDirectors(d.items || []); })
|
|||
|
|
.catch(() => { if (!cancelled) setActiveDirectors([]); })
|
|||
|
|
.finally(() => { if (!cancelled) setLoading(false); });
|
|||
|
|
return () => { cancelled = true; };
|
|||
|
|
}, [state.company?.id]);
|
|||
|
|
|
|||
|
|
// Build attendee preview list (same order the create endpoint will use):
|
|||
|
|
// resigned → appointed → existing
|
|||
|
|
const resignedMap = new Map(state.resigned.map((p) => [p.director_id, p]));
|
|||
|
|
const appointedMap = new Map(state.appointed.map((p) => [p.director_id, p]));
|
|||
|
|
|
|||
|
|
const pickedIds = new Set([
|
|||
|
|
...resignedMap.keys(),
|
|||
|
|
...appointedMap.keys(),
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
const existingAttendance: AttendeePreview[] = existingDirectors
|
|||
|
|
.filter((d) => !pickedIds.has(d.id))
|
|||
|
|
.map((d) => ({
|
|||
|
|
director_id: d.id,
|
|||
|
|
name: d.first_names,
|
|||
|
|
surname: d.surname,
|
|||
|
|
email: d.email,
|
|||
|
|
role: "existing",
|
|||
|
|
must_sign: true,
|
|||
|
|
attended: true,
|
|||
|
|
effective_date: state.meeting_date,
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
const attendees: AttendeePreview[] = [
|
|||
|
|
...[...resignedMap.values()].map((p) => {
|
|||
|
|
const d = existingDirectors.find((x) => x.id === p.director_id);
|
|||
|
|
return {
|
|||
|
|
director_id: p.director_id,
|
|||
|
|
name: d?.first_names ?? "",
|
|||
|
|
surname: d?.surname ?? "",
|
|||
|
|
email: d?.email ?? null,
|
|||
|
|
role: "resigned" as const,
|
|||
|
|
must_sign: true,
|
|||
|
|
attended: true,
|
|||
|
|
effective_date: p.effective_date,
|
|||
|
|
};
|
|||
|
|
}),
|
|||
|
|
...[...appointedMap.values()].map((p) => {
|
|||
|
|
const d = existingDirectors.find((x) => x.id === p.director_id);
|
|||
|
|
return {
|
|||
|
|
director_id: p.director_id,
|
|||
|
|
name: d?.first_names ?? "",
|
|||
|
|
surname: d?.surname ?? "",
|
|||
|
|
email: d?.email ?? null,
|
|||
|
|
role: "appointed" as const,
|
|||
|
|
must_sign: true,
|
|||
|
|
attended: true,
|
|||
|
|
effective_date: p.effective_date,
|
|||
|
|
};
|
|||
|
|
}),
|
|||
|
|
...existingAttendance,
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="space-y-5">
|
|||
|
|
<p className="text-sm text-muted">
|
|||
|
|
Final check. The auto-populated attendees table below will be created
|
|||
|
|
when you press <strong>Create resolution</strong>.
|
|||
|
|
</p>
|
|||
|
|
|
|||
|
|
{/* Summary cards */}
|
|||
|
|
<div className="grid grid-cols-2 gap-3">
|
|||
|
|
<SummaryRow label="Company" value={state.company?.enterprise_name ?? "—"} />
|
|||
|
|
<SummaryRow label="Path" value={state.path === "letters_received" ? "Letters received" : state.path === "letter_generated" ? "Letter generated" : "—"} />
|
|||
|
|
<SummaryRow label="Meeting date" value={state.meeting_date} mono />
|
|||
|
|
<SummaryRow label="Resigned" value={String(state.resigned.length)} />
|
|||
|
|
<SummaryRow label="Appointed" value={String(state.appointed.length)} />
|
|||
|
|
<SummaryRow
|
|||
|
|
label="Total attendees"
|
|||
|
|
value={String(attendees.length)}
|
|||
|
|
accent
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{state.notes && (
|
|||
|
|
<div className="border border-border rounded-lg p-3 bg-surface-warm">
|
|||
|
|
<p className="text-xs font-semibold text-muted uppercase mb-1">Notes</p>
|
|||
|
|
<p className="text-sm text-fg whitespace-pre-wrap">{state.notes}</p>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* Attendees table */}
|
|||
|
|
<div>
|
|||
|
|
<p className="text-xs font-semibold text-muted uppercase mb-2">
|
|||
|
|
Attendees (auto-populated, editable later)
|
|||
|
|
</p>
|
|||
|
|
{loading ? (
|
|||
|
|
<p className="text-sm text-muted italic p-3">Loading attendees…</p>
|
|||
|
|
) : attendees.length === 0 ? (
|
|||
|
|
<p className="text-sm text-muted italic p-3">No attendees yet.</p>
|
|||
|
|
) : (
|
|||
|
|
<div className="border border-border rounded-lg overflow-hidden">
|
|||
|
|
<table className="w-full text-sm">
|
|||
|
|
<thead className="bg-surface-warm border-b border-border">
|
|||
|
|
<tr>
|
|||
|
|
<th className="text-left px-3 py-2 text-xs font-semibold text-muted uppercase">Role</th>
|
|||
|
|
<th className="text-left px-3 py-2 text-xs font-semibold text-muted uppercase">Name</th>
|
|||
|
|
<th className="text-left px-3 py-2 text-xs font-semibold text-muted uppercase hidden sm:table-cell">Email</th>
|
|||
|
|
<th className="text-left px-3 py-2 text-xs font-semibold text-muted uppercase hidden md:table-cell">Effective</th>
|
|||
|
|
<th className="text-left px-3 py-2 text-xs font-semibold text-muted uppercase hidden md:table-cell">Sign?</th>
|
|||
|
|
</tr>
|
|||
|
|
</thead>
|
|||
|
|
<tbody className="divide-y divide-border-soft">
|
|||
|
|
{attendees.map((a) => (
|
|||
|
|
<tr key={a.director_id}>
|
|||
|
|
<td className="px-3 py-2">
|
|||
|
|
<RolePill role={a.role} />
|
|||
|
|
</td>
|
|||
|
|
<td className="px-3 py-2 font-semibold text-fg">
|
|||
|
|
{fullName(a)}
|
|||
|
|
</td>
|
|||
|
|
<td className="px-3 py-2 hidden sm:table-cell text-xs text-muted font-mono">
|
|||
|
|
{a.email || "—"}
|
|||
|
|
</td>
|
|||
|
|
<td className="px-3 py-2 hidden md:table-cell text-xs font-mono text-muted">
|
|||
|
|
{a.effective_date}
|
|||
|
|
</td>
|
|||
|
|
<td className="px-3 py-2 hidden md:table-cell text-xs">
|
|||
|
|
{a.must_sign ? "Yes" : "No"}
|
|||
|
|
</td>
|
|||
|
|
</tr>
|
|||
|
|
))}
|
|||
|
|
</tbody>
|
|||
|
|
</table>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function SummaryRow({
|
|||
|
|
label,
|
|||
|
|
value,
|
|||
|
|
mono,
|
|||
|
|
accent,
|
|||
|
|
}: {
|
|||
|
|
label: string;
|
|||
|
|
value: string;
|
|||
|
|
mono?: boolean;
|
|||
|
|
accent?: boolean;
|
|||
|
|
}) {
|
|||
|
|
return (
|
|||
|
|
<div className="border border-border rounded-lg px-4 py-3 bg-surface">
|
|||
|
|
<p className="text-xs text-muted uppercase tracking-wide">{label}</p>
|
|||
|
|
<p
|
|||
|
|
className={classNames(
|
|||
|
|
"mt-1",
|
|||
|
|
accent ? "text-accent font-bold" : "text-fg font-semibold",
|
|||
|
|
Boolean(mono) && "font-mono text-sm"
|
|||
|
|
)}
|
|||
|
|
>
|
|||
|
|
{value}
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function RolePill({ role }: { role: "existing" | "resigned" | "appointed" }) {
|
|||
|
|
const style =
|
|||
|
|
role === "resigned"
|
|||
|
|
? "bg-danger/10 text-danger"
|
|||
|
|
: role === "appointed"
|
|||
|
|
? "bg-success/10 text-success"
|
|||
|
|
: "bg-surface-warm text-muted border border-border";
|
|||
|
|
const label = role === "existing" ? "Existing" : role === "resigned" ? "Resigned" : "Appointed";
|
|||
|
|
return (
|
|||
|
|
<span className={classNames("inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold", style)}>
|
|||
|
|
{label}
|
|||
|
|
</span>
|
|||
|
|
);
|
|||
|
|
}
|