386 lines
17 KiB
TypeScript
386 lines
17 KiB
TypeScript
|
|
// components/AppointmentTable.tsx
|
||
|
|
// Table view of all ysterbaard_director_resignations — same data, but a
|
||
|
|
// dashboard-style "where are we now" view grouped by status. Surfaces
|
||
|
|
// signed + pending so SJ can see at a glance what's waiting on what.
|
||
|
|
|
||
|
|
"use client";
|
||
|
|
|
||
|
|
import { useEffect, useMemo, useState } from "react";
|
||
|
|
import StatusBadge from "@/components/StatusBadge";
|
||
|
|
|
||
|
|
type Status = "pending" | "mail_sent" | "signature_received" | "mail_sent_to_all";
|
||
|
|
|
||
|
|
interface Appointment {
|
||
|
|
id: string;
|
||
|
|
status: Status | "";
|
||
|
|
sent_to: string;
|
||
|
|
sent_at: string;
|
||
|
|
sign_token: string;
|
||
|
|
signature: string;
|
||
|
|
letter: string;
|
||
|
|
missing_emails: string;
|
||
|
|
proceed_anyway: boolean;
|
||
|
|
created: string;
|
||
|
|
updated: string;
|
||
|
|
expand?: {
|
||
|
|
director?: { id: string; first_names?: string; surname?: string; email?: string };
|
||
|
|
company?: { id: string; enterprise_no?: string; enterprise_name?: string; company_email?: string };
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
const STATUS_LABELS: Record<Status, string> = {
|
||
|
|
pending: "Pending — letter not yet sent",
|
||
|
|
mail_sent: "Awaiting signature",
|
||
|
|
signature_received: "Signed — distro in flight",
|
||
|
|
mail_sent_to_all: "Complete",
|
||
|
|
};
|
||
|
|
|
||
|
|
const STATUS_BADGE: Record<Status, "muted" | "warn" | "warn" | "success"> = {
|
||
|
|
pending: "muted",
|
||
|
|
mail_sent: "warn",
|
||
|
|
signature_received: "warn",
|
||
|
|
mail_sent_to_all: "success",
|
||
|
|
};
|
||
|
|
|
||
|
|
function fmtDate(iso: string | null | undefined): string {
|
||
|
|
if (!iso) return "—";
|
||
|
|
const d = new Date(iso);
|
||
|
|
if (isNaN(d.getTime())) return iso;
|
||
|
|
return d.toLocaleString("en-ZA", {
|
||
|
|
day: "2-digit", month: "short", year: "numeric",
|
||
|
|
hour: "2-digit", minute: "2-digit",
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function relTime(iso: string | null | undefined): string {
|
||
|
|
if (!iso) return "";
|
||
|
|
const diff = Date.now() - new Date(iso).getTime();
|
||
|
|
const min = Math.floor(diff / 60000);
|
||
|
|
if (min < 1) return "just now";
|
||
|
|
if (min < 60) return `${min}m ago`;
|
||
|
|
const hr = Math.floor(min / 60);
|
||
|
|
if (hr < 24) return `${hr}h ago`;
|
||
|
|
const d = Math.floor(hr / 24);
|
||
|
|
if (d < 30) return `${d}d ago`;
|
||
|
|
return "";
|
||
|
|
}
|
||
|
|
|
||
|
|
export default function AppointmentTable() {
|
||
|
|
const [items, setItems] = useState<Appointment[]>([]);
|
||
|
|
const [loading, setLoading] = useState(true);
|
||
|
|
const [error, setError] = useState<string | null>(null);
|
||
|
|
const [filter, setFilter] = useState<"all" | Status>("all");
|
||
|
|
const [q, setQ] = useState("");
|
||
|
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||
|
|
const [actionError, setActionError] = useState<string | null>(null);
|
||
|
|
|
||
|
|
const handleCancel = async (a: Appointment) => {
|
||
|
|
if (!confirm(`Cancel and delete the appointment for ${a.expand?.director?.first_names || ""} ${a.expand?.director?.surname || ""} at ${a.expand?.company?.enterprise_name || a.expand?.company?.enterprise_no || "this company"}?\n\nThis permanently removes the record.`)) return;
|
||
|
|
setBusyId(a.id); setActionError(null);
|
||
|
|
try {
|
||
|
|
const res = await fetch(`/api/pb/records/${encodeURIComponent(a.id)}?collection=ysterbaard_director_resignations`, { method: "DELETE" });
|
||
|
|
if (!res.ok) {
|
||
|
|
const data = await res.json().catch(() => ({}));
|
||
|
|
throw new Error(data.error || `HTTP ${res.status}`);
|
||
|
|
}
|
||
|
|
setItems((prev) => prev.filter((x) => x.id !== a.id));
|
||
|
|
} catch (e) {
|
||
|
|
setActionError(e instanceof Error ? e.message : "Cancel failed");
|
||
|
|
} finally {
|
||
|
|
setBusyId(null);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleResend = async (a: Appointment, mail: "initial" | "signed") => {
|
||
|
|
setBusyId(a.id); setActionError(null);
|
||
|
|
try {
|
||
|
|
const res = await fetch("/api/resignations/resend", {
|
||
|
|
method: "POST",
|
||
|
|
headers: { "Content-Type": "application/json" },
|
||
|
|
body: JSON.stringify({ id: a.id, mail }),
|
||
|
|
});
|
||
|
|
const data = await res.json().catch(() => ({}));
|
||
|
|
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
||
|
|
const reload = await fetch("/api/resignations");
|
||
|
|
if (reload.ok) {
|
||
|
|
const fresh = await reload.json();
|
||
|
|
setItems(fresh.items || []);
|
||
|
|
}
|
||
|
|
} catch (e) {
|
||
|
|
setActionError(e instanceof Error ? e.message : "Resend failed");
|
||
|
|
} finally {
|
||
|
|
setBusyId(null);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleUploadSigned = async (a: Appointment, file: File) => {
|
||
|
|
setBusyId(a.id); setActionError(null);
|
||
|
|
try {
|
||
|
|
const form = new FormData();
|
||
|
|
form.append("file", file);
|
||
|
|
const res = await fetch(
|
||
|
|
`/api/resolutions/${encodeURIComponent(a.id)}/upload` +
|
||
|
|
`?collection=ysterbaard_director_resignations&field=signature`,
|
||
|
|
{ method: "POST", body: form }
|
||
|
|
);
|
||
|
|
const data = await res.json().catch(() => ({}));
|
||
|
|
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
||
|
|
const reload = await fetch("/api/resignations");
|
||
|
|
if (reload.ok) {
|
||
|
|
const fresh = await reload.json();
|
||
|
|
setItems(fresh.items || []);
|
||
|
|
}
|
||
|
|
} catch (e) {
|
||
|
|
setActionError(e instanceof Error ? e.message : "Upload failed");
|
||
|
|
} finally {
|
||
|
|
setBusyId(null);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleComplete = async (a: Appointment) => {
|
||
|
|
const who = `${a.expand?.director?.first_names || ""} ${a.expand?.director?.surname || ""}`.trim();
|
||
|
|
if (!confirm(`Mark the resignation for ${who || "this director"} as complete?\n\nThe status becomes "Complete".`)) return;
|
||
|
|
setBusyId(a.id); setActionError(null);
|
||
|
|
try {
|
||
|
|
const res = await fetch(
|
||
|
|
`/api/pb/records/${encodeURIComponent(a.id)}?collection=ysterbaard_director_resignations`,
|
||
|
|
{
|
||
|
|
method: "PATCH",
|
||
|
|
headers: { "Content-Type": "application/json" },
|
||
|
|
body: JSON.stringify({ status: "mail_sent_to_all" }),
|
||
|
|
}
|
||
|
|
);
|
||
|
|
const data = await res.json().catch(() => ({}));
|
||
|
|
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
||
|
|
const reload = await fetch("/api/resignations");
|
||
|
|
if (reload.ok) {
|
||
|
|
const fresh = await reload.json();
|
||
|
|
setItems(fresh.items || []);
|
||
|
|
}
|
||
|
|
} catch (e) {
|
||
|
|
setActionError(e instanceof Error ? e.message : "Could not complete");
|
||
|
|
} finally {
|
||
|
|
setBusyId(null);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
fetch("/api/resignations")
|
||
|
|
.then((r) => r.json())
|
||
|
|
.then((d) => {
|
||
|
|
if (d.error) throw new Error(d.error);
|
||
|
|
setItems(d.items || []);
|
||
|
|
})
|
||
|
|
.catch((e) => setError(e instanceof Error ? e.message : "Failed to load"))
|
||
|
|
.finally(() => setLoading(false));
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
const counts = useMemo(() => {
|
||
|
|
const c: Record<string, number> = { all: items.length };
|
||
|
|
for (const it of items) {
|
||
|
|
const s = it.status || "(empty)";
|
||
|
|
c[s] = (c[s] || 0) + 1;
|
||
|
|
}
|
||
|
|
return c;
|
||
|
|
}, [items]);
|
||
|
|
|
||
|
|
const filtered = useMemo(() => {
|
||
|
|
let out = items;
|
||
|
|
if (filter !== "all") {
|
||
|
|
out = out.filter((it) => it.status === filter);
|
||
|
|
}
|
||
|
|
const qq = q.trim().toLowerCase();
|
||
|
|
if (qq) {
|
||
|
|
out = out.filter((it) => {
|
||
|
|
const d = it.expand?.director;
|
||
|
|
const co = it.expand?.company;
|
||
|
|
return [
|
||
|
|
it.id, it.sent_to, it.sign_token,
|
||
|
|
d?.first_names, d?.surname, d?.email,
|
||
|
|
co?.enterprise_no, co?.enterprise_name, co?.company_email,
|
||
|
|
].some((v) => v && String(v).toLowerCase().includes(qq));
|
||
|
|
});
|
||
|
|
}
|
||
|
|
return out.sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime());
|
||
|
|
}, [items, filter, q]);
|
||
|
|
|
||
|
|
if (loading) return <div className="text-sm text-muted">Loading appointments…</div>;
|
||
|
|
if (error) return <div className="text-sm text-danger">Error: {error}</div>;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="space-y-4">
|
||
|
|
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||
|
|
<h2 className="text-lg font-semibold text-fg">All appointments</h2>
|
||
|
|
<input
|
||
|
|
value={q}
|
||
|
|
onChange={(e) => setQ(e.target.value)}
|
||
|
|
placeholder="Search director, company, email…"
|
||
|
|
className="bg-surface border border-border rounded px-3 py-2 text-sm text-fg placeholder-muted focus:outline-none focus:border-accent w-full md:w-72"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="flex flex-wrap gap-2 text-xs">
|
||
|
|
{(["all", "pending", "mail_sent", "signature_received", "mail_sent_to_all"] as const).map((k) => (
|
||
|
|
<button
|
||
|
|
key={k}
|
||
|
|
onClick={() => setFilter(k)}
|
||
|
|
className={
|
||
|
|
"px-3 py-1.5 rounded-full border font-semibold transition-colors " +
|
||
|
|
(filter === k
|
||
|
|
? "bg-accent border-accent text-accent-on"
|
||
|
|
: "bg-surface border-border text-fg hover:border-accent")
|
||
|
|
}
|
||
|
|
>
|
||
|
|
{k === "all" ? "All" : STATUS_LABELS[k as Status].split(" — ")[0]}
|
||
|
|
<span className="ml-1.5 text-muted">{counts[k] || 0}</span>
|
||
|
|
</button>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{filtered.length === 0 ? (
|
||
|
|
<div className="bg-surface border border-border rounded-lg p-8 text-center text-sm text-muted">
|
||
|
|
No appointments match this filter.
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<div className="bg-surface border border-border rounded-lg overflow-x-auto">
|
||
|
|
<table className="w-full text-sm">
|
||
|
|
<thead>
|
||
|
|
<tr className="border-b border-border bg-surface-warm">
|
||
|
|
<th className="text-left px-4 py-3 text-xs font-semibold text-muted uppercase tracking-wide">Director</th>
|
||
|
|
<th className="text-left px-4 py-3 text-xs font-semibold text-muted uppercase tracking-wide">Company</th>
|
||
|
|
<th className="text-left px-4 py-3 text-xs font-semibold text-muted uppercase tracking-wide">Status</th>
|
||
|
|
<th className="text-left px-4 py-3 text-xs font-semibold text-muted uppercase tracking-wide hidden lg:table-cell">Sent to</th>
|
||
|
|
<th className="text-left px-4 py-3 text-xs font-semibold text-muted uppercase tracking-wide hidden md:table-cell">Sent at</th>
|
||
|
|
<th className="text-left px-4 py-3 text-xs font-semibold text-muted uppercase tracking-wide hidden xl:table-cell">Signed letter</th>
|
||
|
|
<th className="text-right px-4 py-3 text-xs font-semibold text-muted uppercase tracking-wide">Actions</th>
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody className="divide-y divide-border-soft">
|
||
|
|
{filtered.map((a) => {
|
||
|
|
const d = a.expand?.director;
|
||
|
|
const co = a.expand?.company;
|
||
|
|
const directorName = [d?.first_names, d?.surname].filter(Boolean).join(" ") || "—";
|
||
|
|
const companyName = co?.enterprise_name || co?.enterprise_no || "—";
|
||
|
|
const sig = !!a.signature;
|
||
|
|
const letter = !!a.letter;
|
||
|
|
const status = (a.status || "pending") as Status;
|
||
|
|
return (
|
||
|
|
<tr key={a.id} className="hover:bg-surface-warm transition-colors">
|
||
|
|
<td className="px-4 py-3">
|
||
|
|
<div className="font-semibold text-fg">{directorName}</div>
|
||
|
|
{d?.email && <div className="text-xs text-muted">{d.email}</div>}
|
||
|
|
</td>
|
||
|
|
<td className="px-4 py-3">
|
||
|
|
<div className="text-fg">{companyName}</div>
|
||
|
|
{co?.enterprise_no && <div className="text-xs text-muted font-mono">{co.enterprise_no}</div>}
|
||
|
|
</td>
|
||
|
|
<td className="px-4 py-3">
|
||
|
|
<StatusBadge variant={STATUS_BADGE[status]}>
|
||
|
|
{STATUS_LABELS[status]}
|
||
|
|
</StatusBadge>
|
||
|
|
{a.missing_emails && (
|
||
|
|
<div className="text-xs text-danger mt-1" title={a.missing_emails}>
|
||
|
|
Missing: {a.missing_emails}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</td>
|
||
|
|
<td className="px-4 py-3 hidden lg:table-cell">
|
||
|
|
<span className="font-mono text-xs text-fg-2 break-all">{a.sent_to || "—"}</span>
|
||
|
|
</td>
|
||
|
|
<td className="px-4 py-3 hidden md:table-cell">
|
||
|
|
<div className="text-fg tabular-nums text-xs">{fmtDate(a.sent_at)}</div>
|
||
|
|
<div className="text-xs text-muted">{relTime(a.sent_at)}</div>
|
||
|
|
</td>
|
||
|
|
<td className="px-4 py-3 hidden xl:table-cell">
|
||
|
|
<div className="flex gap-2 text-xs">
|
||
|
|
<span className={sig ? "text-success" : "text-muted"}>
|
||
|
|
{sig ? "✓ Signature" : "— Signature"}
|
||
|
|
</span>
|
||
|
|
<span className={letter ? "text-success" : "text-muted"}>
|
||
|
|
{letter ? "✓ Letter" : "— Letter"}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
</td>
|
||
|
|
<td className="px-4 py-3">
|
||
|
|
<div className="flex flex-wrap gap-1.5 justify-end">
|
||
|
|
{sig && (
|
||
|
|
<a
|
||
|
|
href={`/api/resolutions/${encodeURIComponent(a.id)}/document?collection=ysterbaard_director_resignations&field=signature&download=false`}
|
||
|
|
target="_blank"
|
||
|
|
rel="noopener noreferrer"
|
||
|
|
className="px-2 py-1 text-xs font-semibold rounded border border-border bg-surface text-fg hover:border-accent hover:text-accent no-underline transition-colors"
|
||
|
|
title="Open the signed resignation you uploaded"
|
||
|
|
>
|
||
|
|
View signed
|
||
|
|
</a>
|
||
|
|
)}
|
||
|
|
<label
|
||
|
|
className={`px-2 py-1 text-xs font-semibold rounded border cursor-pointer transition-colors ${
|
||
|
|
sig
|
||
|
|
? "border-border bg-surface text-fg hover:border-accent hover:text-accent"
|
||
|
|
: "border-accent bg-accent text-accent-on hover:opacity-90"
|
||
|
|
} ${busyId === a.id ? "opacity-40 pointer-events-none" : ""}`}
|
||
|
|
title="Attach the signed letter returned by the director (PDF)"
|
||
|
|
>
|
||
|
|
{sig ? "Replace signed PDF" : "Upload signed PDF"}
|
||
|
|
<input
|
||
|
|
type="file"
|
||
|
|
accept=".pdf,application/pdf"
|
||
|
|
className="hidden"
|
||
|
|
onChange={(e) => {
|
||
|
|
const f = e.target.files?.[0];
|
||
|
|
e.target.value = "";
|
||
|
|
if (f) void handleUploadSigned(a, f);
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
</label>
|
||
|
|
<button
|
||
|
|
onClick={() => handleComplete(a)}
|
||
|
|
disabled={busyId === a.id || status === "mail_sent_to_all"}
|
||
|
|
className="px-2 py-1 text-xs font-semibold rounded border border-border bg-surface text-success hover:border-accent disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||
|
|
title="Mark this resignation complete"
|
||
|
|
>
|
||
|
|
Complete
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
onClick={() => handleResend(a, "initial")}
|
||
|
|
disabled={busyId === a.id}
|
||
|
|
className="px-2 py-1 text-xs font-semibold rounded border border-border bg-surface text-fg hover:border-accent hover:text-accent disabled:opacity-40 transition-colors"
|
||
|
|
title="Resend the first (initial consent) email"
|
||
|
|
>
|
||
|
|
Resend 1st
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
onClick={() => handleResend(a, "signed")}
|
||
|
|
disabled={busyId === a.id || !sig}
|
||
|
|
className="px-2 py-1 text-xs font-semibold rounded border border-border bg-surface text-fg hover:border-accent hover:text-accent disabled:opacity-40 transition-colors"
|
||
|
|
title={sig ? "Resend the second (signed confirmation) email" : "No signature yet — cannot resend"}
|
||
|
|
>
|
||
|
|
Resend 2nd
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
onClick={() => handleCancel(a)}
|
||
|
|
disabled={busyId === a.id}
|
||
|
|
className="px-2 py-1 text-xs font-semibold rounded border border-danger/40 text-danger bg-surface hover:bg-danger/10 disabled:opacity-40 transition-colors"
|
||
|
|
title="Cancel and delete this appointment"
|
||
|
|
>
|
||
|
|
Cancel
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
{actionError && (
|
||
|
|
<div className="text-sm text-danger bg-danger/10 border border-danger/30 rounded px-3 py-2">
|
||
|
|
{actionError}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|