mirror of
https://github.com/mosh-hamedani/helpdesk.git
synced 2026-05-21 11:58:19 +02:00
Add the ability to view ticket details
This commit is contained in:
parent
a2531b1ce0
commit
6518a45d33
|
|
@ -6,6 +6,7 @@ import LoginPage from "./pages/LoginPage";
|
||||||
import HomePage from "./pages/HomePage";
|
import HomePage from "./pages/HomePage";
|
||||||
import UsersPage from "./pages/UsersPage";
|
import UsersPage from "./pages/UsersPage";
|
||||||
import TicketsPage from "./pages/TicketsPage";
|
import TicketsPage from "./pages/TicketsPage";
|
||||||
|
import TicketDetailPage from "./pages/TicketDetailPage";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -15,6 +16,7 @@ function App() {
|
||||||
<Route element={<Layout />}>
|
<Route element={<Layout />}>
|
||||||
<Route path="/" element={<HomePage />} />
|
<Route path="/" element={<HomePage />} />
|
||||||
<Route path="/tickets" element={<TicketsPage />} />
|
<Route path="/tickets" element={<TicketsPage />} />
|
||||||
|
<Route path="/tickets/:id" element={<TicketDetailPage />} />
|
||||||
<Route element={<AdminRoute />}>
|
<Route element={<AdminRoute />}>
|
||||||
<Route path="/users" element={<UsersPage />} />
|
<Route path="/users" element={<UsersPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,10 @@
|
||||||
--sidebar-ring: oklch(0.556 0 0);
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@utility link {
|
||||||
|
@apply text-foreground hover:underline;
|
||||||
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
|
|
|
||||||
131
client/src/pages/TicketDetailPage.tsx
Normal file
131
client/src/pages/TicketDetailPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link } from "react-router";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import {
|
import {
|
||||||
|
|
@ -9,7 +10,7 @@ import {
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
flexRender,
|
flexRender,
|
||||||
} from "@tanstack/react-table";
|
} 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 { type TicketCategory } from "core/constants/ticket-category.ts";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
@ -52,17 +53,18 @@ interface TicketsResponse {
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusVariant: Record<TicketStatus, "default" | "secondary" | "outline"> =
|
|
||||||
{
|
|
||||||
open: "default",
|
|
||||||
resolved: "secondary",
|
|
||||||
closed: "outline",
|
|
||||||
};
|
|
||||||
|
|
||||||
const columns: ColumnDef<Ticket>[] = [
|
const columns: ColumnDef<Ticket>[] = [
|
||||||
{
|
{
|
||||||
accessorKey: "subject",
|
accessorKey: "subject",
|
||||||
header: "Subject",
|
header: "Subject",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Link
|
||||||
|
to={`/tickets/${row.original.id}`}
|
||||||
|
className="link font-medium"
|
||||||
|
>
|
||||||
|
{row.original.subject}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "senderName",
|
accessorKey: "senderName",
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
import { render } from "@testing-library/react";
|
import { render } from "@testing-library/react";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { MemoryRouter } from "react-router";
|
||||||
|
|
||||||
export function renderWithQuery(ui: React.ReactElement) {
|
export function renderWithQuery(ui: React.ReactElement) {
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: { queries: { retry: false } },
|
defaultOptions: { queries: { retry: false } },
|
||||||
});
|
});
|
||||||
return render(
|
return render(
|
||||||
|
<MemoryRouter>
|
||||||
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>
|
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>
|
||||||
|
</MemoryRouter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,9 @@
|
||||||
export const ticketStatuses = ["open", "resolved", "closed"] as const;
|
export const ticketStatuses = ["open", "resolved", "closed"] as const;
|
||||||
|
|
||||||
export type TicketStatus = (typeof ticketStatuses)[number];
|
export type TicketStatus = (typeof ticketStatuses)[number];
|
||||||
|
|
||||||
|
export const statusVariant: Record<TicketStatus, "default" | "secondary" | "outline"> = {
|
||||||
|
open: "default",
|
||||||
|
resolved: "secondary",
|
||||||
|
closed: "outline",
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -51,4 +51,26 @@ router.get("/", requireAuth, async (req, res) => {
|
||||||
res.json({ tickets, total, page: query.page, pageSize: query.pageSize });
|
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;
|
export default router;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue