Workflow-App/Workflow/components/ResignationTable.tsx

386 lines
17 KiB
TypeScript
Raw Permalink Normal View History

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