Workflow-App/Workflow/components/ResolutionWizard.tsx
sjcoetzeesj-coder 11d0178ab1 feat: commit Workflow app source; move CIPC credentials to env
Adds the full Next.js app (app/, components/, lib/, scripts/, assets/,
docs/, Dockerfile, compose, configs) to version control.

Security:
- CIPC BizPortal customer-code logins (Login 1 + Login 3) now read
  CIPC_LOGIN1_CODE / CIPC_LOGIN1_PASSWORD / CIPC_LOGIN3_CODE /
  CIPC_LOGIN3_PASSWORD from .env.local instead of source.
- BizPortal personal login in apply-bizportal route reads
  BIZPORTAL_ID_NO / BIZPORTAL_PASSWORD.
- Added .env.example as the committed template.

.gitignore excludes .env*, node_modules, .next, logs, sqlite, Playwright
storage_state.json (live CIPC cookies), scratch dirs, client PDFs and
debug screenshots.
2026-10-18 15:21:45 +02:00

874 lines
No EOL
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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>
);
}