"use client"; /* Approving or declining straight from the email, on whatever device opened it. * * This is the centred page shell rather than the phone column: a manager reaches it from a mail * client, as often on a desktop as on a ward phone, and dropping them into an app chrome they * never signed into would be a strange thing to meet. * * Nothing has happened when this page loads. That is the point of the design: the link renders, * the button decides. */ import { useState } from "react"; import { Err, MyShell, h1, kicker, lead, primary } from "@/components/my"; import { DECLINE_REASONS } from "@/lib/staffreq"; import type { ReqLine } from "@/lib/staffdata"; type Data = { code: string; subjectName: string; subjectMeta: string; /** The whole ask, in the order it was entered. */ lines: ReqLine[]; /** linesSummary() — the one-liner every other screen leads with. */ summary: string; reason: string; note: string; raisedByName: string; facility: string; }; const INK = "#201e1d"; const N600 = "var(--color-neutral-600)"; const N700 = "var(--color-neutral-700)"; const ACCENT_700 = "var(--color-accent-700)"; /** The garments, one row each. Deliberately drawn here rather than borrowed from the staff app's * own list: this page is the centred desktop shell, not the phone column, and its type sizes and * rules are a size up from everything in components/staffui. What it must match is the *content* — * the same order, the same strike-through on a refusal, the same reason against it. */ function Lines({ lines }: { lines: readonly ReqLine[] }) { return (
{lines.map((l) => { const off = l.status === "declined"; return (
{l.qty} × {l.item} — {l.size}
{off && (
Declined{l.declineReason ? ` — ${l.declineReason}` : ""}
)}
); })}
); } export default function ApproveByLink({ token, data, decided, status, declineReason }: { token: string; data: Data; decided: boolean; status: string; declineReason: string | null; }) { const [done, setDone] = useState(null); const [declining, setDeclining] = useState(false); const [busy, setBusy] = useState(false); const [err, setErr] = useState(""); async function decide(action: "approve" | "decline", reason?: string) { setBusy(true); setErr(""); const r = await fetch("/api/staff/decide", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ token, action, reason }), }).catch(() => null); const j = await r?.json().catch(() => ({})); setBusy(false); // Already decided — the other link in the email was used, or this one twice. The route says // so with `already`, and the server render of this page shows the decision that stands, so // reload into that rather than sit on a red error with two live buttons under it. if (j?.already) { window.location.reload(); return; } if (!r || !r.ok) { setErr(j?.error || "That didn’t work."); return; } setDone({ approved: action === "approve", reason, notified: !!j?.notified }); } if (done) { return (
ThreadCount

{done.approved ? "Approved." : "Declined."}

{/* "Has been told" only when an email actually left — a wearer with no account or a site with no mail hears nothing from this, and saying otherwise is how they wait a fortnight. */} {done.approved ? `${done.notified ? `${data.subjectName.split(" ")[0]} has been told, and` : `${data.subjectName.split(" ")[0]} hasn’t been emailed — it’s on their record in the app — and`} ${data.lines.length > 1 ? "all of it is" : "it’s"} with the linen room now.` : `${done.notified ? `${data.subjectName.split(" ")[0]} has been told` : `${data.subjectName.split(" ")[0]} hasn’t been emailed, so mention it to them`} — ${(done.reason || "").toLowerCase()}.`}

You can close this. Nothing else is waiting on you here.

); } if (decided) { return (
{data.facility}

Already decided.

{status === "declined" ? `This request was declined${declineReason ? ` — ${declineReason.toLowerCase()}` : ""}.` : "This request has already been approved and is with the linen room."}

{/* Which garments went and which didn't. A manager coming back to a request they settled on their phone deserves the same answer here as the wearer gets on their order — the alternative is a page that says "approved" over an ask where a third of it was refused. */}

Approval links work once. Open the app if you need to look at it again.

); } return (
{data.facility} · {data.code}

{data.subjectName} needs your approval.

{data.subjectMeta &&

{data.subjectMeta}

}
{data.summary}
{data.reason &&
{data.reason}
} {data.note &&

{data.note}

} {data.raisedByName && (

Raised for them by {data.raisedByName}.

)}
{/* The summary above is a count and three names; a manager about to approve four garments needs to see the four. One garment needs no list — the heading already is one. */} {data.lines.length > 1 && } {err &&
{err}
} {declining ? (
Why are you declining?

{data.lines.length > 1 ? `This turns down all ${data.lines.length} garments. ${data.subjectName.split(" ")[0]} is told which reason you picked.` : `${data.subjectName.split(" ")[0]} is told which one you picked.`}

{DECLINE_REASONS.map((r) => ( ))}
) : (
)} {/* The one thing this page cannot do. Signed in, a manager approves the tunic and turns down the fleece on the same request; from an email link there is no signed-in person to check a per-garment decision against, so it is deliberately the whole ask either way. A manager who wants part of it has to be told where that lives rather than left approving three garments to get one of them through. */}

Nothing has been decided yet — this page just shows you the request. The link works once, and it settles {data.lines.length > 1 ? "the whole request" : "it"} one way or the other. {data.lines.length > 1 && " To approve some garments and not others, open the app."}

); }