import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Linkified from "@/shared/Linkified";
import axios from "axios";
import { TEMPLATE_PATTERN } from "@/shared/status";
import { useTemplates, TEMPLATE_KIND_INVOICE, TEMPLATE_KIND_RESCHEDULE, patternBadgeClass, patternLabel } from "./MessageTemplates";
import { notifyError } from "../shared/toast";
import ExternalAnswerDialog, { ago, dueLabel } from "./shared/ExternalAnswerDialog";
import HandedOverFetch from "./shared/HandedOverFetch";
import useTabVisible from "../shared/useTabVisible";
import BookingDocuments from "@/shared/BookingDocuments";
import { CONVERSATION_STATUS, CONVERSATION_WAITING, CHATROOM_STATUS_LABEL, MESSAGE_CHANNEL_LABEL } from "@/shared/status";
import useStickToLast from "@/shared/useStickToLast";
import NewMessages from "@/shared/NewMessages";
import Modal from "@/shared/Modal";

const STATUS_LABEL = {
    [CONVERSATION_STATUS.RUNNING]:              "Running",
    [CONVERSATION_STATUS.WAITING_INPUT]:        "Waiting · input",
    [CONVERSATION_STATUS.WAITING_API]:          "Waiting · api",
    [CONVERSATION_STATUS.WAITING_ACK]:          "Waiting · ack",
    [CONVERSATION_STATUS.WAITING_EVIDENCE]:     "Waiting · evidence",
    [CONVERSATION_STATUS.WAITING_VERIFICATION]: "Waiting · verify",
    [CONVERSATION_STATUS.WAITING_APPOINTMENT]:  "Waiting · meeting",
    // 10 upwards were never added here, and the row falls back to the raw NUMBER — so a run parked
    // on a signature, a form or an outside body showed a staffer "12" where every other row read a
    // sentence. The same gap this file's own comments record for the room statuses, one map along.
    // (Arfu, 18/09/2026)
    [CONVERSATION_STATUS.WAITING_SIGNATURE]:     "Waiting · signature",
    [CONVERSATION_STATUS.WAITING_FORM]:          "Waiting · form",
    [CONVERSATION_STATUS.WAITING_EXTERNAL]:      "Waiting · outside body",
    [CONVERSATION_STATUS.WAITING_SCHEDULE]:      "Waiting · not due yet",
    [CONVERSATION_STATUS.WAITING_EXTERNAL_FORM]: "Waiting · their form",
    [CONVERSATION_STATUS.WAITING_CALCULATION]:   "Waiting · sums",
    [CONVERSATION_STATUS.WAITING_DOCUMENT]:      "Waiting · document",
    [CONVERSATION_STATUS.WAITING_MATCH]:         "Waiting · somebody to take it",
    [CONVERSATION_STATUS.WAITING_OTHER_SIDE]:    "Waiting · the other side",
    [CONVERSATION_STATUS.DONE]:                 "Done",
    [CONVERSATION_STATUS.FAILED]:               "Failed",
};

const STATUS_CLASS = (s) => {
    if (s === CONVERSATION_STATUS.DONE) return "pt-b-green";
    if (s === CONVERSATION_STATUS.FAILED) return "pt-b-red";
    if (s === CONVERSATION_STATUS.RUNNING) return "pt-b-blue";
    if (CONVERSATION_WAITING.includes(s)) return "pt-b-amber";
    return "pt-b-grey";
};

// Keyed on ChatroomMessage::ACTOR_*. `contact` became `user` on 2026-08-13 with the anonymous
// identity it named; until this was fixed every customer message missed the map and fell through
// to the grey "unknown actor" badge.
const ACTOR_COLORS = {
    engine:    { fg: "var(--blush)", label: "engine" },
    user:      { fg: "var(--green)", label: "customer" },
    org_staff: { fg: "var(--amber)", label: "org staff" },
    admin:     { fg: "var(--red)",   label: "admin" },
    // An outside system the org connected, speaking for itself (FP-T2).
    integration: { fg: "var(--brand-700, #8a2540)", label: "integration" },
};

function ActorTag({ type }) {
    const c = ACTOR_COLORS[type] || { fg: "var(--muted)", label: type || "—" };
    return (
        <span style={{ color: c.fg, fontSize: 10.5, fontWeight: 700, fontFamily: "var(--mono)", textTransform: "uppercase", letterSpacing: ".06em" }}>
            {c.label}
        </span>
    );
}

// The same wording the transcript and the list already use for a timestamp, so an engagement's tab
// reads like everything around it.
const fmtWhen = (v) => v
    ? new Date(v).toLocaleString("en-HK", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" })
    : "\u2014";

const fmtBytes = (n = 0) => {
    if (!n) return "0 B";
    const u = ["B", "KB", "MB", "GB"];
    let i = 0, v = n;
    while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; }
    return `${v.toFixed(v >= 10 || i === 0 ? 0 : 1)} ${u[i]}`;
};

function describePayload(p) {
    if (!p || typeof p !== "object") return "";
    if (p.text) return p.text;
    if (p.type === "user_input") return `User input: ${JSON.stringify(p.value)}`;
    if (p.type === "ack") return "Acknowledgement received";
    if (p.type === "evidence") {
        // What they actually sent, not the raw object. A staffer judging proof reads this line
        // before deciding, and `{"text":"proof","attachments":[…]}` tells them nothing.
        const files = Array.isArray(p.payload?.attachments) ? p.payload.attachments : [];
        const note = p.payload?.text || "";
        const said = files.length ? `${files.length} file${files.length === 1 ? "" : "s"}` : "";

        return "Evidence: " + ([note, said].filter(Boolean).join(" · ") || "(empty)");
    }
    if (p.type === "verification") return `Verification ok=${p.ok ?? "?"}`;
    if (p.type === "api_result") return `API result ok=${p.ok ?? "?"}`;
    // Engine questions arrived under p.kind until 2026_08_13_001200; one key now.
    if (["choice", "slot_picker", "resource_picker", "evidence", "verify"].includes(p.type)) return `${p.type} request — ${p.prompt || ""}`;
    return JSON.stringify(p).slice(0, 120);
}

// MUST match the pattern in App\Services\MessagePlaceholders::fill — a template body is filled
// here when staff send it by hand and THERE when a flow sends it, and a name one accepts and the
// other does not is a body that reads differently depending on who pressed send. Dots since
// 15/09/2026: {{member.licence_no}} went out with its braces showing until this was widened.
function fillPlaceholders(body, ctx) {
    return String(body || "").replace(/\{\{\s*([a-z0-9_]+(?:\.[a-z0-9_]+)?)\s*\}\}/gi, (whole, k) => ctx[k.toLowerCase()] ?? whole);
}

function TemplatePicker({ templates, onPick }) {
    const [open, setOpen] = useState(false);
    const [query, setQuery] = useState("");
    const ref = useRef(null);

    useEffect(() => {
        if (!open) return;
        const onDocClick = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
        document.addEventListener("mousedown", onDocClick);
        return () => document.removeEventListener("mousedown", onDocClick);
    }, [open]);

    const filtered = useMemo(() => {
        const q = query.trim().toLowerCase();
        if (!q) return templates;
        return templates.filter(t =>
            t.name.toLowerCase().includes(q) ||
            (t.category || "").toLowerCase().includes(q) ||
            (t.body || "").toLowerCase().includes(q)
        );
    }, [templates, query]);

    return (
        <div ref={ref} style={{ position: "relative" }}>
            <button type="button" className="pt-btn pt-btn-soft pt-btn-sm" onClick={() => setOpen(o => !o)}>
                <span style={{ fontWeight: 700 }}>+ Template</span>
            </button>
            {open && (
                <div style={{
                    position: "absolute", bottom: "calc(100% + 6px)", left: 0,
                    width: 320, maxHeight: 360, overflowY: "auto",
                    background: "var(--surface)", border: "1px solid var(--line)", borderRadius: 12,
                    boxShadow: "var(--shadow-lg)", zIndex: 50,
                }}>
                    <div style={{ padding: 8, borderBottom: "1px solid var(--line)" }}>
                        <input
                            className="pt-input"
                            placeholder="Search templates…"
                            value={query}
                            onChange={e => setQuery(e.target.value)}
                            autoFocus
                            style={{ height: 34, fontSize: 13 }}
                        />
                    </div>
                    {filtered.length === 0 ? (
                        <div style={{ padding: 20, textAlign: "center", color: "var(--muted)", fontSize: 12.5 }}>
                            No templates match.
                        </div>
                    ) : filtered.map(t => (
                        <button key={t.id} onClick={() => { onPick(t); setOpen(false); setQuery(""); }}
                            style={{
                                display: "block", width: "100%", textAlign: "left",
                                padding: "10px 12px", background: "none", border: "none",
                                borderBottom: "1px solid var(--line)", cursor: "pointer",
                                fontFamily: "var(--font)",
                            }}>
                            <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 2 }}>
                                <span style={{ fontWeight: 600, fontSize: 13, color: "var(--ink)" }}>{t.name}</span>
                                <span style={{ display: "flex", gap: 4, flexShrink: 0 }}>
                                    {t.pattern ? <span className={`pt-badge ${patternBadgeClass(t.pattern)}`} style={{ padding: "1px 7px", fontSize: 10 }}>{patternLabel(t.pattern)}</span> : null}
                                    {t.category ? <span className="pt-badge pt-b-brand" style={{ padding: "1px 7px", fontSize: 10 }}>{t.category}</span> : null}
                                </span>
                            </div>
                            <div style={{ fontSize: 11.5, color: "var(--muted)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
                                {(t.body || "").slice(0, 70)}
                            </div>
                        </button>
                    ))}
                </div>
            )}
        </div>
    );
}

