Uncommitted work in progress at the point of starting the Kanban project. Captures: - Registration wizard refactor + multi-step routing - structured physical/postal address fields on companies and directors - appointments flow (sign + verify) - document type manager + upload/panel for companies and directors - status actions, approve button, action/dropdown buttons - step2 review page tsconfig.tsbuildinfo is now ignored (build artefact).
284 lines
11 KiB
TypeScript
284 lines
11 KiB
TypeScript
// components/DirectorAppointmentTab.tsx
|
|
// Table of all director appointments + "Acceptance Letter" modal
|
|
|
|
"use client";
|
|
|
|
import { useEffect, useState, useCallback } from "react";
|
|
import type { YsterbaardDirectorAppointment } from "@/lib/types";
|
|
|
|
type Status = YsterbaardDirectorAppointment["status"];
|
|
|
|
const STATUS_LABELS: Record<Status, string> = {
|
|
pending: "Pending",
|
|
mail_sent: "Mail sent",
|
|
signature_received: "Signature received",
|
|
mail_sent_to_all: "Mail sent to all",
|
|
};
|
|
|
|
const STATUS_VARIANTS: Record<Status, "muted" | "warn" | "success" | "info"> = {
|
|
pending: "muted",
|
|
mail_sent: "warn",
|
|
signature_received: "success",
|
|
mail_sent_to_all: "info",
|
|
};
|
|
|
|
interface DirectorOption { id: string; label: string; email?: string; telephone?: string; }
|
|
interface CompanyOption { id: string; label: string; }
|
|
|
|
interface Appointment extends YsterbaardDirectorAppointment {
|
|
_director_name?: string;
|
|
_company_name?: string;
|
|
}
|
|
|
|
function StatusBadge({ variant, children }: { variant: string; children: React.ReactNode }) {
|
|
const classes: Record<string, string> = {
|
|
muted: "bg-slate-100 text-slate-600",
|
|
warn: "bg-amber-100 text-amber-700",
|
|
success: "bg-green-100 text-green-700",
|
|
info: "bg-teal-100 text-teal-700",
|
|
};
|
|
return (
|
|
<span className={`inline-flex px-2 py-0.5 rounded text-xs font-semibold ${classes[variant] || "bg-slate-100 text-slate-600"}`}>
|
|
{children}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
export default function DirectorAppointmentTab() {
|
|
const [appointments, setAppointments] = useState<Appointment[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [showModal, setShowModal] = useState(false);
|
|
const [sending, setSending] = useState(false);
|
|
const [sendError, setSendError] = useState<string | null>(null);
|
|
|
|
// Modal form state
|
|
const [directors, setDirectors] = useState<DirectorOption[]>([]);
|
|
const [companies, setCompanies] = useState<CompanyOption[]>([]);
|
|
const [selectedDirector, setSelectedDirector] = useState("");
|
|
const [selectedCompany, setSelectedCompany] = useState("");
|
|
|
|
const loadAppointments = useCallback(async () => {
|
|
try {
|
|
const res = await fetch("/api/appointments");
|
|
if (!res.ok) throw new Error();
|
|
const data = await res.json();
|
|
const items: Appointment[] = (data.items || []).map((item: YsterbaardDirectorAppointment) => ({
|
|
...item,
|
|
_director_name: item.expand?.director
|
|
? [item.expand.director.first_names, item.expand.director.surname].filter(Boolean).join(" ")
|
|
: item.expand?.director?.first_names || "—",
|
|
_company_name: item.expand?.company?.enterprise_name || "—",
|
|
}));
|
|
setAppointments(items);
|
|
} catch {
|
|
// silently fail
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
loadAppointments();
|
|
}, [loadAppointments]);
|
|
|
|
const openModal = async () => {
|
|
setShowModal(true);
|
|
setSendError(null);
|
|
setSelectedDirector("");
|
|
setSelectedCompany("");
|
|
|
|
// Load directors
|
|
try {
|
|
const res = await fetch("/api/directors/list");
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setDirectors(
|
|
(data.items || []).map((d: { id: string; first_names?: string; surname?: string; email?: string; telephone?: string }) => ({
|
|
id: d.id,
|
|
label: [d.first_names, d.surname].filter(Boolean).join(" ") || d.id,
|
|
email: d.email,
|
|
telephone: d.telephone,
|
|
}))
|
|
);
|
|
}
|
|
} catch { /* ignore */ }
|
|
|
|
// Load companies
|
|
try {
|
|
const res = await fetch("/api/companies/list");
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setCompanies(
|
|
(data.items || []).map((c: { id: string; enterprise_name?: string }) => ({
|
|
id: c.id,
|
|
label: c.enterprise_name || c.id,
|
|
}))
|
|
);
|
|
}
|
|
} catch { /* ignore */ }
|
|
};
|
|
|
|
const handleSend = async () => {
|
|
if (!selectedDirector || !selectedCompany) return;
|
|
setSending(true);
|
|
setSendError(null);
|
|
|
|
// Get full director + company details
|
|
const director = directors.find((d) => d.id === selectedDirector);
|
|
const company = companies.find((c) => c.id === selectedCompany);
|
|
|
|
try {
|
|
const res = await fetch("/api/appointments", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
director_id: selectedDirector,
|
|
company_id: selectedCompany,
|
|
director_email: director?.email || "SJ@Dyfins.com",
|
|
director_cell: director?.telephone || "",
|
|
director_name: director?.label.split(" ")[0] || "",
|
|
director_surname: director?.label.split(" ").slice(1).join(" ") || "",
|
|
company_name: company?.label || "",
|
|
}),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => ({}));
|
|
throw new Error(data.error || `HTTP ${res.status}`);
|
|
}
|
|
|
|
setShowModal(false);
|
|
loadAppointments();
|
|
} catch (err: unknown) {
|
|
setSendError(err instanceof Error ? err.message : "Failed to send letter");
|
|
} finally {
|
|
setSending(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-sm font-semibold text-fg">
|
|
{loading ? "Loading..." : `${appointments.length} appointment${appointments.length !== 1 ? "s" : ""}`}
|
|
</p>
|
|
<button
|
|
onClick={openModal}
|
|
className="px-4 py-2 bg-accent text-accent-on rounded font-semibold text-sm hover:opacity-90 transition-opacity"
|
|
>
|
|
Acceptance Letter
|
|
</button>
|
|
</div>
|
|
|
|
{/* Table */}
|
|
{loading ? (
|
|
<div className="text-center py-12 text-muted text-sm">Loading...</div>
|
|
) : appointments.length === 0 ? (
|
|
<div className="bg-surface border border-border rounded-lg p-12 text-center text-muted text-sm">
|
|
No director appointments yet. Click “Acceptance Letter” to send the first one.
|
|
</div>
|
|
) : (
|
|
<div className="bg-surface border border-border rounded-lg overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-border bg-surface-warm">
|
|
<th className="text-left px-5 py-3 text-xs font-semibold text-muted uppercase tracking-wide">Director</th>
|
|
<th className="text-left px-5 py-3 text-xs font-semibold text-muted uppercase tracking-wide hidden md:table-cell">Company</th>
|
|
<th className="text-left px-5 py-3 text-xs font-semibold text-muted uppercase tracking-wide">Status</th>
|
|
<th className="text-left px-5 py-3 text-xs font-semibold text-muted uppercase tracking-wide hidden sm:table-cell">Sent to</th>
|
|
<th className="text-left px-5 py-3 text-xs font-semibold text-muted uppercase tracking-wide hidden lg:table-cell">Date</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-border-soft">
|
|
{appointments.map((appt) => (
|
|
<tr key={appt.id} className="hover:bg-surface-warm transition-colors">
|
|
<td className="px-5 py-4 font-semibold text-fg">{appt._director_name}</td>
|
|
<td className="px-5 py-4 hidden md:table-cell text-fg-2">{appt._company_name}</td>
|
|
<td className="px-5 py-4">
|
|
<StatusBadge variant={STATUS_VARIANTS[appt.status]}>
|
|
{STATUS_LABELS[appt.status]}
|
|
</StatusBadge>
|
|
</td>
|
|
<td className="px-5 py-4 hidden sm:table-cell text-muted text-xs">{appt.sent_to || "—"}</td>
|
|
<td className="px-5 py-4 hidden lg:table-cell text-muted tabular-nums text-xs">
|
|
{appt.sent_at ? new Date(appt.sent_at).toLocaleDateString("en-ZA") : "—"}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
{/* Modal */}
|
|
{showModal && (
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
|
|
onClick={(e) => { if (e.target === e.currentTarget) setShowModal(false); }}
|
|
>
|
|
<div className="bg-surface border border-border rounded-xl shadow-2xl w-full max-w-md">
|
|
<div className="border-b border-border px-6 py-4">
|
|
<h2 className="text-lg font-semibold text-fg">Send Acceptance Letter</h2>
|
|
<p className="text-sm text-muted mt-0.5">Select a company and director to initiate the appointment.</p>
|
|
</div>
|
|
|
|
<div className="p-6 space-y-4">
|
|
{/* Company */}
|
|
<div>
|
|
<label className="block text-sm font-semibold text-fg mb-1.5">Company</label>
|
|
<select
|
|
value={selectedCompany}
|
|
onChange={(e) => setSelectedCompany(e.target.value)}
|
|
className="w-full bg-surface border border-border rounded px-3 py-2.5 text-sm text-fg focus:outline-none focus:border-accent"
|
|
>
|
|
<option value="">— Select company —</option>
|
|
{companies.map((c) => (
|
|
<option key={c.id} value={c.id}>{c.label}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Director */}
|
|
<div>
|
|
<label className="block text-sm font-semibold text-fg mb-1.5">Director</label>
|
|
<select
|
|
value={selectedDirector}
|
|
onChange={(e) => setSelectedDirector(e.target.value)}
|
|
className="w-full bg-surface border border-border rounded px-3 py-2.5 text-sm text-fg focus:outline-none focus:border-accent"
|
|
>
|
|
<option value="">— Select director —</option>
|
|
{directors.map((d) => (
|
|
<option key={d.id} value={d.id}>{d.label}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{sendError && (
|
|
<div className="bg-red-500/10 border border-red-500/30 rounded-lg px-4 py-3 text-sm text-red-400">
|
|
{sendError}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="border-t border-border px-6 py-4 flex justify-end gap-3">
|
|
<button
|
|
onClick={() => setShowModal(false)}
|
|
className="px-4 py-2 text-sm font-semibold text-muted hover:text-fg transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={handleSend}
|
|
disabled={!selectedDirector || !selectedCompany || sending}
|
|
className="px-4 py-2 bg-accent text-accent-on rounded font-semibold text-sm disabled:opacity-40 hover:opacity-90 transition-opacity"
|
|
>
|
|
{sending ? "Sending..." : "Send Letter"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|