import { useState } from "react"; import { useParams } from "react-router-dom"; import { AlertCircle, ArrowLeft, Loader2, MessagesSquare } from "lucide-react"; import { Alert, AlertDescription } from "../../../components/ui/alert.tsx"; import { Badge } from "../../../components/ui/badge.tsx"; import { EmptyState } from "../../../components/ui/empty-state.tsx"; import { useSupportTicketThread, TICKET_STATUS_LABELS, TICKET_STATUS_VARIANT, TICKET_HOLDER_LABELS, type SupportTicketThreadEvent, } from "../../../hooks/useSupportTickets"; import { formatDateTime } from "../../../utils/dateUtils"; /** * ONE TICKET, ITS THREAD, AND THE REPLY BOX THAT FOLLOWS IT - what a practice sees after raising a * ticket from the assistant's escalation form. `SupportPage` is the list this screen is reached from; * this is what opens when a row (or the emailed "Open this ticket" link) is followed. * * THE REPLY BOX DISAPPEARS ON A RESOLVED TICKET, not because the route refuses it (the server does * that too, with a 409) but because a form that is always there invites a reply the server will only * reject a moment later - the same shape of gap the widget's own send button avoids by disabling on * an empty draft. * * The status label, Badge variant and holder label maps live in `useSupportTickets.ts`, not here - see * that file's comment for why a second copy is the wrong move on a screen where the status word is * the signal. */ /** * Who said it, in the reader's own terms - never an account id, and never guessed when it is blank. * * THREE SIDES, NOT TWO. A ticket is raised with the contractor managing this practice's platform, but * escalation can move it to Akashic and hand-back can move it back - so one thread can hold replies * from both. This reads `event.side`, the side that WROTE that message, rather than inferring from * who holds the ticket now: a hand-back or a link ending is exactly the moment those two stop * agreeing, and re-labelling an old reply to match a new holder would misattribute who actually wrote * it. "Your contractor" and "Akashic support" are the same two words `moveDescription` below uses for * the same two destinations, so the thread reads as one vocabulary throughout. */ const speakerLabel = (event: SupportTicketThreadEvent): string => { if (event.side === "contractor") return event.authorName ? `${event.authorName} · Your contractor` : "Your contractor"; if (event.side === "akashic") return event.authorName ? `${event.authorName} · Akashic support` : "Akashic support"; return event.authorName || "Your practice"; }; /** * Plain words for an ownership move - `kind` is a database word and `movedTo` is the server's * `AKASHIC` / `CONTRACTOR#` shape, and neither belongs in front of a practice reading their own * ticket. A link ending says only that the relationship ended, never why: that is between the * practice and their contractor, and this thread is not the place to characterise it. */ const moveDescription = (event: SupportTicketThreadEvent): string => { const destination = event.movedTo === "AKASHIC" ? "Akashic support" : "your contractor"; switch (event.kind) { case "escalation": return `Escalated to ${destination}.`; case "hand-back": return `Handed back to ${destination}.`; case "link-ended": return "Moved to Akashic support because the contractor relationship ended."; default: return "This ticket's owner changed."; } }; export const SupportTicketDetail = () => { const { ticketId } = useParams<{ ticketId: string }>(); const { ticket, events, loading, error, notFound, reply, replying, replyError } = useSupportTicketThread(ticketId); const [draft, setDraft] = useState(""); const submitReply = async (formEvent: React.FormEvent) => { formEvent.preventDefault(); if (!draft.trim() || replying) return; try { await reply(draft.trim()); setDraft(""); } catch { // `replyError` already carries what to say. The draft is kept, on the same reasoning // `SupportTicketForm` states for its own send failure: losing what somebody just wrote is // worse than making them press send again. } }; return (
{error && ( {error} )} {loading && (

Loading this ticket…

)} {/* An ordinary state - a stale bookmark, a colleague's ticket - never rendered as a failure. */} {!loading && notFound && ( This ticket could not be found. It may belong to a colleague, or the link may be out of date. )} {!loading && ticket && ( <>

{ticket.subject}

{TICKET_STATUS_LABELS[ticket.status]} {/* Status and holder answer different questions - what is happening to the ticket, and who is holding it - so this stays a second badge rather than words folded into the status one. */} {TICKET_HOLDER_LABELS[ticket.holder]}

{ticket.reference && `${ticket.reference} — `} Raised {formatDateTime(ticket.createdAt)}

{ticket.body}

{/* Oldest first - the thread reads top to bottom the way it happened, the same order every message thread in this app already renders in. */}
{events.length === 0 ? ( ) : ( events.map((item, index) => (

{speakerLabel(item)} · {formatDateTime(item.at)}

{item.kind === "reply" && (

{item.body}

)} {item.kind === "status" && (

Marked {item.status ? TICKET_STATUS_LABELS[item.status] : "updated"}.

)} {item.kind !== "reply" && item.kind !== "status" && (

{moveDescription(item)}

)}
)) )}
{ticket.status !== "resolved" && (