Add the ability to view ticket details

This commit is contained in:
Moshfegh Hamedani 2026-02-23 10:47:42 -08:00
parent a2531b1ce0
commit 6518a45d33
7 changed files with 179 additions and 9 deletions

View file

@ -6,6 +6,7 @@ import LoginPage from "./pages/LoginPage";
import HomePage from "./pages/HomePage";
import UsersPage from "./pages/UsersPage";
import TicketsPage from "./pages/TicketsPage";
import TicketDetailPage from "./pages/TicketDetailPage";
function App() {
return (
@ -15,6 +16,7 @@ function App() {
<Route element={<Layout />}>
<Route path="/" element={<HomePage />} />
<Route path="/tickets" element={<TicketsPage />} />
<Route path="/tickets/:id" element={<TicketDetailPage />} />
<Route element={<AdminRoute />}>
<Route path="/users" element={<UsersPage />} />
</Route>

View file

@ -114,6 +114,10 @@
--sidebar-ring: oklch(0.556 0 0);
}
@utility link {
@apply text-foreground hover:underline;
}
@layer base {
* {
@apply border-border outline-ring/50;

View file

@ -0,0 +1,131 @@
import { useParams, Link } from "react-router";
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import { type TicketStatus, statusVariant } from "core/constants/ticket-status.ts";
import { type TicketCategory } from "core/constants/ticket-category.ts";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { AlertCircle, ArrowLeft } from "lucide-react";
interface TicketDetail {
id: number;
subject: string;
body: string;
bodyHtml: string | null;
status: TicketStatus;
category: TicketCategory | null;
senderName: string;
senderEmail: string;
assignedTo: { id: string; name: string } | null;
createdAt: string;
updatedAt: string;
}
export default function TicketDetailPage() {
const { id } = useParams<{ id: string }>();
const { data: ticket, isLoading, error } = useQuery({
queryKey: ["ticket", id],
queryFn: async () => {
const { data } = await axios.get<TicketDetail>(`/api/tickets/${id}`);
return data;
},
});
return (
<div className="space-y-6">
<Link
to="/tickets"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-4 w-4" />
Back to tickets
</Link>
{isLoading && (
<div className="space-y-4">
<Skeleton className="h-8 w-96" />
<div className="flex gap-2">
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="h-5 w-24 rounded-full" />
</div>
<Skeleton className="h-40 w-full" />
</div>
)}
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
{axios.isAxiosError(error) && error.response?.status === 404
? "Ticket not found"
: "Failed to load ticket"}
</AlertDescription>
</Alert>
)}
{ticket && (
<>
<div>
<h1 className="text-2xl font-bold">{ticket.subject}</h1>
<div className="flex items-center gap-2 mt-2">
<Badge variant={statusVariant[ticket.status]}>
{ticket.status}
</Badge>
{ticket.category && (
<Badge variant="secondary">
{ticket.category.replace(/_/g, " ")}
</Badge>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">From: </span>
{ticket.senderName} ({ticket.senderEmail})
</div>
<div>
<span className="text-muted-foreground">Assigned to: </span>
{ticket.assignedTo ? ticket.assignedTo.name : "Unassigned"}
</div>
<div>
<span className="text-muted-foreground">Created: </span>
{new Date(ticket.createdAt).toLocaleString()}
</div>
<div>
<span className="text-muted-foreground">Updated: </span>
{new Date(ticket.updatedAt).toLocaleString()}
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Message</CardTitle>
<CardDescription>
From {ticket.senderName}
</CardDescription>
</CardHeader>
<CardContent>
{ticket.bodyHtml ? (
<div
dangerouslySetInnerHTML={{ __html: ticket.bodyHtml }}
/>
) : (
<p className="whitespace-pre-wrap">{ticket.body}</p>
)}
</CardContent>
</Card>
</>
)}
</div>
);
}

View file

@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
import { Link } from "react-router";
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import {
@ -9,7 +10,7 @@ import {
getCoreRowModel,
flexRender,
} from "@tanstack/react-table";
import { type TicketStatus } from "core/constants/ticket-status.ts";
import { type TicketStatus, statusVariant } from "core/constants/ticket-status.ts";
import { type TicketCategory } from "core/constants/ticket-category.ts";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
@ -52,17 +53,18 @@ interface TicketsResponse {
pageSize: number;
}
const statusVariant: Record<TicketStatus, "default" | "secondary" | "outline"> =
{
open: "default",
resolved: "secondary",
closed: "outline",
};
const columns: ColumnDef<Ticket>[] = [
{
accessorKey: "subject",
header: "Subject",
cell: ({ row }) => (
<Link
to={`/tickets/${row.original.id}`}
className="link font-medium"
>
{row.original.subject}
</Link>
),
},
{
accessorKey: "senderName",

View file

@ -1,11 +1,14 @@
import { render } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter } from "react-router";
export function renderWithQuery(ui: React.ReactElement) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>
<MemoryRouter>
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>
</MemoryRouter>
);
}

View file

@ -1,3 +1,9 @@
export const ticketStatuses = ["open", "resolved", "closed"] as const;
export type TicketStatus = (typeof ticketStatuses)[number];
export const statusVariant: Record<TicketStatus, "default" | "secondary" | "outline"> = {
open: "default",
resolved: "secondary",
closed: "outline",
};

View file

@ -51,4 +51,26 @@ router.get("/", requireAuth, async (req, res) => {
res.json({ tickets, total, page: query.page, pageSize: query.pageSize });
});
router.get("/:id", requireAuth, async (req, res) => {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
res.status(400).json({ error: "Invalid ticket ID" });
return;
}
const ticket = await prisma.ticket.findUnique({
where: { id },
include: {
assignedTo: { select: { id: true, name: true } },
},
});
if (!ticket) {
res.status(404).json({ error: "Ticket not found" });
return;
}
res.json(ticket);
});
export default router;