mirror of
https://github.com/mosh-hamedani/helpdesk.git
synced 2026-05-21 11:58:19 +02:00
Add pagination
This commit is contained in:
parent
77abd55f44
commit
a2531b1ce0
|
|
@ -1,4 +1,4 @@
|
|||
import { screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import axios from "axios";
|
||||
|
|
@ -39,9 +39,9 @@ const mockTickets = [
|
|||
},
|
||||
];
|
||||
|
||||
const defaultParams = {
|
||||
params: { sortBy: "createdAt", sortOrder: "desc" },
|
||||
};
|
||||
function mockResponse(tickets = mockTickets, total = tickets.length) {
|
||||
return { data: { tickets, total, page: 1, pageSize: 10 } };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
|
|
@ -64,7 +64,7 @@ describe("TicketsPage", () => {
|
|||
});
|
||||
|
||||
it("should display tickets in a table after loading", async () => {
|
||||
mockedAxios.get.mockResolvedValue({ data: { tickets: mockTickets } });
|
||||
mockedAxios.get.mockResolvedValue(mockResponse());
|
||||
renderWithQuery(<TicketsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
|
|
@ -83,7 +83,7 @@ describe("TicketsPage", () => {
|
|||
});
|
||||
|
||||
it("should display sender name and email", async () => {
|
||||
mockedAxios.get.mockResolvedValue({ data: { tickets: mockTickets } });
|
||||
mockedAxios.get.mockResolvedValue(mockResponse());
|
||||
renderWithQuery(<TicketsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
|
|
@ -96,7 +96,7 @@ describe("TicketsPage", () => {
|
|||
});
|
||||
|
||||
it("should display status badges", async () => {
|
||||
mockedAxios.get.mockResolvedValue({ data: { tickets: mockTickets } });
|
||||
mockedAxios.get.mockResolvedValue(mockResponse());
|
||||
renderWithQuery(<TicketsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
|
|
@ -108,7 +108,7 @@ describe("TicketsPage", () => {
|
|||
});
|
||||
|
||||
it("should display category with underscores replaced by spaces", async () => {
|
||||
mockedAxios.get.mockResolvedValue({ data: { tickets: mockTickets } });
|
||||
mockedAxios.get.mockResolvedValue(mockResponse());
|
||||
renderWithQuery(<TicketsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
|
|
@ -119,7 +119,7 @@ describe("TicketsPage", () => {
|
|||
});
|
||||
|
||||
it("should show dash for null category", async () => {
|
||||
mockedAxios.get.mockResolvedValue({ data: { tickets: mockTickets } });
|
||||
mockedAxios.get.mockResolvedValue(mockResponse());
|
||||
renderWithQuery(<TicketsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
|
|
@ -132,7 +132,7 @@ describe("TicketsPage", () => {
|
|||
});
|
||||
|
||||
it("should format createdAt as a locale date string", async () => {
|
||||
mockedAxios.get.mockResolvedValue({ data: { tickets: [mockTickets[0]] } });
|
||||
mockedAxios.get.mockResolvedValue(mockResponse([mockTickets[0]]));
|
||||
renderWithQuery(<TicketsPage />);
|
||||
|
||||
const expectedDate = new Date(
|
||||
|
|
@ -168,7 +168,7 @@ describe("TicketsPage", () => {
|
|||
});
|
||||
|
||||
it("should render an empty table body when there are no tickets", async () => {
|
||||
mockedAxios.get.mockResolvedValue({ data: { tickets: [] } });
|
||||
mockedAxios.get.mockResolvedValue(mockResponse([], 0));
|
||||
renderWithQuery(<TicketsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
|
|
@ -179,23 +179,28 @@ describe("TicketsPage", () => {
|
|||
|
||||
expect(screen.getByRole("table")).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("row")).toHaveLength(1); // header row only
|
||||
expect(screen.getByText("No tickets")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call axios.get with /api/tickets and default sort params", async () => {
|
||||
mockedAxios.get.mockResolvedValue({ data: { tickets: [] } });
|
||||
it("should call axios.get with default sort and pagination params", async () => {
|
||||
mockedAxios.get.mockResolvedValue(mockResponse([], 0));
|
||||
renderWithQuery(<TicketsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedAxios.get).toHaveBeenCalledWith(
|
||||
"/api/tickets",
|
||||
defaultParams
|
||||
);
|
||||
expect(mockedAxios.get).toHaveBeenCalledWith("/api/tickets", {
|
||||
params: {
|
||||
sortBy: "createdAt",
|
||||
sortOrder: "desc",
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should sort by column when clicking a column header", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockedAxios.get.mockResolvedValue({ data: { tickets: mockTickets } });
|
||||
mockedAxios.get.mockResolvedValue(mockResponse());
|
||||
renderWithQuery(<TicketsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
|
|
@ -205,20 +210,24 @@ describe("TicketsPage", () => {
|
|||
});
|
||||
|
||||
mockedAxios.get.mockClear();
|
||||
mockedAxios.get.mockResolvedValue({ data: { tickets: mockTickets } });
|
||||
mockedAxios.get.mockResolvedValue(mockResponse());
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Subject/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedAxios.get).toHaveBeenCalledWith("/api/tickets", {
|
||||
params: { sortBy: "subject", sortOrder: "asc" },
|
||||
params: expect.objectContaining({
|
||||
sortBy: "subject",
|
||||
sortOrder: "asc",
|
||||
page: 1,
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should toggle sort order when clicking the same column header twice", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockedAxios.get.mockResolvedValue({ data: { tickets: mockTickets } });
|
||||
mockedAxios.get.mockResolvedValue(mockResponse());
|
||||
renderWithQuery(<TicketsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
|
|
@ -228,26 +237,30 @@ describe("TicketsPage", () => {
|
|||
});
|
||||
|
||||
mockedAxios.get.mockClear();
|
||||
mockedAxios.get.mockResolvedValue({ data: { tickets: mockTickets } });
|
||||
mockedAxios.get.mockResolvedValue(mockResponse());
|
||||
|
||||
// First click: sort by Subject ascending
|
||||
await user.click(screen.getByRole("button", { name: /Subject/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedAxios.get).toHaveBeenCalledWith("/api/tickets", {
|
||||
params: { sortBy: "subject", sortOrder: "asc" },
|
||||
params: expect.objectContaining({
|
||||
sortBy: "subject",
|
||||
sortOrder: "asc",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
mockedAxios.get.mockClear();
|
||||
mockedAxios.get.mockResolvedValue({ data: { tickets: mockTickets } });
|
||||
mockedAxios.get.mockResolvedValue(mockResponse());
|
||||
|
||||
// Second click: sort by Subject descending
|
||||
await user.click(screen.getByRole("button", { name: /Subject/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedAxios.get).toHaveBeenCalledWith("/api/tickets", {
|
||||
params: { sortBy: "subject", sortOrder: "desc" },
|
||||
params: expect.objectContaining({
|
||||
sortBy: "subject",
|
||||
sortOrder: "desc",
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -265,7 +278,7 @@ describe("TicketsPage", () => {
|
|||
|
||||
it("should send search param when typing in the search input", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockedAxios.get.mockResolvedValue({ data: { tickets: mockTickets } });
|
||||
mockedAxios.get.mockResolvedValue(mockResponse());
|
||||
renderWithQuery(<TicketsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
|
|
@ -275,7 +288,7 @@ describe("TicketsPage", () => {
|
|||
});
|
||||
|
||||
mockedAxios.get.mockClear();
|
||||
mockedAxios.get.mockResolvedValue({ data: { tickets: [mockTickets[0]] } });
|
||||
mockedAxios.get.mockResolvedValue(mockResponse([mockTickets[0]]));
|
||||
|
||||
await user.type(
|
||||
screen.getByPlaceholderText("Search tickets..."),
|
||||
|
|
@ -290,10 +303,8 @@ describe("TicketsPage", () => {
|
|||
});
|
||||
|
||||
it("should include status filter in API request", async () => {
|
||||
mockedAxios.get.mockResolvedValue({ data: { tickets: [mockTickets[0]] } });
|
||||
renderWithQuery(
|
||||
<TicketsTable filters={{ status: "open" }} />
|
||||
);
|
||||
mockedAxios.get.mockResolvedValue(mockResponse([mockTickets[0]]));
|
||||
renderWithQuery(<TicketsTable filters={{ status: "open" }} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedAxios.get).toHaveBeenCalledWith("/api/tickets", {
|
||||
|
|
@ -303,10 +314,8 @@ describe("TicketsPage", () => {
|
|||
});
|
||||
|
||||
it("should include category filter in API request", async () => {
|
||||
mockedAxios.get.mockResolvedValue({ data: { tickets: [mockTickets[1]] } });
|
||||
renderWithQuery(
|
||||
<TicketsTable filters={{ category: "refund_request" }} />
|
||||
);
|
||||
mockedAxios.get.mockResolvedValue(mockResponse([mockTickets[1]]));
|
||||
renderWithQuery(<TicketsTable filters={{ category: "refund_request" }} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedAxios.get).toHaveBeenCalledWith("/api/tickets", {
|
||||
|
|
@ -316,10 +325,8 @@ describe("TicketsPage", () => {
|
|||
});
|
||||
|
||||
it("should include search filter in API request", async () => {
|
||||
mockedAxios.get.mockResolvedValue({ data: { tickets: [mockTickets[0]] } });
|
||||
renderWithQuery(
|
||||
<TicketsTable filters={{ search: "login" }} />
|
||||
);
|
||||
mockedAxios.get.mockResolvedValue(mockResponse([mockTickets[0]]));
|
||||
renderWithQuery(<TicketsTable filters={{ search: "login" }} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedAxios.get).toHaveBeenCalledWith("/api/tickets", {
|
||||
|
|
@ -327,4 +334,67 @@ describe("TicketsPage", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should display pagination info and controls", async () => {
|
||||
mockedAxios.get.mockResolvedValue(mockResponse(mockTickets, 50));
|
||||
renderWithQuery(<TicketsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("Showing 1–10 of 50 tickets")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText("Page 1 of 5")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "First page" })).toBeDisabled();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Previous page" })
|
||||
).toBeDisabled();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Next page" })
|
||||
).toBeEnabled();
|
||||
expect(screen.getByRole("button", { name: "Last page" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("should fetch page 2 when clicking the next page button", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockedAxios.get.mockResolvedValue(mockResponse(mockTickets, 50));
|
||||
renderWithQuery(<TicketsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("Showing 1–10 of 50 tickets")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
mockedAxios.get.mockClear();
|
||||
mockedAxios.get.mockResolvedValue({
|
||||
data: { tickets: mockTickets, total: 50, page: 2, pageSize: 10 },
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Next page" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedAxios.get).toHaveBeenCalledWith("/api/tickets", {
|
||||
params: expect.objectContaining({ page: 2, pageSize: 10 }),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should disable all pagination buttons on the last page", async () => {
|
||||
mockedAxios.get.mockResolvedValue(mockResponse(mockTickets, 3));
|
||||
renderWithQuery(<TicketsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Showing 1–3 of 3 tickets")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText("Page 1 of 1")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "First page" })).toBeDisabled();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Previous page" })
|
||||
).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Next page" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Last page" })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
import {
|
||||
type ColumnDef,
|
||||
type SortingState,
|
||||
type PaginationState,
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
flexRender,
|
||||
|
|
@ -22,7 +23,16 @@ import {
|
|||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { AlertCircle, ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
ArrowUpDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
} from "lucide-react";
|
||||
import type { TicketFilters } from "./TicketsPage";
|
||||
|
||||
interface Ticket {
|
||||
|
|
@ -35,6 +45,13 @@ interface Ticket {
|
|||
createdAt: string;
|
||||
}
|
||||
|
||||
interface TicketsResponse {
|
||||
tickets: Ticket[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
const statusVariant: Record<TicketStatus, "default" | "secondary" | "outline"> =
|
||||
{
|
||||
open: "default",
|
||||
|
|
@ -88,35 +105,60 @@ const columns: ColumnDef<Ticket>[] = [
|
|||
},
|
||||
];
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
export default function TicketsTable({ filters }: { filters: TicketFilters }) {
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{ id: "createdAt", desc: true },
|
||||
]);
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: PAGE_SIZE,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
|
||||
}, [filters]);
|
||||
|
||||
const sortBy = sorting[0]?.id ?? "createdAt";
|
||||
const sortOrder = sorting[0]?.desc ?? true ? "desc" : "asc";
|
||||
|
||||
const {
|
||||
data: tickets,
|
||||
data,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ["tickets", sortBy, sortOrder, filters],
|
||||
queryKey: ["tickets", sortBy, sortOrder, filters, pagination.pageIndex],
|
||||
queryFn: async () => {
|
||||
const { data } = await axios.get<{ tickets: Ticket[] }>("/api/tickets", {
|
||||
params: { sortBy, sortOrder, ...filters },
|
||||
const { data } = await axios.get<TicketsResponse>("/api/tickets", {
|
||||
params: {
|
||||
sortBy,
|
||||
sortOrder,
|
||||
...filters,
|
||||
page: pagination.pageIndex + 1,
|
||||
pageSize: pagination.pageSize,
|
||||
},
|
||||
});
|
||||
return data.tickets;
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
const total = data?.total ?? 0;
|
||||
const pageCount = Math.ceil(total / pagination.pageSize);
|
||||
|
||||
const table = useReactTable({
|
||||
data: tickets ?? [],
|
||||
data: data?.tickets ?? [],
|
||||
columns,
|
||||
state: { sorting },
|
||||
onSortingChange: setSorting,
|
||||
state: { sorting, pagination },
|
||||
onSortingChange: (updater) => {
|
||||
setSorting(updater);
|
||||
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
|
||||
},
|
||||
onPaginationChange: setPagination,
|
||||
manualSorting: true,
|
||||
manualPagination: true,
|
||||
enableMultiSort: false,
|
||||
pageCount,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
|
|
@ -130,66 +172,126 @@ export default function TicketsTable({ filters }: { filters: TicketFilters }) {
|
|||
}
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-3"
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
>
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
{header.column.getIsSorted() === "asc" ? (
|
||||
<ArrowUp className="ml-2 h-4 w-4" />
|
||||
) : header.column.getIsSorted() === "desc" ? (
|
||||
<ArrowDown className="ml-2 h-4 w-4" />
|
||||
) : (
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading
|
||||
? Array.from({ length: 5 }).map((_, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-48" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-40" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-5 w-24 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
: table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
<div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-3"
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
>
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
{header.column.getIsSorted() === "asc" ? (
|
||||
<ArrowUp className="ml-2 h-4 w-4" />
|
||||
) : header.column.getIsSorted() === "desc" ? (
|
||||
<ArrowDown className="ml-2 h-4 w-4" />
|
||||
) : (
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading
|
||||
? Array.from({ length: 5 }).map((_, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-48" />
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-40" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-5 w-24 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
: table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{!isLoading && !error && (
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{total === 0
|
||||
? "No tickets"
|
||||
: `Showing ${pagination.pageIndex * pagination.pageSize + 1}–${Math.min((pagination.pageIndex + 1) * pagination.pageSize, total)} of ${total} tickets`}
|
||||
</p>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => table.firstPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
aria-label="First page"
|
||||
>
|
||||
<ChevronsLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-sm px-2">
|
||||
Page {pagination.pageIndex + 1} of {pageCount || 1}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
aria-label="Next page"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => table.lastPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
aria-label="Last page"
|
||||
>
|
||||
<ChevronsRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,4 +28,6 @@ export const ticketListQuerySchema = z.object({
|
|||
status: z.enum(ticketStatuses).optional(),
|
||||
category: z.enum(ticketCategories).optional(),
|
||||
search: z.string().optional(),
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(10),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -29,20 +29,26 @@ router.get("/", requireAuth, async (req, res) => {
|
|||
];
|
||||
}
|
||||
|
||||
const tickets = await prisma.ticket.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
subject: true,
|
||||
status: true,
|
||||
category: true,
|
||||
senderName: true,
|
||||
senderEmail: true,
|
||||
createdAt: true,
|
||||
},
|
||||
where,
|
||||
orderBy: { [query.sortBy]: query.sortOrder },
|
||||
});
|
||||
res.json({ tickets });
|
||||
const [tickets, total] = await Promise.all([
|
||||
prisma.ticket.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
subject: true,
|
||||
status: true,
|
||||
category: true,
|
||||
senderName: true,
|
||||
senderEmail: true,
|
||||
createdAt: true,
|
||||
},
|
||||
where,
|
||||
orderBy: { [query.sortBy]: query.sortOrder },
|
||||
skip: (query.page - 1) * query.pageSize,
|
||||
take: query.pageSize,
|
||||
}),
|
||||
prisma.ticket.count({ where }),
|
||||
]);
|
||||
|
||||
res.json({ tickets, total, page: query.page, pageSize: query.pageSize });
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
Loading…
Reference in a new issue