From 1029176873772ccaeec4f44c239d8d48c40527f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gjermund=20H=C3=B8s=C3=B8ien=20Wiggen?= Date: Sun, 7 Jun 2026 22:02:08 +0200 Subject: [PATCH] Add ticket detail page with transaction timeline and status change --- web/src/app/tickets/[id]/page.tsx | 375 ++++++++++++++++++++++++++++++ 1 file changed, 375 insertions(+) create mode 100644 web/src/app/tickets/[id]/page.tsx diff --git a/web/src/app/tickets/[id]/page.tsx b/web/src/app/tickets/[id]/page.tsx new file mode 100644 index 0000000..cb45b7a --- /dev/null +++ b/web/src/app/tickets/[id]/page.tsx @@ -0,0 +1,375 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import { useParams } from "next/navigation"; +import Link from "next/link"; +import { formatDistanceToNow } from "date-fns"; +import { + ArrowLeft, + Plus, + ArrowRightLeft, + MessageSquare, + Pencil, + RefreshCw, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Card, + CardHeader, + CardTitle, + CardContent, +} from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { + getTicket, + getTicketTransactions, + getQueues, + previewTicket, + updateTicket, +} from "@/lib/api"; +import type { Ticket, Transaction, Queue, PreviewResult, UpdateResult } from "@/lib/types"; + +const STATUS_COLORS: Record = { + new: "bg-blue-500/10 text-blue-400 border-blue-500/30", + open: "bg-sky-500/10 text-sky-400 border-sky-500/30", + in_progress: "bg-yellow-500/10 text-yellow-400 border-yellow-500/30", + resolved: "bg-green-500/10 text-green-400 border-green-500/30", + closed: "bg-neutral-500/10 text-neutral-400 border-neutral-500/30", +}; + +const TX_ICONS: Record = { + Create: Plus, + StatusChange: ArrowRightLeft, + Comment: MessageSquare, + CustomField: Pencil, +}; + +const TX_COLORS: Record = { + Create: "bg-green-500/10 text-green-400 border-green-500/30", + StatusChange: "bg-blue-500/10 text-blue-400 border-blue-500/30", + Comment: "bg-neutral-500/10 text-neutral-400 border-neutral-500/30", + CustomField: "bg-purple-500/10 text-purple-400 border-purple-500/30", +}; + +function formatDate(iso: string) { + return new Date(iso).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +export default function TicketDetailPage() { + const { id } = useParams<{ id: string }>(); + const [ticket, setTicket] = useState(null); + const [transactions, setTransactions] = useState([]); + const [queues, setQueues] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [selectedStatus, setSelectedStatus] = useState(""); + const [previewing, setPreviewing] = useState(false); + const [applying, setApplying] = useState(false); + const [previewDialogOpen, setPreviewDialogOpen] = useState(false); + const [previewResult, setPreviewResult] = useState(null); + const [previewError, setPreviewError] = useState(null); + const [applyResult, setApplyResult] = useState(null); + const [applyError, setApplyError] = useState(null); + + const fetchData = useCallback(async () => { + setLoading(true); + setError(null); + const [tRes, txRes, qRes] = await Promise.all([ + getTicket(id), + getTicketTransactions(id), + getQueues(), + ]); + if (tRes.error) setError(tRes.error); + else { + setTicket(tRes.data); + setSelectedStatus(tRes.data?.status ?? ""); + } + if (txRes.error) setError(txRes.error); + else setTransactions(txRes.data ?? []); + if (qRes.error && !error) setError(qRes.error); + else setQueues(qRes.data ?? []); + setLoading(false); + }, [id]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + const handlePreview = async () => { + if (!selectedStatus) return; + setPreviewing(true); + setPreviewError(null); + const { data, error } = await previewTicket(id, { status: selectedStatus }); + setPreviewing(false); + if (error) { + setPreviewError(error); + } else { + setPreviewResult(data); + } + setPreviewDialogOpen(true); + }; + + const handleApply = async () => { + if (!selectedStatus) return; + setApplying(true); + setApplyError(null); + setApplyResult(null); + const { data, error } = await updateTicket(id, { status: selectedStatus }); + setApplying(false); + if (error) { + setApplyError(error); + } else { + setApplyResult(data); + await fetchData(); + } + }; + + const queueName = queues.find((q) => q.id === ticket?.queue_id)?.name ?? ticket?.queue_id ?? "Unknown"; + + if (loading) { + return
Loading ticket...
; + } + + if (error && !ticket) { + return ( +
+
+ {error} +
+ + Back to tickets + +
+ ); + } + + if (!ticket) { + return ( +
+ Ticket not found.{" "} + + Back to tickets + +
+ ); + } + + return ( +
+ + + All tickets + + + + +
+
+ {ticket.subject} +
+ {queueName} + Created {formatDate(ticket.created_at)} + Updated {formatDate(ticket.updated_at)} +
+
+ + {ticket.status.replace("_", " ")} + +
+
+ Owner: {ticket.owner_id ?? "Unassigned"} +
+
+
+ + + + Change Status + + + + + + {applyError && {applyError}} + {applyResult && ( +
+ Status updated successfully. + {applyResult.scrip_results.map((r) => ( +
+ {r.scripId}: {r.message} +
+ ))} +
+ )} +
+
+ + + + Transaction Timeline + + + {transactions.length === 0 ? ( +
No transactions yet.
+ ) : ( +
+ {transactions.map((tx) => { + const Icon = TX_ICONS[tx.transaction_type] ?? MessageSquare; + return ( +
+
+ +
+
+
+ + {tx.transaction_type} + + {tx.field && ( + + {tx.field} + {tx.old_value && tx.new_value ? ( + <> + {" "} + {tx.old_value} →{" "} + {tx.new_value} + + ) : tx.new_value ? ( + <> + {" "} + set to {tx.new_value} + + ) : null} + + )} +
+ + {formatDistanceToNow(new Date(tx.created_at), { addSuffix: true })} + +
+
+ ); + })} +
+ )} +
+
+ + {ticket.custom_fields && ticket.custom_fields.length > 0 && ( + + + Custom Fields + + +
+ {ticket.custom_fields.map((cf) => ( +
+ + {cf.custom_field?.name ?? cf.custom_field_id}: + + {cf.value} +
+ ))} +
+
+
+ )} + + + + + Preview Status Change + + The following scrips would execute when changing status to{" "} + + {selectedStatus.replace("_", " ")} + + . + + +
+ {previewError && ( +
{previewError}
+ )} + {previewResult && previewResult.prepared_scrips.length === 0 && ( +
No scrips would be triggered.
+ )} + {previewResult?.prepared_scrips.map((ps) => ( +
+
{ps.scripName}
+
+ Action: {ps.actionType} + {ps.dryRun ? " (dry run)" : " (would execute)"} +
+
+ ))} +
+
+
+
+ ); +}