// EMBEDDED is this same screen inside somebody else's dialog (owner, 23/09/2026: "can simply reuse
// this part"). Bookings opens a member's conversation without leaving the list it was read from, so
// the column of OTHER conversations goes — the dialog is about one of them — and everything else,
// the engagement pills and the reply box included, is the screen staff already know. All of that
// member's threads stay reachable; the booking's own is simply the one it opens on.
const Inbox = ({ initialConversationId = null, initialFocus = null, initialThreadId = null, embedded = false, onConsumed, onActiveConversationChange, unreadByConversation = {}, unreadByRun = {}, unreadMessagesByConversation = {} }) => {
    const [convs, setConvs] = useState([]);
    const [loading, setLoading] = useState(true);
    const [selected, setSelected] = useState(null);
    const [detail, setDetail] = useState(null);
    const [detailLoading, setDetailLoading] = useState(false);
    const [reply, setReply] = useState("");
    const [replyAttachments, setReplyAttachments] = useState([]);
    const [detailTab, setDetailTab] = useState("chat");
    // Who is IN the room. A step can be addressed to one side (`to` on the node), and until
    // 11/09/2026 nothing could put that side in the room to receive it — so this tab is the other
    // half of the two-sided gate, not a convenience. (Arfu)
    const [people, setPeople] = useState([]);
    const visible = useTabVisible();
    const { templates } = useTemplates();
    const replyRef = useRef(null);
    const fileRef = useRef(null);
    const selectedRef = useRef(null);
    // Which message the caller asked to land on, and which one is lit up now. See the effects
    // below the auto-open.
    const focusWanted = useRef(null);
    // The engagement a caller asked for — "Open the case" on a queue means THAT case, not Main.
    // A ref for the same reason as focusWanted: the parent clears its request before the room
    // has been selected. (Arfu, 25/09/2026)
    const threadWanted = useRef(null);
    const [focusedMessageId, setFocusedMessageId] = useState(null);

    useEffect(() => { selectedRef.current = selected; }, [selected]);

    // Where the transcript sits, on the same rule the customer's two chats use — see
    // shared/useStickToLast.
    //
    // This screen used to scroll to the bottom on EVERY change, unconditionally: reading something
    // further up while a conversation was live meant being dragged back down every three seconds by
    // the poll. Staying put is only half the answer, so the count of what arrived meanwhile is the
    // other half. (Arfu, 02/09/2026)
    // WHICH ENGAGEMENT is being read. Declared up here because the placement below depends on it:
    // switching engagement swaps the whole transcript, and the room's message COUNT does not change
    // when it does. (Arfu, 18/09/2026)
    const [threadId, setThreadId] = useState(null);

    const { ref: chatBodyRef, onScroll: onChatScroll, repin: repinChat, behind, jumpToLatest, anchorAt } =
        useStickToLast(detail?.messages?.length ?? 0, [detailTab, threadId]);


    const load = useCallback(async () => {
        try {
            const res = await axios.get("/conversation/list");
            const list = res.data?.conversations || [];
            setConvs(list);
        } catch (e) {
            console.error(e);
        } finally {
            setLoading(false);
        }
    }, []);

    useEffect(() => { load(); }, [load]);

    // Poll the conversation list for new contact messages — same cadence as
    // the notification poll in Home so the bell and the list stay in sync.
    useEffect(() => {
        const tick = async () => {
            try {
                const res = await axios.get("/conversation/list");
                setConvs(res.data?.conversations || []);
            } catch { /* silent */ }
        };
        const id = setInterval(tick, 3000);
        return () => clearInterval(id);
    }, []);

    // Auto-refresh the open conversation.
    //
    // NOT only while the Chat tab is showing, which is what this used to do — Inspect reads the same
    // document and simply went stale, and switching back to Chat showed whatever had been fetched
    // before you left it. There is nothing to clobber: both tabs are read-only views of `detail`.
    // This screen is live on every tab now, which is why it carries no Refresh button when every
    // other list does. (Arfu, 02/09/2026)
    // This fetch is ALSO what tells the server the Inbox is on this conversation, which is what
    // stops a member's message ringing a bell nobody needed (see [[App\Services\ThreadPresence]]).
    // So it stops the moment the tab goes behind another, and runs once immediately on the way back
    // rather than waiting out the interval.
    useEffect(() => {
        if (!selected || !visible) return;
        let cancelled = false;
        const tick = async () => {
            if (selectedRef.current !== selected) return;
            try {
                // WHICH THREAD is on screen. Sent only when the panel is actually filtering to one:
                // a room with a single conversation draws every message, and claiming one thread of
                // it would let the rest ring a bell for something already in front of the staffer.
                // EMPTY STRING, not null: axios drops a null query param, and an ABSENT key means
                // "the whole room is on screen" to the server — which would silence every
                // engagement while a staffer reads Main. Empty is Main. (Arfu, 23/09/2026)
                const params = filteringRef.current
                    ? { id: selected, flow_run_id: threadRef.current || "" }
                    : { id: selected };
                const res = await axios.get("/conversation/get", { params });
                if (!cancelled) setDetail(res.data);
            } catch { /* silent */ }
        };
        tick();
        const id = setInterval(tick, 3000);
        return () => { cancelled = true; clearInterval(id); };
    }, [selected, visible, threadId]);

    // Given by the shell, off the unread notifications the bell counts — see Home. This was a
    // last-seen-message tally until 23/09/2026 and could not answer the question it was asked: it
    // marked every conversation as seen at page load, so a message that arrived overnight lit
    // nothing, and it only ever looked at the LAST message in a room.
    const unreadByConv = unreadByConversation;
    // Whether a given thread inside the open room is waiting — keyed room:run, Main being "".
    const unreadInThread = (threadId) => unreadByRun[`${selected}:${threadId || ""}`] || 0;

    const open = async (id) => {
        setSelected(id);
        setDetailLoading(true);
        setDetail(null);
        setReply("");
        setDetailTab("chat");
        try {
            const res = await axios.get("/conversation/get", { params: { id } });
            setDetail(res.data);
        } catch (e) {
            console.error(e);
        } finally {
            setDetailLoading(false);
        }
    };

    // Auto-open a conversation requested by a notification click in the parent.
    useEffect(() => {
        if (!initialConversationId || loading) return;
        // Held in a ref rather than state: the parent clears it on the next line, and the
        // transcript this refers to has not arrived yet.
        focusWanted.current = initialFocus || null;
        threadWanted.current = initialFocus?.threadId || null;
        // Already on that room: the selection below will not re-run, so pick the thread here.
        if (threadWanted.current && String(selected) === String(initialConversationId)) {
            setThreadId(threadWanted.current);
            threadWanted.current = null;
        }
        setDetailTab("chat");
        open(initialConversationId);
        if (onConsumed) onConsumed();
        // eslint-disable-next-line react-hooks/exhaustive-deps
    }, [initialConversationId, loading]);

    // …and once the transcript is in, find the message the caller meant. A payment can appear in
    // this thread four times over — invoice, receipt, verdict — so the LAST mention wins: that is
    // where the story of that invoice currently is. (Arfu, 15/09/2026)
    useEffect(() => {
        const want = focusWanted.current;
        if (!want?.paymentId || !detail?.messages?.length) return;
        focusWanted.current = null;

        const hit = [...detail.messages].reverse()
            .find(m => Number(m.payload?.payment_id) === Number(want.paymentId));
        if (!hit) return;

        // Select the engagement it belongs to FIRST. Since 17/09/2026 the transcript shows one
        // thread at a time, so jumping to a message in another one would scroll to a row that is
        // not on screen — a notification that appears to do nothing.
        setThreadId(String(hit.flow_run_id ?? ""));
        setFocusedMessageId(hit.id);
    }, [detail]);

    // Scrolling happens after the paint that renders the row, and AFTER repinChat has pinned the
    // transcript to the bottom — otherwise the pin wins and the jump is invisible.
    useEffect(() => {
        if (!focusedMessageId || detailTab !== "chat") return;

        const frame = requestAnimationFrame(() => {
            document.querySelector(`[data-msg-id="${focusedMessageId}"]`)
                ?.scrollIntoView({ behavior: "smooth", block: "center" });
        });
        // The highlight fades by itself. A permanent one would still be sitting there the next
        // time this conversation is opened for an unrelated reason.
        const fade = setTimeout(() => setFocusedMessageId(null), 4000);

        return () => { cancelAnimationFrame(frame); clearTimeout(fade); };
    }, [focusedMessageId, detailTab, detail]);

    // Resolved server-side since 2026-08-18. Every source moved in the chatroom split: this read
    // `conversation.vars`, which a ROOM does not have, so all five placeholders rendered their
    // fallback and booking_ref rendered a FABRICATED `BK-{room id}` — the invented-reference
    // defect T19-c already had to fix once on the booking card.
    const conversationContext = useMemo(
        () => detail?.context || {
            customer_name: "there",
            service:       "your appointment",
            when:          "",
            booking_ref:   "",
            org_name:      "",
        },
        [detail],
    );

    const loadPeople = useCallback(async (roomId) => {
        if (!roomId) return;
        try {
            const res = await axios.get("/conversation/participants", { params: { chatroom_id: roomId } });
            setPeople(res.data?.data?.participants ?? []);
        } catch (e) {
            notifyError(e?.response?.data?.message || "Could not load the people in this conversation.");
        }
    }, []);

    useEffect(() => {
        if (detailTab !== "people" || !selected) return;
        loadPeople(selected);
    }, [detailTab, selected, loadPeople]);

    const [invoiceOpen, setInvoiceOpen] = useState(false);
    const [invoiceAmount, setInvoiceAmount] = useState("");
    const [invoiceNote, setInvoiceNote] = useState("");
    const [invoiceSending, setInvoiceSending] = useState(false);
    const [choiceOpen, setChoiceOpen] = useState(false);
    const [choiceText, setChoiceText] = useState("");
    const [choiceOptions, setChoiceOptions] = useState([]);
    const [choiceSending, setChoiceSending] = useState(false);

    const applyTemplate = (t) => {
        if (t?.kind === TEMPLATE_KIND_INVOICE) {
            setInvoiceNote(fillPlaceholders(t.body, conversationContext));
            setInvoiceAmount("");
            setInvoiceOpen(true);
            return;
        }
        if (t?.kind === TEMPLATE_KIND_RESCHEDULE) {
            sendReschedule(fillPlaceholders(t.body, conversationContext));
            return;
        }
        if (t?.pattern === TEMPLATE_PATTERN.TWO_WAY) {
            setChoiceText(fillPlaceholders(t.body, conversationContext));
            const opts = Array.isArray(t.options) && t.options.length
                ? t.options.map(o => fillPlaceholders(String(o), conversationContext))
                : ["Yes", "No"];
            setChoiceOptions(opts);
            setChoiceOpen(true);
            return;
        }
        const filled = fillPlaceholders(t.body, conversationContext);
        setReply(prev => prev ? `${prev}\n${filled}` : filled);
        setTimeout(() => replyRef.current?.focus(), 0);
    };

    const sendReschedule = async (text) => {
        if (!detail?.conversation) return;
        try {
            await axios.post("/conversation/send-reschedule", {
                id: detail.conversation.id,
                text: text || null,
            });
            const res = await axios.get("/conversation/get", { params: { id: detail.conversation.id } });
            setDetail(res.data);
        } catch (err) {
            console.error(err);
            notifyError(err?.response?.data?.message || "Could not send reschedule offer.");
        }
    };

    const sendChoice = async (e) => {
        e?.preventDefault();
        if (!detail?.conversation) return;
        const text = choiceText.trim();
        const opts = choiceOptions.map(o => String(o).trim()).filter(Boolean);
        if (!text || opts.length < 2) return;
        setChoiceSending(true);
        try {
            await axios.post("/conversation/send-choice", {
                id: detail.conversation.id,
                text,
                options: opts,
            });
            const res = await axios.get("/conversation/get", { params: { id: detail.conversation.id } });
            setDetail(res.data);
            setChoiceOpen(false);
            setChoiceText("");
            setChoiceOptions([]);
        } catch (err) {
            console.error(err);
            notifyError(err?.response?.data?.message || "Could not send 2-way prompt.");
        } finally {
            setChoiceSending(false);
        }
    };

    const sendInvoice = async (e) => {
        e?.preventDefault();
        const amount = Number(invoiceAmount);
        if (!detail?.conversation || !(amount > 0)) return;
        setInvoiceSending(true);
        try {
            await axios.post("/conversation/send-invoice", {
                id: detail.conversation.id,
                amount,
                note: invoiceNote || null,
            });
            const res = await axios.get("/conversation/get", { params: { id: detail.conversation.id } });
            setDetail(res.data);
            setInvoiceOpen(false);
            setInvoiceAmount("");
            setInvoiceNote("");
        } catch (err) {
            console.error(err);
            notifyError(err?.response?.data?.message || "Could not send invoice.");
        } finally {
            setInvoiceSending(false);
        }
    };

    const [verifyPayload, setVerifyPayload] = useState(null);
    const [rejectReason, setRejectReason] = useState("");
    const [verifyBusy, setVerifyBusy] = useState(false);

    // The 3-way `verify` NODE, which is a different thing from verifyPayload above — that one
    // resolves a PAYMENT record and is keyed on payment_id. A verify node behind an EVIDENCE
    // step has no payment to key on, which is why the payment dialog could not resolve one and a
    // 3-way run parked in waiting_verification forever (found by the T12 browser suite,
    // 2026-08-18). A gate behind a receipt is resolved by the dialog now — see
    // gateIsUnresolvedReceipt below — so this card is what is left for evidence.
    const [gateNote, setGateNote] = useState("");
    const [gateBusy, setGateBusy] = useState(false);

    // The newest run in the room — what "this conversation is at node X" means now that a room
    // outlives any single execution and may have hosted several. The controller returns them
    // newest-first.
    const activeRun = useMemo(() => (detail?.flow_runs || [])[0] || null, [detail]);

    // ONE THREAD PER ENGAGEMENT (17/09/2026), the org side of what the user panel does. A room is
    // the member's standing place with this vendor and holds every engagement they have had, so a
    // customer who books the same service twice used to read as one transcript with the second run
    // starting mid-sentence under the first.
    //
    // Derived here rather than asked for: /conversation/get already returns `flow_runs`, and every
    // message already carries `flow_run_id`. Messages belonging to no run — what somebody typed
    // rather than what a flow said — are their own thread at the end.
    const threads = useMemo(() => {
        const runs = detail?.flow_runs || [];
        const messages = detail?.messages || [];
        const rows = runs.map((r) => ({
            id: String(r.id),
            run: r,
            count: messages.filter((m) => String(m.flow_run_id ?? "") === String(r.id)).length,
        }));

        // MAIN always, even with nothing in it: it is the ROOM — where a staffer types a reply that
        // belongs to no engagement — and a tab that appears only once somebody has used it is a
        // tab nobody discovers. The user panel has always drawn it this way.
        const loose = messages.filter((m) => !m.flow_run_id).length;
        rows.unshift({ id: "", run: null, count: loose });

        return rows;
    }, [detail]);

    // A different conversation, a different ENGAGEMENT, or coming back to the Chat tab starts at
    // the newest message.
    useEffect(() => { repinChat(); }, [selected, detailTab, threadId]);
    // THE SAME STRIP AS THE USER PANEL (17/09/2026): one row that slides, plus a button to the
    // full list. It wrapped to as many rows as it needed before, which pushed the transcript down
    // the screen on a busy room — and the two panels showing one room two different ways is its own
    // small tax on whoever works in both.
    const stripRef = useRef(null);
    const dragRef = useRef(null);
    const draggedRef = useRef(false);
    const [allOpen, setAllOpen] = useState(false);

    const onStripDown = (e) => {
        const el = stripRef.current;
        if (!el) return;
        draggedRef.current = false;
        dragRef.current = { x: e.clientX, left: el.scrollLeft };
    };
    const onStripMove = (e) => {
        const el = stripRef.current;
        if (!el || !dragRef.current) return;
        const dx = e.clientX - dragRef.current.x;
        if (Math.abs(dx) > 4) draggedRef.current = true;
        el.scrollLeft = dragRef.current.left - dx;
    };
    const onStripUp = () => { dragRef.current = null; };
    // A slide is not a choice: pointerup runs BEFORE click, so the flag has to outlive the drag.
    const pickIfNotDragging = (fn) => () => { if (!draggedRef.current) fn(); };

    // Follow the conversation: opening another one lands on its newest engagement rather than
    // keeping a thread id from the last.
    // MAIN, unless something is WAITING in one of the room's engagements — then that one, because
    // the dot on the row is what made the staffer press it. Null means "the default", which is the
    // first pill. (Arfu, 23/09/2026)
    const waitingThreadIn = (roomId) => {
        if (!roomId) return null;
        const prefix = `${roomId}:`;
        const hit = Object.keys(unreadByRun).find(k => k.startsWith(prefix) && unreadByRun[k] && k.slice(prefix.length));
        return hit ? hit.slice(prefix.length) : null;
    };
    useEffect(() => {
        // The thread a caller asked for — Bookings opens a member's conversation ON the engagement
        // the row was about — else whichever is waiting, else the default.
        const wanted = threadWanted.current;
        threadWanted.current = null;
        setThreadId(initialThreadId ?? wanted ?? waitingThreadIn(selected));
    }, [selected, initialThreadId]);

    const currentThreadId = threadId !== null ? threadId : (threads[0]?.id ?? null);
    // A room holding ONE conversation draws every message, so there is no thread to name; with
    // several, the panel filters and the thread on screen is what it claims and what it clears.
    const currentThread = threads.find((t) => String(t.id) === String(currentThreadId));
    const currentBookingId = currentThread?.run?.booking_id ?? null;
    const currentThreadLabel = currentThread?.run?.booking_ref ?? "";
    const filtering = threads.length >= 2;
    const threadRef = useRef(null);
    const filteringRef = useRef(false);
    useEffect(() => { threadRef.current = currentThreadId; filteringRef.current = filtering; });

    // WHAT WAS NEW WHEN THE ROOM WAS OPENED (Arfu, 02/10/2026). Opening a room reads its
    // notifications at once, so the dots went and nothing in the transcript said which messages
    // were the new ones. Taken here, before the effect below tells the parent to read them, and kept
    // until another room is opened; anything landing meanwhile in a thread not on screen joins it.
    const [newIds, setNewIds] = useState(() => new Set());
    const newIdsRoom = useRef(null);
    useEffect(() => {
        const ids = unreadMessagesByConversation[selected] || [];
        if (newIdsRoom.current !== selected) {
            newIdsRoom.current = selected;
            setNewIds(new Set(ids));
            return;
        }
        if (ids.some((id) => !newIds.has(id))) setNewIds((prev) => new Set([...prev, ...ids]));
    }, [selected, unreadMessagesByConversation]);

    // Tell the parent what is on screen — the conversation, and the engagement inside it when the
    // panel is filtering to one — so it can clear the notifications for exactly that and no more.
    // `undefined` for the run means the whole room. When the chat tab is hidden or no conversation
    // is selected, the parent treats this as "not viewing".
    useEffect(() => {
        if (!onActiveConversationChange) return;
        const viewing = !!selected && detailTab === "chat";
        const runId = filtering ? (currentThreadId || null) : undefined;
        onActiveConversationChange(selected, viewing, runId);
        return () => {
            if (viewing) onActiveConversationChange(selected, false, undefined);
        };
    }, [selected, detailTab, currentThreadId, filtering, onActiveConversationChange]);

    const visibleMessages = useMemo(() => {
        const messages = detail?.messages || [];
        if (currentThreadId === null || threads.length < 2) return messages;

        return messages.filter((m) => String(m.flow_run_id ?? "") === String(currentThreadId));
    }, [detail, currentThreadId, threads.length]);

    // Where the reader left off: the divider goes above this one, and the chat opens AT it rather
    // than at the newest — once per room and engagement, so a poll cannot drag them back up.
    const firstNewId = useMemo(
        () => visibleMessages.find((m) => newIds.has(String(m.id)))?.id ?? null,
        [visibleMessages, newIds],
    );
    const anchoredFor = useRef(null);
    useEffect(() => {
        if (!firstNewId || detailTab !== "chat") return;
        // A notification that names a payment has its own place to land.
        if (focusWanted.current || focusedMessageId) return;
        const key = `${selected}:${currentThreadId ?? ""}`;
        if (anchoredFor.current === key) return;
        anchoredFor.current = key;
        requestAnimationFrame(() => anchorAt(chatBodyRef.current?.querySelector("[data-new-divider]")));
    }, [firstNewId, detailTab, selected, currentThreadId]);
    useEffect(() => { anchoredFor.current = null; }, [selected]);

    // The room's run that is waiting on org staff, if any. A room may host several runs, so this
    // picks the one actually asking a question rather than assuming there is only one.
    const pendingGate = useMemo(
        () => (detail?.flow_runs || []).find(r => r.status === CONVERSATION_STATUS.WAITING_VERIFICATION) || null,
        [detail],
    );

    // A run waiting on an OUTSIDE BODY — Immigration, a consulate, a courier. Its answer is recorded
    // here, on the case, as well as from "Waiting on others", so staff reading the chat do not have
    // to leave it to say what came back (owner, 30/09/2026).
    const pendingExternal = useMemo(
        () => (detail?.flow_runs || []).find(r => r.status === CONVERSATION_STATUS.WAITING_EXTERNAL) || null,
        [detail],
    );
    const [answeringExternal, setAnsweringExternal] = useState(false);
    // ON ITS OWN THREAD ONLY (owner, 30/09/2026): a room with several engagements shows each in
    // its own pill, and Main is not one of them — a card there reads as belonging to nothing.
    // A room with one conversation has no pills, so there it always shows.
    const onThreadOf = (run) => !!run && (threads.length < 2
        || String(currentThreadId) === String(run._id ?? run.id));
    const externalWait = pendingExternal && onThreadOf(pendingExternal) ? {
        flow_run_id: String(pendingExternal._id ?? pendingExternal.id),
        body: pendingExternal.waiting_body || "somebody outside",
        service: pendingExternal.service_full_name || pendingExternal.service_name || null,
        customer: (detail?.conversation?.participant_names || []).join(", ") || null,
        booking_code: pendingExternal.booking_ref || null,
        since: pendingExternal.waiting_since || null,
        due: pendingExternal.waiting_until || null,
        working_days_left: pendingExternal.waiting_days_left ?? null,
    } : null;

    // A dated wait (`await_until`, a step's delay): no card to answer, only when it comes back.
    const comingBack = (detail?.flow_runs || []).find(r =>
        r.status === CONVERSATION_STATUS.WAITING_SCHEDULE && r.comes_back_at && onThreadOf(r)) || null;

    // A "Get their documents" step handed to staff (v0.34): the run's latest card says so.
    const pendingFetch = useMemo(() => (detail?.flow_runs || []).find(r => {
        if (r.status !== CONVERSATION_STATUS.WAITING_INPUT) return false;
        const card = (detail?.messages || [])
            .filter(m => String(m.flow_run_id) === String(r._id ?? r.id) && m.payload?.type === "fetch_documents")
            .pop();

        return !!card?.payload?.handed_over && card.node_id === r.current_node;
    }) || null, [detail]);

    // The prompt the flow author wrote on the verify node — the actual question being asked.
    // Read from the transcript rather than the node graph, because the panel holds messages, not
    // the definition.
    const gatePrompt = useMemo(() => {
        if (!pendingGate) return "";
        const asked = (detail?.messages || [])
            .filter(m => m.payload?.type === "verify" && String(m.flow_run_id) === String(pendingGate.id))
            .pop();
        return asked?.payload?.prompt || "";
    }, [detail, pendingGate]);

    // WHAT is being judged — the answer the customer actually sent to the evidence step this gate
    // follows. The card asked "Approve?" and showed nothing else, so staff had to scroll back up
    // the transcript to find the thing they were approving, and a gate opened from the queue with
    // the transcript scrolled elsewhere was a decision made blind. (Arfu, 03/09/2026)
    const gateEvidence = useMemo(() => {
        if (!pendingGate) return null;

        const mine = (detail?.messages || []).filter(m => String(m.flow_run_id) === String(pendingGate.id));

        // What the GATE says it is about, when its author named a proof step. Preferred over the
        // last thing sent, because a flow may ask for two different proofs before one verify and
        // "the most recent" is then a guess.
        const asked = mine.filter(m => m.payload?.type === "verify").pop();
        if (asked?.payload?.evidence) return { payload: asked.payload.evidence };

        return mine.filter(m => m.direction === "inbound" && m.payload?.type === "evidence").pop() || null;
    }, [detail, pendingGate]);

    // A gate raised behind a RECEIPT belongs to the Verify payment dialog, which shows the file,
    // the amount and who sent it — everything this card asks you to decide without. Accepting or
    // rejecting there resolves this gate too (Org\DashboardController::resolveVerifyGateFor), so
    // the card would be the same question asked twice with less to go on. Every other verify node
    // still shows it: evidence has no dialog of its own. (Arfu, 01/09/2026)
    const gateIsUnresolvedReceipt = useMemo(() => {
        if (!pendingGate) return false;
        const messages = detail?.messages || [];

        // The invoice THIS run raised — a room outlives its runs, so an older payment left
        // unresolved must not silence a gate that has nothing to do with it.
        return messages.some(m => m.payload?.type === "payment"
            && String(m.flow_run_id) === String(pendingGate.id)
            && !messages.some(other =>
                (other.payload?.type === "payment_verified" || other.payload?.type === "payment_rejected")
                && other.payload?.payment_id === m.payload?.payment_id));
    }, [detail, pendingGate]);

    const resolveGate = async (ok) => {
        if (!pendingGate || gateBusy) return;
        setGateBusy(true);
        try {
            await axios.post("/conversation/verify", {
                flow_run_id: pendingGate.id,
                ok,
                note: gateNote.trim() || null,
            });
            const res = await axios.get("/conversation/get", { params: { id: detail.conversation.id } });
            setDetail(res.data);
            setGateNote("");
        } catch (err) {
            console.error(err);
            notifyError(err?.response?.data?.message || "Could not record that decision.");
        } finally {
            setGateBusy(false);
        }
    };

    const openVerify = (payload) => {
        setVerifyPayload(payload);
        setRejectReason("");
    };
    const closeVerify = () => {
        setVerifyPayload(null);
        setRejectReason("");
        setVerifyBusy(false);
    };

    const acceptPayment = async () => {
        if (!verifyPayload?.payment_id || !detail?.conversation) return;
        setVerifyBusy(true);
        try {
            await axios.post("/conversation/verify-payment", { payment_id: verifyPayload.payment_id });
            const res = await axios.get("/conversation/get", { params: { id: detail.conversation.id } });
            setDetail(res.data);
            closeVerify();
        } catch (err) {
            console.error(err);
            notifyError(err?.response?.data?.message || "Could not verify payment.");
        } finally {
            setVerifyBusy(false);
        }
    };

    const rejectPayment = async () => {
        if (!verifyPayload?.payment_id || !detail?.conversation) return;
        setVerifyBusy(true);
        try {
            await axios.post("/conversation/reject-payment", {
                payment_id: verifyPayload.payment_id,
                reason: rejectReason.trim() || null,
            });
            const res = await axios.get("/conversation/get", { params: { id: detail.conversation.id } });
            setDetail(res.data);
            closeVerify();
        } catch (err) {
            console.error(err);
            notifyError(err?.response?.data?.message || "Could not reject payment.");
        } finally {
            setVerifyBusy(false);
        }
    };

    const sendReply = async (e) => {
        e?.preventDefault();
        const text = reply.trim();
        if ((!text && replyAttachments.length === 0) || !detail?.conversation) return;
        const convId = detail.conversation.id;
        const form = new FormData();
        if (text) form.append("text", text);
        // THE THREAD ON SCREEN. Without it the server filed a staffer's reply into whichever
        // engagement was newest, so typing in Main sent the message somewhere they were not
        // looking and the transcript stayed empty. (Arfu, 17/09/2026)
        if (currentThreadId) form.append("flow_run_id", currentThreadId);
        replyAttachments.forEach(a => { if (a.file) form.append("attachments[]", a.file, a.name); });
        try {
            // Routed through the session-shim /conversation/messages/{id}, which
            // delegates to Api\Org\ConversationController::storeMessage — same code path
            // external REST clients hit at POST /api/org/conversations/{id}/messages.
            await axios.post(`/conversation/messages/${convId}`, form);
        } catch (err) {
            console.error(err);
            notifyError(err?.response?.data?.message || "Could not send reply.");
            return;
        }
        try {
            const res = await axios.get("/conversation/get", { params: { id: detail.conversation.id } });
            setDetail(res.data);
        } catch (err) { console.error(err); }
        setReply("");
        replyAttachments.forEach(a => a.url && URL.revokeObjectURL(a.url));
        setReplyAttachments([]);
    };

    const onPickReplyFiles = (e) => {
        const files = Array.from(e.target.files || []);
        if (!files.length) return;
        const next = files.map(f => ({
            kind: f.type.startsWith("image/") ? "image" : "file",
            url: URL.createObjectURL(f),
            name: f.name,
            size: f.size,
            mime: f.type,
            file: f,
        }));
        setReplyAttachments(a => [...a, ...next]);
        e.target.value = "";
    };

    const removeReplyAttachment = (i) => {
        setReplyAttachments(a => {
            const target = a[i];
            if (target?.url) URL.revokeObjectURL(target.url);
            return a.filter((_, idx) => idx !== i);
        });
    };

    return (
        <div className="pt-fade-in">
            <div className="pt-grid" style={{ gridTemplateColumns: embedded ? "1fr" : "340px 1fr", gap: 16, alignItems: "flex-start" }}>
                {/* Conversation list — hidden when this screen is somebody else's dialog. */}
                {embedded ? null : (
                <div className="pt-card" style={{ maxHeight: "calc(100vh - 140px)", overflow: "hidden", display: "flex", flexDirection: "column" }}>
                    <div style={{ overflowY: "auto", flex: 1 }}>
                        {loading ? (
                            <div style={{ padding: 32, textAlign: "center", color: "var(--muted)", fontSize: 13 }}>Loading…</div>
                        ) : convs.length === 0 ? (
                            <div style={{ padding: 32, textAlign: "center", color: "var(--muted)", fontSize: 13 }}>
                                No conversations yet. They appear here as soon as a contact starts your widget flow.
                            </div>
                        ) : (
                            convs.map((c) => {
                                const isUnread = unreadByConv[c.id];
                                // Membership is the room's participant list since 2026-08-13.
                                // This read `vars.customer_name` / `vars.user_id` — RUN variables
                                // that a room does not have — so every row said "User #—".
                                const customerName = (c.participant_names || []).join(" & ") || "No participants";
                                return (
                                    <button key={c.id} onClick={() => open(c.id)} data-room={c.id}
                                        style={{
                                            display: "block", width: "100%", textAlign: "left",
                                            padding: "12px 16px", background: selected === c.id ? "var(--blush-bg)" : "transparent",
                                            border: "none", borderBottom: "1px solid var(--line)",
                                            borderLeft: selected === c.id ? "3px solid var(--brand)" : "3px solid transparent",
                                            cursor: "pointer", fontFamily: "var(--font)", transition: "background .12s",
                                        }}>
                                        <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 4 }}>
                                            <span style={{ fontWeight: isUnread ? 700 : 600, fontSize: 13.5, color: "var(--ink)", display: "flex", alignItems: "center", gap: 8 }}>
                                                {isUnread && <span style={{ width: 8, height: 8, borderRadius: "50%", background: "var(--brand)", flexShrink: 0 }} />}
                                                {/* WHO, because a room is one member now and holds
                                                    every service they use. The room id it used to
                                                    lead with is 24 characters that tell a staffer
                                                    nothing. (Arfu, 17/09/2026) */}
                                                <span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
                                                    {customerName}
                                                </span>
                                            </span>
                                            {/* The RUN's state when one is executing, because that is
                                                what tells staff whether the row needs them; the ROOM's
                                                own state otherwise. Feeding c.status to the run map, as
                                                this did, labelled an open room "Running" and a closed
                                                one "Waiting · input" — two different enums that happen
                                                to share the values 1-3. */}
                                            <span className={`pt-badge ${c.run_status ? STATUS_CLASS(c.run_status) : "pt-b-grey"}`} style={{ padding: "2px 8px" }}>
                                                {c.run_status
                                                    ? (STATUS_LABEL[c.run_status] || c.run_status)
                                                    : (CHATROOM_STATUS_LABEL[c.status] || "Open")}
                                            </span>
                                        </div>
                                        <div style={{ fontSize: 12, color: "var(--muted)", display: "flex", justifyContent: "space-between" }}>
                                            {/* current_node belongs to the RUN, not the room — this read
                                                c.current_node and printed "at —" on every row. */}
                                            {/* The newest engagement: which service it is for, and
                                                where it has got to. */}
                                            <span style={{ fontFamily: "var(--mono)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
                                                {c.service_code ? `${c.service_code} · ` : ""}
                                                {c.run_current_node ? `at ${c.run_current_node}` : "no active flow"}
                                            </span>
                                            <span>{c.last_message_at ? new Date(c.last_message_at).toLocaleString("en-HK", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) : ""}</span>
                                        </div>
                                    </button>
                                );
                            })
                        )}
                    </div>
                </div>
                )}

                {/* Detail */}
                {/* Inside a dialog it measures against the DIALOG, not the window — a panel sized to
                    the viewport inside a box that is smaller than the viewport is what puts a second
                    scrollbar down the edge. */}
                <div className="pt-card" style={{ maxHeight: embedded ? "78vh" : "calc(100vh - 140px)", minHeight: 360, overflow: "hidden", display: "flex", flexDirection: "column" }}>
                    {!selected ? (
                        <div style={{ padding: 60, textAlign: "center", color: "var(--muted)" }}>
                            <div style={{ fontSize: 14, marginBottom: 8 }}>Select a conversation to see its timeline.</div>
                            <div style={{ fontSize: 12, color: "var(--faint)" }}>Every action — engine effects, contact events, and your verifications — is recorded.</div>
                        </div>
                    ) : detailLoading ? (
                        <div style={{ padding: 60, textAlign: "center", color: "var(--muted)" }}>Loading…</div>
                    ) : detail?.conversation ? (
                        <>
                            <div className="pt-card-head">
                                <div>
                                    {/* Named by the PERSON, which is what a room is. Which service
                                        each engagement is for is on its own pill below — one room
                                        holds every service this member uses. */}
                                    <h3 style={{ margin: 0 }}>
                                        {(detail.conversation.participant_names || []).join(" & ") || "Conversation"}
                                    </h3>
                                    <div className="pt-hint">
                                        <code style={{ fontFamily: "var(--mono)" }}>#{detail.conversation.id}</code>
                                        {" · "}
                                        {/* Both belong to the RUN. Read off the room until 2026-08-18,
                                            so this line was always "At —" with no version. */}
                                        {activeRun
                                            ? <>At <code style={{ fontFamily: "var(--mono)" }}>{activeRun.current_node ?? "—"}</code></>
                                            : <>No active flow</>}
                                        {activeRun?.flow_version_id ? <> · flow version #{activeRun.flow_version_id}</> : null}
                                    </div>
                                </div>
                                {/* Run state when one is executing, room state otherwise — the two are
                                    different enums that overlap on 1-3, so feeding a room status to
                                    the run map labelled an open room "Running". */}
                                <span className={`pt-badge ${activeRun ? STATUS_CLASS(activeRun.status) : "pt-b-grey"}`} style={{ padding: "4px 12px" }}>
                                    {activeRun
                                        ? (STATUS_LABEL[activeRun.status] || activeRun.status)
                                        : (CHATROOM_STATUS_LABEL[detail.conversation.status] || "Open")}
                                </span>
                            </div>

                            <div style={{ display: "flex", gap: 4, padding: "0 20px", borderBottom: "1px solid var(--line)" }}>
                                {[
                                    ["chat",    `Chat (${visibleMessages.length})`],
                                    ["people",  `People (${(detail.conversation?.participants || []).length})`],
                                    // THE ENGAGEMENT'S PAPERWORK, where the conversation is. Produced,
                                    // handed over and withdrawn from here since 23/09/2026 — all three
                                    // write into this room, and doing them from a list of bookings
                                    // meant speaking into a conversation you were not looking at.
                                    ["documents", "Documents"],
                                    ["inspect", "Inspect"],
                                ].map(([k, label]) => (
                                    <button
                                        key={k}
                                        onClick={() => setDetailTab(k)}
                                        style={{
                                            padding: "12px 14px", background: "none", border: "none", cursor: "pointer",
                                            fontFamily: "var(--font)", fontSize: 13, fontWeight: 600,
                                            color: detailTab === k ? "var(--brand)" : "var(--muted)",
                                            borderBottom: detailTab === k ? "2px solid var(--brand)" : "2px solid transparent",
                                            marginBottom: -1, transition: "color .12s",
                                        }}>
                                        {label}
                                    </button>
                                ))}
                            </div>

                            {detailTab === "chat" ? (
                                <div className="pt-card-pad" style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column", position: "relative" }}>
                                    {/* ONE ROW THAT SLIDES, exactly as the user panel draws it: MAIN first — the
                                        room itself, holding whatever was typed rather than said — then one pill
                                        per engagement, then a button to the full list. It wrapped to as many
                                        rows as it needed before, which pushed the transcript down the screen on
                                        a busy room. Hidden entirely when the room holds nothing but Main. */}
                                    {threads.length > 1 && (
                                        // STICKY: the pills say which engagement you are reading, so they
                                        // are the one thing that must not scroll away with it.
                                        <div style={{
                                            display: "flex", alignItems: "center", gap: 6, marginBottom: 10, minWidth: 0,
                                            position: "sticky", top: 0, zIndex: 5, background: "var(--surface)",
                                            paddingBottom: 6,
                                        }}>
                                            <div ref={stripRef}
                                                onPointerDown={onStripDown} onPointerMove={onStripMove}
                                                onPointerUp={onStripUp} onPointerLeave={onStripUp}
                                                className="pt-subchats"
                                                style={{ display: "flex", flexWrap: "nowrap", gap: 6, flex: 1, minWidth: 0, overflowX: "auto", cursor: "grab" }}>
                                                {threads.map((t) => {
                                                    const on = String(currentThreadId) === String(t.id);
                                                    // NAMED THE WAY THE USER PANEL NAMES IT: the service's short
                                                    // name and what the engagement booked. A column of dates
                                                    // tells a staffer nothing about which case they are opening.
                                                    const label = !t.run
                                                        ? "Main"
                                                        : (t.run.booking_ref
                                                            ? `${t.run.service_name || "Chat"} \u00b7 ${t.run.booking_ref}`
                                                            : (t.run.service_name
                                                                ? `${t.run.service_name} \u00b7 ${fmtWhen(t.run.created_at)}`
                                                                : fmtWhen(t.run.created_at)));
                                                    const done = t.run && [
                                                        CONVERSATION_STATUS.DONE, CONVERSATION_STATUS.FAILED,
                                                    ].includes(Number(t.run.status));
                                                    const waiting = unreadInThread(t.id);
                                                    return (
                                                        <button key={t.id || "main"} type="button" data-on={on ? "1" : "0"}
                                                            onClick={pickIfNotDragging(() => setThreadId(t.id))}
                                                            title={t.run
                                                                ? `${label} \u2002\u00b7\u2002 ${done ? "finished" : "still open"} \u2002\u00b7\u2002 started ${fmtWhen(t.run.created_at)}`
                                                                : "The room itself \u2014 messages that belong to no engagement"}
                                                            style={{
                                                                flex: "0 0 auto", whiteSpace: "nowrap",
                                                                padding: "5px 11px", borderRadius: 999, cursor: "pointer",
                                                                border: `1px solid ${on ? "var(--brand)" : "var(--line)"}`,
                                                                background: on ? "var(--brand-soft, #d9efe9)" : (done ? "var(--surface-2)" : "var(--surface)"),
                                                                color: on ? "var(--brand, #0e7a68)" : (done ? "var(--faint)" : "var(--muted)"),
                                                                fontFamily: "var(--font)", fontSize: 12, fontWeight: 600,
                                                            }}>
                                                            {/* WHICH PILL. A staffer lands on one thread and the
                                                                message may be in another. (Arfu, 23/09/2026) */}
                                                            {waiting ? <span style={{ display: "inline-block", width: 7, height: 7, borderRadius: "50%", background: "var(--brand)", marginRight: 5 }} /> : null}
                                                            {done ? "\u2713 " : ""}{label}
                                                        </button>
                                                    );
                                                })}
                                            </div>
                                            {/* Outside the scroller, so it never slides away. */}
                                            <button type="button" onClick={() => setAllOpen(true)}
                                                title="All conversations in this room" aria-label="All conversations"
                                                style={{
                                                    flex: "0 0 auto", padding: "5px 11px", borderRadius: 999, cursor: "pointer",
                                                    border: "1px solid var(--line)", background: "var(--surface)",
                                                    color: "var(--muted)", fontFamily: "var(--mono)", fontSize: 14, lineHeight: 1,
                                                }}>
                                                ⋯
                                            </button>
                                        </div>
                                    )}
                                    {/* Every engagement as a ROW: what it is, what it booked, whether it is
                                        finished and when it began — none of which fits on a pill. Same list the
                                        user panel opens from its own ⋯. */}
                                    {allOpen && (
                                        <Modal onClose={() => setAllOpen(false)}>
                                            <div onClick={(e) => e.stopPropagation()} className="pt-card" style={{
                                                width: "min(460px, 94vw)", maxHeight: "80vh", display: "flex", flexDirection: "column",
                                                background: "var(--surface)", borderRadius: 14, overflow: "hidden",
                                            }}>
                                                <div style={{ display: "flex", alignItems: "center", gap: 10, padding: "14px 16px", borderBottom: "1px solid var(--line)" }}>
                                                    <div style={{ flex: 1, fontWeight: 700, fontSize: 15 }}>Conversations in this room</div>
                                                    <button type="button" onClick={() => setAllOpen(false)} aria-label="Close"
                                                        style={{ border: "none", background: "none", fontSize: 20, lineHeight: 1, cursor: "pointer", color: "var(--muted)" }}>×</button>
                                                </div>
                                                <div style={{ overflowY: "auto", padding: 10, display: "flex", flexDirection: "column", gap: 8 }}>
                                                    {threads.map((t) => {
                                                        const on = String(currentThreadId) === String(t.id);
                                                        const done = t.run && [
                                                            CONVERSATION_STATUS.DONE, CONVERSATION_STATUS.FAILED,
                                                        ].includes(Number(t.run.status));
                                                        return (
                                                            <button key={t.id || "main"} type="button"
                                                                onClick={() => { setThreadId(t.id); setAllOpen(false); }}
                                                                style={{
                                                                    textAlign: "left", width: "100%", cursor: "pointer", padding: "11px 12px",
                                                                    borderRadius: 11, fontFamily: "var(--font)",
                                                                    border: `1px solid ${on ? "var(--brand)" : "var(--line)"}`,
                                                                    background: on ? "var(--brand-soft, #d9efe9)" : "var(--surface)",
                                                                }}>
                                                                <div style={{ fontSize: 13.5, fontWeight: 600, color: "var(--ink)" }}>
                                                                    {unreadInThread(t.id) ? <span style={{ display: "inline-block", width: 7, height: 7, borderRadius: "50%", background: "var(--brand)", marginRight: 5 }} /> : null}
                                                                    {!t.run ? "Main" : (t.run.service_name || "Chat")}
                                                                    {t.run?.booking_ref
                                                                        ? <span style={{ fontFamily: "var(--mono)", fontWeight: 500 }}> · {t.run.booking_ref}</span>
                                                                        : null}
                                                                </div>
                                                                <div style={{ fontSize: 11.5, color: "var(--muted)", marginTop: 2 }}>
                                                                    {!t.run
                                                                        ? `Messages that belong to no engagement · ${t.count}`
                                                                        : `${done ? "Finished" : "Still open"} · started ${fmtWhen(t.run.created_at)} · ${t.count} message${t.count === 1 ? "" : "s"}`}
                                                                </div>
                                                            </button>
                                                        );
                                                    })}
                                                </div>
                                            </div>
                                        </Modal>
                                    )}
                                    <div ref={chatBodyRef} onScroll={onChatScroll} data-chat-body
                                        style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column", gap: 10, overflowY: "auto", padding: "4px 2px" }}>
                                        {visibleMessages.length === 0 ? (
                                            <div style={{ padding: 24, textAlign: "center", color: "var(--muted)", fontSize: 13 }}>
                                                No messages yet.
                                            </div>
                                        ) : visibleMessages.map((m) => {
                                            // "user" since 2026-08-13 — ACTOR_CONTACT was renamed with the
                                            // anonymous identity it named. This still read "contact", so every
                                            // customer message rendered on the ORG side of the thread. Exactly
                                            // the defect T19-a fixed in User\DashboardController; this site was
                                            // missed because it is a JSX literal, not the PHP constant.
                                            const isUserSide = m.actor_type === "user";
                                            const ptype = m.payload?.type;
                                            const fmtHKD = (n) => 'HK$ ' + Number(n || 0).toLocaleString("en-HK");
                                            const focused = m.id === focusedMessageId;
                                            return (
                                                <div key={m.id} data-msg-id={m.id} style={{
                                                    display: "flex", flexDirection: "column",
                                                    alignItems: isUserSide ? "flex-start" : "flex-end",
                                                    // Lit rather than outlined: a border would resize the row and
                                                    // shift everything under it as the highlight fades.
                                                    borderRadius: 14,
                                                    background: focused ? "var(--amber-bg, #fff6e5)" : "transparent",
                                                    boxShadow: focused ? "0 0 0 2px var(--amber, #c98a14)" : "none",
                                                    padding: focused ? 6 : 0,
                                                    transition: "background .35s, box-shadow .35s, padding .35s",
                                                }}>
                                                    {m.id === firstNewId && (
                                                        <div data-new-divider style={{ alignSelf: "stretch", display: "flex", alignItems: "center", gap: 10, margin: "6px 0 4px", color: "var(--brand)", fontSize: 11, fontWeight: 700, letterSpacing: ".06em", textTransform: "uppercase" }}>
                                                            <span style={{ flex: 1, height: 1, background: "var(--brand)", opacity: .35 }} />
                                                            New messages
                                                            <span style={{ flex: 1, height: 1, background: "var(--brand)", opacity: .35 }} />
                                                        </div>
                                                    )}
                                                    {ptype === "invoice" ? (
                                                        <div style={{ maxWidth: "88%", border: "1.5px solid var(--blush)", background: "linear-gradient(180deg,#fff,#fffafb)", borderRadius: 14, padding: 12, boxShadow: "0 4px 14px rgba(40,16,21,.06)" }}>
                                                            <div style={{ fontSize: 11, fontWeight: 700, color: "var(--brand-700)", textTransform: "uppercase", letterSpacing: ".06em", marginBottom: 6 }}>Invoice sent</div>
                                                            <div style={{ fontSize: 22, fontWeight: 800, color: "var(--brand)", fontFamily: "var(--mono)", letterSpacing: "-.01em", marginBottom: 6 }}>{fmtHKD(m.payload?.amount)}</div>
                                                            {m.payload?.service ? <div style={{ fontSize: 12.5, color: "var(--ink-2)" }}>for {m.payload.service}</div> : null}
                                                            {m.payload?.note ? <div style={{ fontSize: 12.5, color: "var(--muted)", marginTop: 6, whiteSpace: "pre-wrap" }}>{m.payload.note}</div> : null}
                                                        </div>
                                                    ) : ptype === "receipt_uploaded" ? (
                                                        <div style={{ maxWidth: "88%", border: "1.5px solid var(--amber)", background: "linear-gradient(180deg,#fff,#fffaf0)", borderRadius: 14, padding: 12 }}>
                                                            <div style={{ fontSize: 11, fontWeight: 700, color: "var(--amber)", textTransform: "uppercase", letterSpacing: ".06em", marginBottom: 6 }}>Receipt uploaded · awaiting verification</div>
                                                            <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 8 }}>
                                                                <span style={{ width: 30, height: 30, borderRadius: 8, background: "var(--amber-bg)", color: "var(--amber)", display: "grid", placeItems: "center", fontWeight: 800, fontFamily: "var(--mono)" }}>$</span>
                                                                <div>
                                                                    <div style={{ fontSize: 14, fontWeight: 700, color: "var(--ink)" }}>{fmtHKD(m.payload?.amount)}</div>
                                                                    <div style={{ fontSize: 11.5, color: "var(--muted)", fontFamily: "var(--mono)" }}>{m.payload?.file_name}</div>
                                                                </div>
                                                            </div>
                                                            <button type="button" className="pt-btn pt-btn-primary pt-btn-sm" onClick={() => {
                                                                const pid = m.payload?.payment_id;
                                                                const resolution = detail.messages.find(other =>
                                                                    (other.payload?.type === "payment_verified" || other.payload?.type === "payment_rejected")
                                                                    && other.payload?.payment_id === pid
                                                                    && other.id > m.id);
                                                                openVerify({
                                                                    payment_id: pid,
                                                                    code: m.payload?.code,
                                                                    amount: m.payload?.amount,
                                                                    file_name: m.payload?.file_name,
                                                                    file_size: m.payload?.file_size,
                                                                    file_url: m.payload?.file_url,
                                                                    file_mime: m.payload?.file_mime,
                                                                    uploaded_at: m.created_at,
                                                                    service: conversationContext.service,
                                                                    customer_name: conversationContext.customer_name,
                                                                    booking_ref: conversationContext.booking_ref,
                                                                    resolution: resolution
                                                                        ? { kind: resolution.payload?.type, reason: resolution.payload?.reason, at: resolution.created_at }
                                                                        : null,
                                                                });
                                                            }}>
                                                                Verify payment
                                                            </button>
                                                        </div>
                                                    ) : ptype === "payment_rejected" ? (
                                                        <div style={{ maxWidth: "88%", border: "1.5px solid var(--red)", background: "linear-gradient(180deg,#fff,#fbe7ea)", borderRadius: 14, padding: 12 }}>
                                                            <div style={{ fontSize: 11, fontWeight: 700, color: "var(--red)", textTransform: "uppercase", letterSpacing: ".06em", marginBottom: 6 }}>✕ Payment rejected</div>
                                                            <div style={{ fontSize: 18, fontWeight: 800, color: "var(--ink)", fontFamily: "var(--mono)" }}>{fmtHKD(m.payload?.amount)}</div>
                                                            {m.payload?.reason ? <div style={{ fontSize: 12.5, color: "var(--ink-2)", marginTop: 6, whiteSpace: "pre-wrap" }}>Reason: {m.payload.reason}</div> : null}
                                                            <div style={{ fontSize: 11.5, color: "var(--muted)", marginTop: 4 }}>Asked the user to re-upload.</div>
                                                        </div>
                                                    ) : ptype === "payment_verified" ? (
                                                        <div style={{ maxWidth: "88%", border: "1.5px solid var(--green)", background: "linear-gradient(180deg,#fff,#f0fbf5)", borderRadius: 14, padding: 12 }}>
                                                            <div style={{ fontSize: 11, fontWeight: 700, color: "var(--green)", textTransform: "uppercase", letterSpacing: ".06em", marginBottom: 6 }}>✓ Payment verified</div>
                                                            <div style={{ fontSize: 18, fontWeight: 800, color: "var(--ink)", fontFamily: "var(--mono)" }}>{fmtHKD(m.payload?.amount)}</div>
                                                            {m.payload?.service ? <div style={{ fontSize: 12.5, color: "var(--muted)", marginTop: 4 }}>{m.payload.service}</div> : null}
                                                        </div>
                                                    ) : ptype === "choice_request" ? (
                                                        (() => {
                                                            const answer = detail.messages.find(o => o.payload?.type === "choice_response" && o.payload?.request_id === ("msg_" + m.id));
                                                            return (
                                                                <div style={{ maxWidth: "88%", border: "1.5px solid var(--amber)", background: "linear-gradient(180deg,#fff,#fffaf0)", borderRadius: 14, padding: 12 }}>
                                                                    <div style={{ fontSize: 11, fontWeight: 700, color: "var(--amber)", textTransform: "uppercase", letterSpacing: ".06em", marginBottom: 6 }}>2-way · awaiting reply</div>
                                                                    <div style={{ fontSize: 13.5, color: "var(--ink)", lineHeight: 1.5, whiteSpace: "pre-wrap", marginBottom: 8 }}><Linkified text={m.payload?.text} /></div>
                                                                    <div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
                                                                        {(m.payload?.options || []).map((opt, oi) => (
                                                                            <span key={oi} style={{
                                                                                padding: "4px 10px", borderRadius: 999, fontSize: 12, fontWeight: 600,
                                                                                background: answer?.payload?.choice === opt ? "var(--green-bg)" : "var(--surface-2)",
                                                                                color: answer?.payload?.choice === opt ? "var(--green)" : "var(--muted)",
                                                                                border: answer?.payload?.choice === opt ? "1px solid var(--green)" : "1px solid var(--line)",
                                                                            }}>
                                                                                {answer?.payload?.choice === opt ? "✓ " : ""}{opt}
                                                                            </span>
                                                                        ))}
                                                                    </div>
                                                                </div>
                                                            );
                                                        })()
                                                    ) : ptype === "choice_response" ? (
                                                        <div style={{ maxWidth: "88%", border: "1.5px solid var(--green)", background: "linear-gradient(180deg,#fff,#f0fbf5)", borderRadius: 14, padding: 12 }}>
                                                            <div style={{ fontSize: 11, fontWeight: 700, color: "var(--green)", textTransform: "uppercase", letterSpacing: ".06em", marginBottom: 6 }}>↳ Customer chose</div>
                                                            <div style={{ fontSize: 14, fontWeight: 700, color: "var(--ink)" }}>{m.payload?.choice}</div>
                                                            {m.payload?.question ? <div style={{ fontSize: 11.5, color: "var(--muted)", marginTop: 4, fontStyle: "italic" }}>“{m.payload.question}”</div> : null}
                                                        </div>
                                                    ) : ptype === "reschedule_request" ? (
                                                        (() => {
                                                            const confirmed = detail.messages.find(o => o.payload?.type === "reschedule_confirmed" && o.payload?.request_id === ("msg_" + m.id));
                                                            return (
                                                                <div style={{ maxWidth: "88%", border: "1.5px solid var(--amber)", background: "linear-gradient(180deg,#fff,#fffaf0)", borderRadius: 14, padding: 12 }}>
                                                                    <div style={{ fontSize: 11, fontWeight: 700, color: "var(--amber)", textTransform: "uppercase", letterSpacing: ".06em", marginBottom: 6 }}>
                                                                        {confirmed ? "↺ Reschedule sent · customer replied" : "↺ Reschedule sent · awaiting reply"}
                                                                    </div>
                                                                    <div style={{ fontSize: 13.5, color: "var(--ink)", lineHeight: 1.5, whiteSpace: "pre-wrap", marginBottom: 6 }}><Linkified text={m.payload?.text} /></div>
                                                                    {m.payload?.booking_ref ? <div style={{ fontSize: 12, color: "var(--muted)", fontFamily: "var(--mono)" }}>Booking {m.payload.booking_ref}</div> : null}
                                                                    <div style={{ fontSize: 12, color: "var(--muted)" }}>From {m.payload?.old_when ? new Date(m.payload.old_when).toLocaleString("en-HK", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) : "—"}</div>
                                                                </div>
                                                            );
                                                        })()
                                                    ) : ptype === "reschedule_confirmed" ? (
                                                        <div style={{ maxWidth: "88%", border: "1.5px solid var(--green)", background: "linear-gradient(180deg,#fff,#f0fbf5)", borderRadius: 14, padding: 12 }}>
                                                            <div style={{ fontSize: 11, fontWeight: 700, color: "var(--green)", textTransform: "uppercase", letterSpacing: ".06em", marginBottom: 6 }}>✓ Rescheduled by customer</div>
                                                            <div style={{ fontSize: 13.5, color: "var(--ink)" }}>
                                                                <b style={{ textDecoration: "line-through", color: "var(--muted)" }}>{m.payload?.old_when ? new Date(m.payload.old_when).toLocaleString("en-HK", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) : ""}</b>
                                                                {" → "}
                                                                <b>{m.payload?.new_when ? new Date(m.payload.new_when).toLocaleString("en-HK", { weekday: "short", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) : ""}</b>
                                                            </div>
                                                            {m.payload?.booking_ref ? <div style={{ fontSize: 12, color: "var(--muted)", fontFamily: "var(--mono)", marginTop: 4 }}>Booking {m.payload.booking_ref}</div> : null}
                                                        </div>
                                                    ) : (
                                                        (() => {
                                                            const atts = Array.isArray(m.payload?.attachments) ? m.payload.attachments : [];
                                                            // A FORM'S ANSWERS ARE A LIST, not a sentence. `payload.text` joins them with
                                                            // middots because it is the one-line preview a notification and the search
                                                            // index use; a staffer reading the case wants a line per question.
                                                            // (Arfu, 18/09/2026)
                                                            const asked = m.payload?.type === "form_result" && Array.isArray(m.payload?.answers)
                                                                ? m.payload.answers : null;
                                                            const text = asked ? "" : (m.payload?.text || (atts.length === 0 ? describePayload(m.payload) : ""));
                                                            return (
                                                                <div style={{ maxWidth: "78%", display: "flex", flexDirection: "column", alignItems: isUserSide ? "flex-start" : "flex-end", gap: 6 }}>
                                                                    {/* THE FORM, FILLED IN. What the customer did was fill a form,
                                                                        so this shows one — the questions in order, each with its
                                                                        answer in the box — rather than a sentence of middots, which
                                                                        is what `payload.text` is for (a notification preview, the
                                                                        search index). (Arfu, 18/09/2026) */}
                                                                    {asked ? (
                                                                        <div style={{
                                                                            background: "var(--surface)", border: "1px solid var(--line)",
                                                                            borderRadius: 14, padding: "12px 14px",
                                                                            display: "flex", flexDirection: "column", gap: 10, minWidth: 260,
                                                                        }}>
                                                                            {m.payload?.title ? (
                                                                                <div style={{ fontSize: 13, fontWeight: 700, color: "var(--ink)" }}>{m.payload.title}</div>
                                                                            ) : null}
                                                                            {asked.map((one, n) => (
                                                                                <div key={n} style={{ display: "flex", flexDirection: "column", gap: 3 }}>
                                                                                    <div style={{ fontSize: 11.5, color: "var(--muted)", lineHeight: 1.4 }}>
                                                                                        {one.label || one.name}
                                                                                    </div>
                                                                                    <div style={{
                                                                                        fontSize: 13, color: "var(--ink)", background: "var(--surface-2)",
                                                                                        border: "1px solid var(--line)", borderRadius: 9,
                                                                                        padding: "6px 9px", whiteSpace: "pre-wrap",
                                                                                    }}>{one.value}</div>
                                                                                </div>
                                                                            ))}
                                                                        </div>
                                                                    ) : null}
                                                                    {text ? (
                                                                        <div style={{
                                                                            background: isUserSide ? "var(--surface-2)" : "var(--brand)",
                                                                            color: isUserSide ? "var(--ink)" : "#fff",
                                                                            border: isUserSide ? "1px solid var(--line)" : "none",
                                                                            borderRadius: 14,
                                                                            borderBottomLeftRadius: isUserSide ? 4 : 14,
                                                                            borderBottomRightRadius: isUserSide ? 14 : 4,
                                                                            padding: "9px 13px",
                                                                            fontSize: 13.5, lineHeight: 1.5, whiteSpace: "pre-wrap",
                                                                            boxShadow: isUserSide ? "none" : "0 2px 8px rgba(191,53,80,.2)",
                                                                        }}><Linkified text={text} /></div>
                                                                    ) : null}
                                                                    {atts.map((a, ai) => (
                                                                        a.kind === "image" ? (
                                                                            <a key={ai} href={a.url} target="_blank" rel="noreferrer" style={{ display: "block", borderRadius: 12, overflow: "hidden", border: "1px solid var(--line)", maxWidth: 260 }}>
                                                                                <img src={a.url} alt={a.name || ""} style={{ display: "block", maxWidth: 260, maxHeight: 260, objectFit: "cover" }} />
                                                                            </a>
                                                                        ) : (
                                                                            // Opened in a tab like the member's panel does, not downloaded: a
                                                                            // staffer reading a case wants to look, not collect files. (Arfu, 02/10/2026)
                                                                            <a key={ai} href={a.url} target="_blank" rel="noreferrer" title={a.name} style={{
                                                                                display: "inline-flex", alignItems: "center", gap: 10,
                                                                                padding: "8px 12px", borderRadius: 12,
                                                                                border: "1px solid var(--line)", background: "var(--surface-2)",
                                                                                color: "var(--ink)", textDecoration: "none", maxWidth: 360,
                                                                            }}>
                                                                                <span style={{ width: 28, height: 28, borderRadius: 8, background: "var(--blush-bg)", color: "var(--brand)", display: "grid", placeItems: "center", fontWeight: 800, fontFamily: "var(--mono)", fontSize: 12 }}>F</span>
                                                                                <span style={{ minWidth: 0, flex: 1 }}>
                                                                                    <div style={{ fontSize: 12.5, fontWeight: 600, overflowWrap: "anywhere" }}>{a.name}</div>
                                                                                    <div style={{ fontSize: 11, color: "var(--muted)", fontFamily: "var(--mono)" }}>{fmtBytes(a.size)}</div>
                                                                                </span>
                                                                            </a>
                                                                        )
                                                                    ))}
                                                                </div>
                                                            );
                                                        })()
                                                    )}
                                                    <div style={{ fontSize: 10.5, color: "var(--faint)", marginTop: 4, display: "flex", gap: 6, alignItems: "center" }}>
                                                        <ActorTag type={m.actor_type} />
                                                        {/* WHERE IT WENT (FP-T4, A8). One room holds
                                                            what a member said in the app and what they
                                                            said on WhatsApp, so the transcript has to be
                                                            able to tell them apart — and an unmarked
                                                            line means the app, which is the ordinary
                                                            case and needs no badge. */}
                                                        {MESSAGE_CHANNEL_LABEL[m.channel] ? (
                                                            <span className="pt-badge pt-b-grey" style={{ padding: "1px 6px", fontSize: 10 }}>
                                                                {MESSAGE_CHANNEL_LABEL[m.channel]}
                                                            </span>
                                                        ) : null}
                                                        <span>{m.created_at ? new Date(m.created_at).toLocaleString("en-HK", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) : ""}</span>
                                                    </div>
                                                </div>
                                            );
                                        })}
                                    </div>
                                    {/* Outside the scroller, or it would scroll away with the
                                        messages it announces. The pad above is the positioned
                                        ancestor it floats in. */}
                                    <NewMessages count={behind} onClick={jumpToLatest} />

                                    {pendingGate && !gateIsUnresolvedReceipt && onThreadOf(pendingGate) && (
                                        <div data-verify-gate={pendingGate.id} style={{ flexShrink: 0, marginTop: 16, padding: 14, border: "1.5px solid var(--amber)", background: "linear-gradient(180deg,#fff,#fffaf0)", borderRadius: 12 }}>
                                            <div style={{ fontSize: 11, fontWeight: 700, color: "var(--amber)", textTransform: "uppercase", letterSpacing: ".06em", marginBottom: 8 }}>
                                                Awaiting your verification
                                            </div>
                                            {gatePrompt ? (
                                                <div style={{ fontSize: 13.5, color: "var(--ink)", marginBottom: 10 }}>{gatePrompt}</div>
                                            ) : null}
                                            {gateEvidence ? (
                                                <div style={{ marginBottom: 10, padding: 10, borderRadius: 10, background: "var(--surface-2)", border: "1px solid var(--line)" }}>
                                                    <div style={{ fontSize: 10.5, fontWeight: 700, color: "var(--muted)", textTransform: "uppercase", letterSpacing: ".05em", marginBottom: 6 }}>
                                                        What they sent
                                                    </div>
                                                    {(() => {
                                                        const text = gateEvidence.payload?.payload?.text || gateEvidence.payload?.text;
                                                        const files = gateEvidence.payload?.attachments || gateEvidence.payload?.payload?.attachments || [];
                                                        // An empty labelled box says less than a sentence. If they sent
                                                        // nothing, say THAT — it is a fact about the decision you are
                                                        // being asked to make, not a rendering gap to leave blank.
                                                        if (!text && !files.length) {
                                                            return <div style={{ fontSize: 13, color: "var(--muted)" }}>Nothing — no note and no files.</div>;
                                                        }

                                                        return text ? <div style={{ fontSize: 13, color: "var(--ink)" }}><Linkified text={text} /></div> : null;
                                                    })()}
                                                    {(gateEvidence.payload?.attachments || gateEvidence.payload?.payload?.attachments || []).map((a, i) => (
                                                        <a key={i} href={a.url} target="_blank" rel="noreferrer"
                                                            style={{ display: "inline-block", marginTop: 6, marginRight: 8 }}>
                                                            {a.kind === "image"
                                                                ? <img src={a.url} alt={a.name || ""} style={{ display: "block", maxWidth: 140, maxHeight: 140, borderRadius: 8, border: "1px solid var(--line)" }} />
                                                                : <span style={{ fontSize: 12.5, color: "var(--brand)", textDecoration: "underline" }}>{a.name}</span>}
                                                        </a>
                                                    ))}
                                                </div>
                                            ) : null}
                                            <input className="pt-input" value={gateNote} onChange={e => setGateNote(e.target.value)}
                                                placeholder="Add a note for the customer (optional)" />
                                            <div style={{ display: "flex", gap: 8, justifyContent: "flex-end", marginTop: 10 }}>
                                                <button type="button" className="pt-btn pt-btn-soft pt-btn-sm" disabled={gateBusy} onClick={() => resolveGate(false)}>
                                                    {gateBusy ? "Saving…" : "Reject"}
                                                </button>
                                                <button type="button" className="pt-btn pt-btn-primary pt-btn-sm" disabled={gateBusy} onClick={() => resolveGate(true)}>
                                                    {gateBusy ? "Saving…" : "Approve"}
                                                </button>
                                            </div>
                                        </div>
                                    )}

                                    {externalWait && (
                                        <div data-external-wait={externalWait.flow_run_id} style={{ flexShrink: 0, marginTop: 16, padding: 14, border: "1.5px solid var(--amber)", background: "linear-gradient(180deg,#fff,#fffaf0)", borderRadius: 12, display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap" }}>
                                            <div style={{ flex: "1 1 240px" }}>
                                                <div style={{ fontSize: 11, fontWeight: 700, color: "var(--amber)", textTransform: "uppercase", letterSpacing: ".06em", marginBottom: 6 }}>
                                                    Waiting on {externalWait.body}
                                                </div>
                                                <div style={{ fontSize: 12.5, color: "var(--muted)" }}>
                                                    asked {ago(externalWait.since)} · {dueLabel(externalWait.due, externalWait.working_days_left)}
                                                </div>
                                            </div>
                                            <button type="button" className="pt-btn pt-btn-primary pt-btn-sm" onClick={() => setAnsweringExternal(true)}>
                                                Record what they said
                                            </button>
                                        </div>
                                    )}
                                    {comingBack && (
                                        <div data-comes-back={comingBack._id ?? comingBack.id} style={{ flexShrink: 0, marginTop: 16, padding: 14, border: "1px solid var(--line)", background: "var(--surface-2)", borderRadius: 12 }}>
                                            <div style={{ fontSize: 11, fontWeight: 700, color: "var(--muted)", textTransform: "uppercase", letterSpacing: ".06em", marginBottom: 6 }}>
                                                Waiting for a date
                                            </div>
                                            <div style={{ fontSize: 13, color: "var(--ink)" }}>
                                                Comes back by itself on <b>{new Date(comingBack.comes_back_at).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" })}</b>. Nothing to do until then.
                                            </div>
                                        </div>
                                    )}
                                    {pendingFetch && onThreadOf(pendingFetch) && (
                                        <HandedOverFetch run={pendingFetch} onDone={async () => {
                                            const res = await axios.get("/conversation/get", { params: { id: detail.conversation.id } });
                                            setDetail(res.data);
                                        }} />
                                    )}
                                    {answeringExternal && externalWait && (
                                        <ExternalAnswerDialog wait={externalWait} onClose={() => setAnsweringExternal(false)}
                                            onDone={async () => {
                                                setAnsweringExternal(false);
                                                const res = await axios.get("/conversation/get", { params: { id: detail.conversation.id } });
                                                setDetail(res.data);
                                            }} />
                                    )}

                                    <form onSubmit={sendReply} style={{ flexShrink: 0, marginTop: 16, paddingTop: 14, borderTop: "1px solid var(--line)", background: "var(--surface)" }}>
                                        <div style={{ fontSize: 11.5, fontWeight: 700, color: "var(--muted)", textTransform: "uppercase", letterSpacing: ".06em", marginBottom: 8 }}>
                                            Reply as org staff
                                        </div>
                                        {replyAttachments.length > 0 && (
                                            <div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginBottom: 8 }}>
                                                {replyAttachments.map((a, i) => (
                                                    <div key={i} style={{ position: "relative", border: "1px solid var(--line)", borderRadius: 10, background: "var(--surface-2)", overflow: "hidden", maxWidth: 200 }}>
                                                        {a.kind === "image" ? (
                                                            <img src={a.url} alt={a.name} style={{ display: "block", width: 64, height: 64, objectFit: "cover" }} />
                                                        ) : (
                                                            <div style={{ padding: "10px 12px", fontSize: 12, color: "var(--ink)", maxWidth: 200, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
                                                                {a.name}
                                                                <div style={{ fontSize: 10.5, color: "var(--muted)", fontFamily: "var(--mono)" }}>{fmtBytes(a.size)}</div>
                                                            </div>
                                                        )}
                                                        <button type="button" onClick={() => removeReplyAttachment(i)} aria-label="Remove" style={{
                                                            position: "absolute", top: 2, right: 2, width: 18, height: 18, borderRadius: "50%",
                                                            background: "rgba(35,20,23,.75)", color: "#fff", border: "none", cursor: "pointer",
                                                            display: "grid", placeItems: "center", fontSize: 11, lineHeight: 1, padding: 0,
                                                        }}>×</button>
                                                    </div>
                                                ))}
                                            </div>
                                        )}
                                        <textarea
                                            ref={replyRef}
                                            className="pt-textarea"
                                            value={reply}
                                            onChange={e => setReply(e.target.value)}
                                            rows={3}
                                            placeholder="Type a reply or pick a template…"
                                        />
                                        <input ref={fileRef} type="file" multiple
                                            accept="image/*,application/pdf,.doc,.docx,.txt"
                                            onChange={onPickReplyFiles} style={{ display: "none" }} />
                                        <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginTop: 10, gap: 10 }}>
                                            <div style={{ display: "flex", gap: 6, alignItems: "center" }}>
                                                <TemplatePicker templates={templates} onPick={applyTemplate} />
                                                {/* Asking a question was reachable ONLY by picking a
                                                    template whose pattern happens to be 2-way — so an
                                                    org that had authored none could not send one at
                                                    all, and nothing on the composer said the ability
                                                    existed. The endpoint and this panel were both
                                                    already here. (Arfu, 02/09/2026) */}
                                                <button type="button" className="pt-btn pt-btn-soft pt-btn-sm"
                                                    onClick={() => {
                                                        // Carry whatever they have typed into the
                                                        // question, rather than throwing it away.
                                                        setChoiceText(reply.trim());
                                                        if (!choiceOptions.length) setChoiceOptions(["Yes", "No"]);
                                                        setChoiceOpen(true);
                                                    }}>
                                                    + Question
                                                </button>
                                                <button type="button" className="pt-btn pt-btn-soft pt-btn-sm" onClick={() => fileRef.current?.click()}>
                                                    + Attach
                                                </button>
                                            </div>
                                            <button type="submit" className="pt-btn pt-btn-primary" disabled={!reply.trim() && replyAttachments.length === 0}>
                                                Send reply
                                            </button>
                                        </div>
                                    </form>

                                    {invoiceOpen && (
                                        <form onSubmit={sendInvoice} style={{ flexShrink: 0, marginTop: 16, padding: 14, border: "1.5px solid var(--blush)", background: "var(--blush-bg)", borderRadius: 12 }}>
                                            <div style={{ fontSize: 12, fontWeight: 700, color: "var(--brand-700)", textTransform: "uppercase", letterSpacing: ".06em", marginBottom: 10 }}>
                                                Send payment invoice
                                            </div>
                                            <div style={{ display: "flex", gap: 10, alignItems: "flex-end" }}>
                                                <div style={{ flex: "0 0 160px" }}>
                                                    <label style={{ display: "block", fontSize: 11, color: "var(--muted)", fontWeight: 600, marginBottom: 4 }}>Amount (HKD)</label>
                                                    <input
                                                        type="number" min="1" step="1"
                                                        className="pt-input"
                                                        value={invoiceAmount}
                                                        onChange={e => setInvoiceAmount(e.target.value)}
                                                        placeholder="e.g. 1200"
                                                        style={{ height: 40, fontFamily: "var(--mono)" }}
                                                        autoFocus
                                                    />
                                                </div>
                                                <div style={{ flex: 1 }}>
                                                    <label style={{ display: "block", fontSize: 11, color: "var(--muted)", fontWeight: 600, marginBottom: 4 }}>Note (optional)</label>
                                                    <input
                                                        className="pt-input"
                                                        value={invoiceNote}
                                                        onChange={e => setInvoiceNote(e.target.value)}
                                                        placeholder="Bank details, due date, …"
                                                        style={{ height: 40 }}
                                                    />
                                                </div>
                                            </div>
                                            <div style={{ display: "flex", justifyContent: "flex-end", gap: 8, marginTop: 12 }}>
                                                <button type="button" className="pt-btn pt-btn-ghost pt-btn-sm" onClick={() => setInvoiceOpen(false)}>Cancel</button>
                                                <button type="submit" className="pt-btn pt-btn-primary pt-btn-sm" disabled={invoiceSending || !(Number(invoiceAmount) > 0)}>
                                                    {invoiceSending ? "Sending…" : "Send invoice"}
                                                </button>
                                            </div>
                                        </form>
                                    )}

                                    {choiceOpen && (
                                        <form onSubmit={sendChoice} style={{ flexShrink: 0, marginTop: 16, padding: 14, border: "1.5px solid var(--amber)", background: "var(--amber-bg)", borderRadius: 12 }}>
                                            <div style={{ fontSize: 12, fontWeight: 700, color: "var(--amber)", textTransform: "uppercase", letterSpacing: ".06em", marginBottom: 10 }}>
                                                Send 2-way prompt
                                            </div>
                                            <label style={{ display: "block", fontSize: 11, color: "var(--muted)", fontWeight: 600, marginBottom: 4 }}>Question</label>
                                            <textarea
                                                className="pt-textarea"
                                                value={choiceText}
                                                onChange={(e) => setChoiceText(e.target.value)}
                                                rows={2}
                                                placeholder="What would you like to ask the customer?"
                                            />
                                            <label style={{ display: "block", fontSize: 11, color: "var(--muted)", fontWeight: 600, marginTop: 10, marginBottom: 4 }}>Options ({choiceOptions.length})</label>
                                            <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
                                                {choiceOptions.map((opt, oi) => (
                                                    <div key={oi} style={{ display: "flex", gap: 6, alignItems: "center" }}>
                                                        <input
                                                            className="pt-input"
                                                            value={opt}
                                                            onChange={(e) => setChoiceOptions(prev => prev.map((p, idx) => idx === oi ? e.target.value : p))}
                                                            placeholder={`Option ${oi + 1}`}
                                                            style={{ flex: 1, height: 34 }}
                                                        />
                                                        <button type="button" className="pt-btn pt-btn-ghost pt-btn-sm"
                                                            onClick={() => setChoiceOptions(prev => prev.filter((_, idx) => idx !== oi))}
                                                            disabled={choiceOptions.length <= 2}>−</button>
                                                    </div>
                                                ))}
                                                <button type="button" className="pt-btn pt-btn-ghost pt-btn-sm" style={{ alignSelf: "flex-start" }}
                                                    onClick={() => setChoiceOptions(prev => [...prev, ""])}
                                                    disabled={choiceOptions.length >= 12}>+ Add option</button>
                                            </div>
                                            <div style={{ display: "flex", justifyContent: "flex-end", gap: 8, marginTop: 12 }}>
                                                <button type="button" className="pt-btn pt-btn-ghost pt-btn-sm" onClick={() => setChoiceOpen(false)}>Cancel</button>
                                                <button type="submit" className="pt-btn pt-btn-primary pt-btn-sm"
                                                    disabled={choiceSending || !choiceText.trim() || choiceOptions.filter(o => String(o).trim()).length < 2}>
                                                    {choiceSending ? "Sending…" : "Send prompt"}
                                                </button>
                                            </div>
                                        </form>
                                    )}
                                </div>
                            ) : detailTab === "documents" ? (
                                <div className="pt-card-pad" style={{ flex: 1, minHeight: 0, overflowY: "auto" }}>
                                    {/* PER ENGAGEMENT, not per room: a room holds several, and a
                                        statement produced for one is not paperwork for the next.
                                        Main has no engagement, so it has no paperwork. */}
                                    {currentBookingId ? (
                                        <BookingDocuments
                                            key={currentBookingId}
                                            booking={{ id: currentBookingId, code: currentThreadLabel }}
                                            showInternal
                                            manage
                                            embedded />
                                    ) : (
                                        <div style={{ color: "var(--muted)", fontSize: 13 }}>
                                            Pick a conversation above. The room's own thread holds no engagement, so there is no paperwork filed against it.
                                        </div>
                                    )}
                                </div>
                            ) : detailTab === "people" ? (
                                <div className="pt-card-pad" style={{ flex: 1, minHeight: 0, overflowY: "auto" }}>
                                    <div style={{ fontSize: 11.5, fontWeight: 700, color: "var(--muted)", textTransform: "uppercase", letterSpacing: ".06em", marginBottom: 10 }}>
                                        In this conversation
                                    </div>
                                    <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
                                        {people.length === 0 ? (
                                            <div style={{ color: "var(--muted)", fontSize: 13 }}>Nobody loaded yet.</div>
                                        ) : people.map((row) => (
                                            <div key={row.user_id} style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 12px", border: "1px solid var(--line)", borderRadius: 10, background: "var(--surface)" }}>
                                                <div style={{ flex: 1, minWidth: 0 }}>
                                                    <div style={{ fontSize: 13.5, fontWeight: 600 }}>{row.name || `#${row.user_id}`}</div>
                                                    <div style={{ fontSize: 11.5, color: "var(--muted)" }}>
                                                        {row.role || "no role — cannot answer a step addressed to a side"}
                                                    </div>
                                                </div>
                                            </div>
                                        ))}
                                    </div>
                                </div>
                            ) : (
                                <div className="pt-card-pad" style={{ flex: 1, minHeight: 0, overflowY: "auto" }}>
                                    <div style={{ marginBottom: 18 }}>
                                        <div style={{ fontSize: 11.5, fontWeight: 700, color: "var(--muted)", textTransform: "uppercase", letterSpacing: ".06em", marginBottom: 6 }}>Vars</div>
                                        {/* Flow variables belong to the RUN. Reading them off the room
                                            printed an empty object on every conversation. */}
                                        <pre style={{ background: "var(--surface-2)", border: "1px solid var(--line)", borderRadius: 10, padding: 12, fontSize: 12, color: "var(--ink-2)", maxHeight: 220, overflow: "auto", margin: 0 }}>
                                            {JSON.stringify(activeRun?.vars ?? {}, null, 2)}
                                        </pre>
                                    </div>

                                    <div style={{ fontSize: 11.5, fontWeight: 700, color: "var(--muted)", textTransform: "uppercase", letterSpacing: ".06em", marginBottom: 8 }}>
                                        Timeline ({detail.messages.length})
                                    </div>
                                    <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
                                        {detail.messages.map((m) => (
                                            <div key={m.id} style={{
                                                background: m.direction === "inbound" ? "var(--surface-2)" : "var(--blush-bg)",
                                                border: "1px solid var(--line)", borderRadius: 11, padding: 11,
                                            }}>
                                                <div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 4 }}>
                                                    <span style={{ fontWeight: 700, fontSize: 12 }}>
                                                        {m.direction === "inbound" ? "← in" : "→ out"}
                                                    </span>
                                                    <ActorTag type={m.actor_type} />
                                                    <span style={{ fontSize: 11, color: "var(--faint)", fontFamily: "var(--mono)" }}>
                                                        node {m.node_id}
                                                    </span>
                                                    <span style={{ marginLeft: "auto", fontSize: 11, color: "var(--faint)" }}>
                                                        {m.created_at ? new Date(m.created_at).toLocaleString("en-HK") : ""}
                                                    </span>
                                                </div>
                                                <div style={{ fontSize: 13.5, color: "var(--ink)", lineHeight: 1.5, whiteSpace: "pre-wrap" }}>
                                                    {describePayload(m.payload)}
                                                </div>
                                            </div>
                                        ))}
                                    </div>
                                </div>
                            )}
                        </>
                    ) : null}
                </div>
            </div>

            {verifyPayload && (
                <div
                    role="dialog"
                    aria-modal="true"
                    onClick={(e) => { if (e.target === e.currentTarget) closeVerify(); }}
                    style={{
                        position: "fixed", inset: 0, background: "transparent",
                        zIndex: 220,
                        display: "grid", placeItems: "center", padding: 16,
                    }}>
                    <div style={{
                        width: "min(460px, 96vw)", background: "var(--surface)",
                        border: "1px solid var(--line)", borderRadius: 16,
                        boxShadow: "0 24px 64px rgba(40,16,21,.22), 0 0 0 1px rgba(40,16,21,.06)",
                        overflow: "hidden", fontFamily: "var(--font)",
                        maxHeight: "calc(100vh - 32px)", display: "flex", flexDirection: "column",
                    }}>
                        <div style={{ padding: "16px 20px", borderBottom: "1px solid var(--line)", display: "flex", alignItems: "center", justifyContent: "space-between" }}>
                            <div>
                                <div style={{ fontSize: 16, fontWeight: 700, color: "var(--ink)" }}>Verify payment</div>
                                <div style={{ fontSize: 12, color: "var(--muted)", marginTop: 2 }}>Review the receipt the user uploaded.</div>
                            </div>
                            <button type="button" onClick={closeVerify} aria-label="Close" style={{ background: "none", border: "none", cursor: "pointer", color: "var(--muted)", fontSize: 20, lineHeight: 1, padding: 0 }}>×</button>
                        </div>

                        {(() => {
                            const resolved = verifyPayload.resolution;
                            const statusLabel = resolved?.kind === "payment_verified" ? "Verified"
                                : resolved?.kind === "payment_rejected" ? "Rejected"
                                : "Awaiting verification";
                            const statusBg = resolved?.kind === "payment_verified" ? "var(--green-bg)"
                                : resolved?.kind === "payment_rejected" ? "var(--red-bg)"
                                : "var(--amber-bg)";
                            const statusFg = resolved?.kind === "payment_verified" ? "var(--green)"
                                : resolved?.kind === "payment_rejected" ? "var(--red)"
                                : "var(--amber)";
                            const isImage = (verifyPayload.file_mime || "").startsWith("image/");
                            return (
                                <div style={{ padding: "16px 20px", display: "flex", flexDirection: "column", gap: 14 }}>
                                    <div style={{ background: "var(--surface-2)", border: "1px solid var(--line)", borderRadius: 12, padding: 14, display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12 }}>
                                        <div>
                                            <div style={{ fontSize: 11, fontWeight: 700, color: "var(--muted)", textTransform: "uppercase", letterSpacing: ".06em", marginBottom: 4 }}>Amount</div>
                                            <div style={{ fontSize: 26, fontWeight: 800, color: "var(--brand)", fontFamily: "var(--mono)", letterSpacing: "-.01em" }}>
                                                HK$ {Number(verifyPayload.amount || 0).toLocaleString("en-HK")}
                                            </div>
                                        </div>
                                        <span style={{ background: statusBg, color: statusFg, padding: "6px 12px", borderRadius: 999, fontSize: 12, fontWeight: 700 }}>
                                            {statusLabel}
                                        </span>
                                    </div>

                                    {verifyPayload.file_url ? (
                                        <div>
                                            <div style={{ fontSize: 11, fontWeight: 700, color: "var(--muted)", textTransform: "uppercase", letterSpacing: ".06em", marginBottom: 6 }}>Receipt</div>
                                            {isImage ? (
                                                <a href={verifyPayload.file_url} target="_blank" rel="noreferrer"
                                                   style={{ display: "block", border: "1px solid var(--line)", borderRadius: 12, overflow: "hidden", background: "var(--surface-2)" }}>
                                                    <img src={verifyPayload.file_url} alt={verifyPayload.file_name || "receipt"}
                                                         style={{ display: "block", width: "100%", maxHeight: 320, objectFit: "contain", background: "#fff" }} />
                                                </a>
                                            ) : (
                                                <a href={verifyPayload.file_url} target="_blank" rel="noreferrer"
                                                   style={{ display: "flex", alignItems: "center", gap: 10, padding: 12, border: "1px solid var(--line)", borderRadius: 12, background: "var(--surface-2)", textDecoration: "none", color: "var(--ink)" }}>
                                                    <span style={{ width: 36, height: 36, borderRadius: 9, background: "var(--blush-bg)", color: "var(--brand)", display: "grid", placeItems: "center", fontWeight: 800, fontFamily: "var(--mono)" }}>📄</span>
                                                    <div style={{ minWidth: 0, flex: 1 }}>
                                                        <div style={{ fontSize: 13.5, fontWeight: 600, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{verifyPayload.file_name || "Open receipt"}</div>
                                                        <div style={{ fontSize: 11.5, color: "var(--muted)", fontFamily: "var(--mono)" }}>Open in new tab</div>
                                                    </div>
                                                </a>
                                            )}
                                        </div>
                                    ) : (
                                        <div style={{ fontSize: 13, color: "var(--muted)", padding: 12, background: "var(--surface-2)", borderRadius: 10, border: "1px dashed var(--line-2)" }}>
                                            No file attached.
                                        </div>
                                    )}

                                    <dl style={{ display: "grid", gridTemplateColumns: "120px 1fr", rowGap: 8, columnGap: 12, margin: 0, fontSize: 13 }}>
                                        {verifyPayload.customer_name ? (<>
                                            <dt style={{ color: "var(--muted)" }}>Customer</dt>
                                            <dd style={{ margin: 0, color: "var(--ink)", fontWeight: 600 }}>{verifyPayload.customer_name}</dd>
                                        </>) : null}
                                        {verifyPayload.service ? (<>
                                            <dt style={{ color: "var(--muted)" }}>Service</dt>
                                            <dd style={{ margin: 0, color: "var(--ink)" }}>{verifyPayload.service}</dd>
                                        </>) : null}
                                        {verifyPayload.booking_ref ? (<>
                                            <dt style={{ color: "var(--muted)" }}>Booking</dt>
                                            <dd style={{ margin: 0, color: "var(--ink)", fontFamily: "var(--mono)" }}>{verifyPayload.booking_ref}</dd>
                                        </>) : null}
                                        <dt style={{ color: "var(--muted)" }}>Receipt</dt>
                                        <dd style={{ margin: 0, color: "var(--ink)", fontFamily: "var(--mono)", wordBreak: "break-all" }}>{verifyPayload.file_name || "—"}</dd>
                                        {verifyPayload.uploaded_at ? (<>
                                            <dt style={{ color: "var(--muted)" }}>Uploaded</dt>
                                            <dd style={{ margin: 0, color: "var(--ink)" }}>{new Date(verifyPayload.uploaded_at).toLocaleString("en-HK", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" })}</dd>
                                        </>) : null}
                                        {/* The reference the customer is holding, not the row id —
                                            this is the dialog where the receipt is judged, so it is
                                            the one place both sides must be naming the same thing.
                                            Older messages carry no code and fall back to the id. */}
                                        <dt style={{ color: "var(--muted)" }}>Reference</dt>
                                        <dd style={{ margin: 0, color: "var(--ink)", fontFamily: "var(--mono)" }}>{verifyPayload.code || `#${verifyPayload.payment_id}`}</dd>
                                    </dl>

                                    {resolved ? (
                                        <div style={{
                                            background: statusBg, border: `1px solid ${statusFg}`, borderRadius: 10,
                                            padding: 12, fontSize: 12.5, color: statusFg,
                                        }}>
                                            <b>{statusLabel} on {resolved.at ? new Date(resolved.at).toLocaleString("en-HK", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) : "—"}</b>
                                            {resolved.reason ? <div style={{ marginTop: 4, whiteSpace: "pre-wrap" }}>Reason: {resolved.reason}</div> : null}
                                        </div>
                                    ) : (
                                        <div>
                                            <label style={{ display: "block", fontSize: 11, fontWeight: 700, color: "var(--muted)", textTransform: "uppercase", letterSpacing: ".06em", marginBottom: 6 }}>
                                                Rejection reason (optional — only used if you reject)
                                            </label>
                                            <textarea
                                                className="pt-textarea"
                                                value={rejectReason}
                                                onChange={e => setRejectReason(e.target.value)}
                                                rows={2}
                                                placeholder="e.g. The receipt amount doesn't match the invoice."
                                            />
                                        </div>
                                    )}
                                </div>
                            );
                        })()}

                        <div style={{ padding: "12px 20px 16px", display: "flex", justifyContent: "flex-end", gap: 8, borderTop: "1px solid var(--line)" }}>
                            <button type="button" className="pt-btn pt-btn-ghost" onClick={closeVerify} disabled={verifyBusy}>
                                {verifyPayload.resolution ? "Close" : "Cancel"}
                            </button>
                            <button type="button" className="pt-btn pt-btn-soft" onClick={rejectPayment}
                                disabled={verifyBusy || !!verifyPayload.resolution}
                                style={{ color: verifyPayload.resolution ? "var(--faint)" : "var(--red)", opacity: verifyPayload.resolution ? .5 : 1, cursor: verifyPayload.resolution ? "not-allowed" : "pointer" }}>
                                {verifyBusy ? "…" : "Reject"}
                            </button>
                            <button type="button" className="pt-btn pt-btn-primary" onClick={acceptPayment}
                                disabled={verifyBusy || !!verifyPayload.resolution}
                                style={{ opacity: verifyPayload.resolution ? .5 : 1, cursor: verifyPayload.resolution ? "not-allowed" : "pointer" }}>
                                {verifyBusy ? "Working…" : "Accept payment"}
                            </button>
                        </div>
                    </div>
                </div>
            )}
        </div>
    );
};

export default Inbox;
