485 lines
20 KiB
TypeScript
485 lines
20 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
// components/DirectorAppointmentTab.tsx
|
||
|
|
// Table of all director appointments + "Acceptance Letter" modal
|
||
|
|
// with a pre-send validation gate (per SJ 2026-08-18): the modal shows
|
||
|
|
// the resolved recipient list (new director + linked directors + company
|
||
|
|
// email) and flags any missing/invalid emails BEFORE sending. The user
|
||
|
|
// can Cancel, Edit (jump to the edit screen), or Proceed anyway.
|
||
|
|
|
||
|
|
import { useEffect, useState, useCallback } from "react";
|
||
|
|
import type { YsterbaardDirectorAppointment } from "@/lib/types";
|
||
|
|
import SearchableSelect, { type SearchableSelectRecord } from "./SearchableSelect";
|
||
|
|
|
||
|
|
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",
|
||
|
|
};
|
||
|
|
|
||
|
|
// PB record shapes used by the picker. Keeps the search call and the
|
||
|
|
// `find(...).label` lookups type-safe without sprinkling `any`.
|
||
|
|
type DirectorRow = SearchableSelectRecord & {
|
||
|
|
first_names?: string;
|
||
|
|
surname?: string;
|
||
|
|
email?: string;
|
||
|
|
telephone?: string;
|
||
|
|
};
|
||
|
|
type CompanyRow = SearchableSelectRecord & {
|
||
|
|
enterprise_name?: string;
|
||
|
|
company_email?: string;
|
||
|
|
};
|
||
|
|
|
||
|
|
const STATUS_VARIANTS: Record<Status, "muted" | "warn" | "success" | "info"> = {
|
||
|
|
pending: "muted",
|
||
|
|
mail_sent: "warn",
|
||
|
|
signature_received: "success",
|
||
|
|
mail_sent_to_all: "info",
|
||
|
|
};
|
||
|
|
|
||
|
|
interface Appointment extends YsterbaardDirectorAppointment {
|
||
|
|
_director_name?: string;
|
||
|
|
_company_name?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Surname-or-id search fields match the LinkEntityModal convention.
|
||
|
|
// Email/telephone are intentionally NOT in the search filter so that a
|
||
|
|
// missing email doesn't make a director unsearchable.
|
||
|
|
|
||
|
|
type RecipientStatus = "valid" | "missing" | "invalid";
|
||
|
|
interface Recipient {
|
||
|
|
key: string;
|
||
|
|
label: string;
|
||
|
|
email: string;
|
||
|
|
role: string;
|
||
|
|
status: RecipientStatus;
|
||
|
|
}
|
||
|
|
interface ValidationReport {
|
||
|
|
preview: boolean;
|
||
|
|
initial: Recipient[];
|
||
|
|
distro: Recipient[];
|
||
|
|
problems: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
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>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function StatusDot({ status }: { status: RecipientStatus }) {
|
||
|
|
if (status === "valid") {
|
||
|
|
return (
|
||
|
|
<span className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-green-100 text-green-700 text-xs font-bold flex-shrink-0" title="Valid email">✓</span>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
if (status === "missing") {
|
||
|
|
return (
|
||
|
|
<span className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-amber-100 text-amber-700 text-xs font-bold flex-shrink-0" title="Missing email">!</span>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
return (
|
||
|
|
<span className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-red-100 text-red-700 text-xs font-bold flex-shrink-0" title="Invalid format">x</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 — we hold the FULL record (not just an id) so the
|
||
|
|
// validation gate can read email/telephone without an extra PB round-trip.
|
||
|
|
const [selectedDirector, setSelectedDirector] = useState<DirectorRow | null>(null);
|
||
|
|
const [selectedCompany, setSelectedCompany] = useState<CompanyRow | null>(null);
|
||
|
|
|
||
|
|
// Validation gate
|
||
|
|
const [report, setReport] = useState<ValidationReport | null>(null);
|
||
|
|
const [previewing, setPreviewing] = useState(false);
|
||
|
|
|
||
|
|
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 = () => {
|
||
|
|
setShowModal(true);
|
||
|
|
setSendError(null);
|
||
|
|
setSelectedDirector(null);
|
||
|
|
setSelectedCompany(null);
|
||
|
|
setReport(null);
|
||
|
|
// Director + company lists are fetched on-demand by the SearchableSelect
|
||
|
|
// when the user opens the dropdown — no upfront load.
|
||
|
|
};
|
||
|
|
|
||
|
|
// Fetch the full company record so we can show the company email in the
|
||
|
|
// validation panel.
|
||
|
|
const fetchCompanyDetails = async (id: string) => {
|
||
|
|
try {
|
||
|
|
const res = await fetch(`/api/pb/records/get?collection=ysterbaard_companies&id=${id}&fields=id,enterprise_name,company_email`);
|
||
|
|
if (res.ok) return res.json();
|
||
|
|
} catch { /* ignore */ }
|
||
|
|
return null;
|
||
|
|
};
|
||
|
|
const fetchDirectorDetails = async (id: string) => {
|
||
|
|
try {
|
||
|
|
const res = await fetch(`/api/pb/records/get?collection=ysterbaard_directors&id=${id}&fields=id,first_names,surname,email`);
|
||
|
|
if (res.ok) return res.json();
|
||
|
|
} catch { /* ignore */ }
|
||
|
|
return null;
|
||
|
|
};
|
||
|
|
|
||
|
|
const handlePreview = async () => {
|
||
|
|
if (!selectedDirector || !selectedCompany) {
|
||
|
|
setSendError("Please select a company and a director first.");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
setPreviewing(true);
|
||
|
|
setSendError(null);
|
||
|
|
setReport(null);
|
||
|
|
|
||
|
|
try {
|
||
|
|
// Build the local validation report — same shape the server would
|
||
|
|
// return, but computed client-side so we don't round-trip to the
|
||
|
|
// server just to render the gate. The server re-validates on POST.
|
||
|
|
const director = selectedDirector;
|
||
|
|
const company = selectedCompany;
|
||
|
|
|
||
|
|
// Re-fetch to get the freshest email/company_email (in case the user
|
||
|
|
// edited the record after it was picked in the typeahead).
|
||
|
|
const dirDetails = await fetchDirectorDetails(director.id);
|
||
|
|
const coDetails = await fetchCompanyDetails(company.id);
|
||
|
|
|
||
|
|
const emailRx = /^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$/;
|
||
|
|
const classify = (e: string | undefined | null): RecipientStatus => {
|
||
|
|
const v = (e ?? "").trim();
|
||
|
|
if (!v) return "missing";
|
||
|
|
return emailRx.test(v) ? "valid" : "invalid";
|
||
|
|
};
|
||
|
|
|
||
|
|
// Fetch linked directors for the company
|
||
|
|
let linkedDirectors: Array<{ id: string; first_names?: string; surname?: string; email?: string }> = [];
|
||
|
|
try {
|
||
|
|
const res = await fetch("/api/pb/records/list?collection=ysterbaard_company_directors&perPage=200&filter=" + encodeURIComponent(`company = '${company.id}'`) + "&expand=director&fields=expand.director.id,expand.director.first_names,expand.director.surname,expand.director.email");
|
||
|
|
if (res.ok) {
|
||
|
|
const data = await res.json();
|
||
|
|
linkedDirectors = (data.items || [])
|
||
|
|
.map((j: { expand?: { director?: { id: string; first_names?: string; surname?: string; email?: string } } }) => j.expand?.director)
|
||
|
|
.filter(Boolean);
|
||
|
|
}
|
||
|
|
} catch { /* ignore — junction may not exist */ }
|
||
|
|
|
||
|
|
const dirLabel = [director.first_names, director.surname].filter(Boolean).join(" ") || director.id;
|
||
|
|
const initial: Recipient[] = [{
|
||
|
|
key: "new_director",
|
||
|
|
label: `New director: ${dirLabel}`,
|
||
|
|
email: dirDetails?.email ?? director.email ?? "",
|
||
|
|
role: "to",
|
||
|
|
status: classify(dirDetails?.email ?? director.email),
|
||
|
|
}];
|
||
|
|
|
||
|
|
const distro: Recipient[] = [
|
||
|
|
...linkedDirectors.map((d) => ({
|
||
|
|
key: `director:${d.id}`,
|
||
|
|
label: `Director: ${[d.first_names, d.surname].filter(Boolean).join(" ") || d.id}`,
|
||
|
|
email: d.email ?? "",
|
||
|
|
role: "to",
|
||
|
|
status: classify(d.email),
|
||
|
|
})),
|
||
|
|
{
|
||
|
|
key: "company_email",
|
||
|
|
label: `Company: ${coDetails?.enterprise_name ?? company.enterprise_name ?? company.id}`,
|
||
|
|
email: coDetails?.company_email ?? "",
|
||
|
|
role: "to",
|
||
|
|
status: classify(coDetails?.company_email),
|
||
|
|
},
|
||
|
|
];
|
||
|
|
|
||
|
|
const all = [...initial, ...distro];
|
||
|
|
const problems = all.filter((r) => r.status !== "valid").length;
|
||
|
|
|
||
|
|
setReport({ preview: true, initial, distro, problems });
|
||
|
|
} catch (err: unknown) {
|
||
|
|
setSendError(err instanceof Error ? err.message : "Validation failed.");
|
||
|
|
} finally {
|
||
|
|
setPreviewing(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleSend = async (proceedAnyway: boolean) => {
|
||
|
|
if (!selectedDirector || !selectedCompany) return;
|
||
|
|
setSending(true);
|
||
|
|
setSendError(null);
|
||
|
|
|
||
|
|
const director = selectedDirector;
|
||
|
|
const company = selectedCompany;
|
||
|
|
|
||
|
|
try {
|
||
|
|
const res = await fetch("/api/appointments", {
|
||
|
|
method: "POST",
|
||
|
|
headers: { "Content-Type": "application/json" },
|
||
|
|
body: JSON.stringify({
|
||
|
|
director_id: director.id,
|
||
|
|
company_id: company.id,
|
||
|
|
director_email: director.email || "SJ@Dyfins.com",
|
||
|
|
director_cell: director.telephone || "",
|
||
|
|
director_name: director.first_names || "",
|
||
|
|
director_surname: director.surname || "",
|
||
|
|
company_name: company.enterprise_name || "",
|
||
|
|
proceed_anyway: proceedAnyway,
|
||
|
|
}),
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!res.ok) {
|
||
|
|
const data = await res.json().catch(() => ({}));
|
||
|
|
// 422 is the validation failure — surface the report so the user can fix
|
||
|
|
if (res.status === 422 && data.initial && data.distro) {
|
||
|
|
setReport({
|
||
|
|
preview: false,
|
||
|
|
initial: data.initial,
|
||
|
|
distro: data.distro,
|
||
|
|
problems: (data.initial.length + data.distro.length),
|
||
|
|
});
|
||
|
|
throw new Error(data.error || "Server-side validation failed.");
|
||
|
|
}
|
||
|
|
throw new Error(data.error || `HTTP ${res.status}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
// 200 does not guarantee delivery — the route returns 502 with
|
||
|
|
// { ok: false, warning: "Letter saved but email failed" } when the
|
||
|
|
// mail itself fails. Never report success on a non-OK response.
|
||
|
|
const sent = await res.json().catch(() => null);
|
||
|
|
if (sent?.ok === false) {
|
||
|
|
throw new Error(
|
||
|
|
(sent?.error as string) ||
|
||
|
|
(sent?.warning as string) ||
|
||
|
|
"The letter was saved but the email could not be sent."
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
setShowModal(false);
|
||
|
|
setReport(null);
|
||
|
|
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 removed 2026-08-19 — replaced by AppointmentTable below in ResolutionExplorer
|
||
|
|
which has filter chips, search, and Resend/Cancel action buttons. */}
|
||
|
|
|
||
|
|
{/* 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); setReport(null); } }}
|
||
|
|
>
|
||
|
|
<div className="bg-surface border border-border rounded-xl shadow-2xl w-full max-w-md flex flex-col max-h-[90vh]">
|
||
|
|
<div className="border-b border-border px-6 py-4 flex-shrink-0">
|
||
|
|
<h2 className="text-lg font-semibold text-fg">Send Consent to Act as Director</h2>
|
||
|
|
<p className="text-sm text-muted mt-0.5">
|
||
|
|
{report ? "Review the recipient list before sending." : "Select a company and director to send the consent letter for signature."}
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="p-6 space-y-4 overflow-y-auto flex-1">
|
||
|
|
{/* Always show the picker so the user can change selection */}
|
||
|
|
<div className="grid grid-cols-2 gap-3">
|
||
|
|
<SearchableSelect
|
||
|
|
id="appt-company"
|
||
|
|
label="Company"
|
||
|
|
collection="ysterbaard_companies"
|
||
|
|
fields={["id", "enterprise_name", "company_email", "enterprise_no"]}
|
||
|
|
searchFields={["enterprise_name", "enterprise_no"]}
|
||
|
|
placeholder="Search companies..."
|
||
|
|
value={selectedCompany?.id ?? ""}
|
||
|
|
onChange={(r) => { setSelectedCompany((r as CompanyRow | null)); setReport(null); }}
|
||
|
|
renderLabel={(r) => (r.enterprise_name as string) || (r.id as string)}
|
||
|
|
renderSub={(r) => (r.enterprise_no as string) || (r.company_email as string) || ""}
|
||
|
|
required
|
||
|
|
/>
|
||
|
|
<SearchableSelect
|
||
|
|
id="appt-director"
|
||
|
|
label="Director"
|
||
|
|
collection="ysterbaard_directors"
|
||
|
|
fields={["id", "first_names", "surname", "email", "telephone", "id_no"]}
|
||
|
|
searchFields={["first_names", "surname", "id_no"]}
|
||
|
|
placeholder="Search directors..."
|
||
|
|
value={selectedDirector?.id ?? ""}
|
||
|
|
onChange={(r) => { setSelectedDirector((r as DirectorRow | null)); setReport(null); }}
|
||
|
|
renderLabel={(r) => [r.first_names as string, r.surname as string].filter(Boolean).join(" ") || (r.id as string)}
|
||
|
|
renderSub={(r) => (r.email as string) || (r.telephone as string) || (r.id_no as string) || ""}
|
||
|
|
required
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Validation panel */}
|
||
|
|
{report && (
|
||
|
|
<div className="border border-border rounded-lg overflow-hidden">
|
||
|
|
<div className={`px-4 py-2.5 text-sm font-semibold flex items-center justify-between ${
|
||
|
|
report.problems === 0
|
||
|
|
? "bg-green-50 text-green-800 border-b border-green-200"
|
||
|
|
: "bg-amber-50 text-amber-900 border-b border-amber-200"
|
||
|
|
}`}>
|
||
|
|
<span>
|
||
|
|
{report.problems === 0
|
||
|
|
? "All recipients look good"
|
||
|
|
: `${report.problems} recipient${report.problems !== 1 ? "s" : ""} need attention`}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="p-3 space-y-3 text-sm">
|
||
|
|
<div>
|
||
|
|
<p className="text-xs font-semibold text-muted uppercase tracking-wide mb-1.5">Initial send</p>
|
||
|
|
<div className="space-y-1.5">
|
||
|
|
{report.initial.map((r) => (
|
||
|
|
<div key={r.key} className="flex items-center gap-2">
|
||
|
|
<StatusDot status={r.status} />
|
||
|
|
<span className="text-fg-2 text-xs flex-1">{r.label}</span>
|
||
|
|
<span className={r.status === "valid" ? "text-fg-2 text-xs font-mono" : "text-amber-800 text-xs font-mono"}>
|
||
|
|
{r.status === "missing" ? "(missing)" : r.status === "invalid" ? r.email : r.email}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div>
|
||
|
|
<p className="text-xs font-semibold text-muted uppercase tracking-wide mb-1.5">
|
||
|
|
After director signs (sent to all)
|
||
|
|
</p>
|
||
|
|
<div className="space-y-1.5">
|
||
|
|
{report.distro.map((r) => (
|
||
|
|
<div key={r.key} className="flex items-center gap-2">
|
||
|
|
<StatusDot status={r.status} />
|
||
|
|
<span className="text-fg-2 text-xs flex-1">{r.label}</span>
|
||
|
|
<span className={r.status === "valid" ? "text-fg-2 text-xs font-mono" : "text-amber-800 text-xs font-mono"}>
|
||
|
|
{r.status === "missing" ? "(missing)" : r.status === "invalid" ? r.email : r.email}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{report.problems > 0 && (
|
||
|
|
<p className="text-xs text-amber-900 bg-amber-100 border border-amber-200 rounded px-3 py-2">
|
||
|
|
<strong>Heads up:</strong> recipients flagged above will be skipped when the signed letter is distributed.
|
||
|
|
You can fix the records first, or proceed anyway and email them manually.
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</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-between gap-3 flex-shrink-0">
|
||
|
|
{/* Left side: Cancel / Edit records */}
|
||
|
|
<div className="flex gap-2">
|
||
|
|
<button
|
||
|
|
onClick={() => { setShowModal(false); setReport(null); }}
|
||
|
|
className="px-4 py-2 text-sm font-semibold text-muted hover:text-fg transition-colors"
|
||
|
|
>
|
||
|
|
Cancel
|
||
|
|
</button>
|
||
|
|
{report && report.problems > 0 && (
|
||
|
|
<a
|
||
|
|
href={selectedCompany ? `/companies/${selectedCompany.id}` : "/companies"}
|
||
|
|
target="_blank"
|
||
|
|
rel="noopener"
|
||
|
|
className="px-4 py-2 text-sm font-semibold text-accent hover:text-accent-hover transition-colors"
|
||
|
|
>
|
||
|
|
Fix records first →
|
||
|
|
</a>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Right side: Validate / Send / Proceed anyway */}
|
||
|
|
{!report ? (
|
||
|
|
<button
|
||
|
|
onClick={handlePreview}
|
||
|
|
disabled={!selectedDirector || !selectedCompany || previewing}
|
||
|
|
className="px-4 py-2 bg-accent text-accent-on rounded font-semibold text-sm disabled:opacity-40 hover:opacity-90 transition-opacity"
|
||
|
|
>
|
||
|
|
{previewing ? "Validating..." : "Validate recipients"}
|
||
|
|
</button>
|
||
|
|
) : report.problems === 0 ? (
|
||
|
|
<button
|
||
|
|
onClick={() => handleSend(false)}
|
||
|
|
disabled={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>
|
||
|
|
) : (
|
||
|
|
<button
|
||
|
|
onClick={() => handleSend(true)}
|
||
|
|
disabled={sending}
|
||
|
|
className="px-4 py-2 bg-amber-600 text-white rounded font-semibold text-sm disabled:opacity-40 hover:bg-amber-700 transition-colors"
|
||
|
|
title="Send anyway — the flagged recipients will be skipped when the signed letter is distributed"
|
||
|
|
>
|
||
|
|
{sending ? "Sending..." : "Proceed anyway"}
|
||
|
|
</button>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|