Add ticket list page with filters, status badges, create dialog

This commit is contained in:
Gjermund Høsøien Wiggen
2026-06-07 22:02:06 +02:00
parent f69678db4b
commit a49e888011

View File

@@ -1,65 +1,266 @@
import Image from "next/image";
"use client";
import { useEffect, useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import { Plus, Filter } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
} from "@/components/ui/table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { getTickets, getQueues, createTicket } from "@/lib/api";
import type { Ticket, Queue } from "@/lib/types";
const STATUS_COLORS: Record<string, string> = {
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",
};
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}
export default function TicketsPage() {
const router = useRouter();
const [tickets, setTickets] = useState<Ticket[]>([]);
const [queues, setQueues] = useState<Queue[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [filterQueue, setFilterQueue] = useState<string>("");
const [filterStatus, setFilterStatus] = useState<string>("");
const [dialogOpen, setDialogOpen] = useState(false);
const [newSubject, setNewSubject] = useState("");
const [newQueueId, setNewQueueId] = useState("");
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
const fetchData = useCallback(async (queueId?: string, status?: string) => {
setLoading(true);
setError(null);
const params: { queue_id?: string; status?: string } = {};
if (queueId) params.queue_id = queueId;
if (status) params.status = status;
const [tRes, qRes] = await Promise.all([getTickets(params), getQueues()]);
if (tRes.error) setError(tRes.error);
else setTickets(tRes.data ?? []);
if (qRes.error && !error) setError(qRes.error);
else setQueues(qRes.data ?? []);
setLoading(false);
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
const handleFilter = () => {
fetchData(filterQueue || undefined, filterStatus || undefined);
};
const handleCreate = async () => {
if (!newSubject.trim() || !newQueueId) return;
setCreating(true);
setCreateError(null);
const { data, error } = await createTicket({ subject: newSubject.trim(), queue_id: newQueueId });
setCreating(false);
if (error) {
setCreateError(error);
} else {
setDialogOpen(false);
setNewSubject("");
setNewQueueId("");
fetchData(filterQueue || undefined, filterStatus || undefined);
if (data) router.push(`/tickets/${data.id}`);
}
};
const queueName = (id: string) => queues.find((q) => q.id === id)?.name ?? id;
export default function Home() {
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold tracking-tight">Tickets</h1>
<Button onClick={() => setDialogOpen(true)}>
<Plus className="size-4" />
New Ticket
</Button>
</div>
<div className="flex items-center gap-3 flex-wrap">
<Select value={filterQueue} onValueChange={(v) => setFilterQueue(!v || v === "_all" ? "" : v)}>
<SelectTrigger className="w-44">
<SelectValue placeholder="All queues" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_all">All queues</SelectItem>
{queues.map((q) => (
<SelectItem key={q.id} value={q.id}>
{q.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={filterStatus} onValueChange={(v) => setFilterStatus(!v || v === "_all" ? "" : v)}>
<SelectTrigger className="w-40">
<SelectValue placeholder="All statuses" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_all">All statuses</SelectItem>
<SelectItem value="new">New</SelectItem>
<SelectItem value="open">Open</SelectItem>
<SelectItem value="in_progress">In Progress</SelectItem>
<SelectItem value="resolved">Resolved</SelectItem>
<SelectItem value="closed">Closed</SelectItem>
</SelectContent>
</Select>
<Button variant="outline" onClick={handleFilter}>
<Filter className="size-4" />
Filter
</Button>
</div>
{error && (
<div className="rounded-lg border border-red-500/30 bg-red-500/10 p-4 text-sm text-red-400">
{error}
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
)}
{loading ? (
<div className="text-sm text-neutral-400 py-12 text-center">Loading tickets...</div>
) : tickets.length === 0 ? (
<div className="text-sm text-neutral-400 py-12 text-center">
No tickets yet.{" "}
<button
onClick={() => setDialogOpen(true)}
className="text-blue-400 hover:underline"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
Create your first ticket
</button>
</div>
</main>
) : (
<div className="rounded-lg border border-neutral-800 overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Subject</TableHead>
<TableHead>Queue</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tickets.map((t) => (
<TableRow
key={t.id}
className="cursor-pointer hover:bg-neutral-800/50"
onClick={() => router.push(`/tickets/${t.id}`)}
>
<TableCell className="font-mono text-xs text-neutral-400">
{t.id.slice(0, 8)}
</TableCell>
<TableCell className="font-medium">{t.subject}</TableCell>
<TableCell>
<Badge variant="outline">{queueName(t.queue_id)}</Badge>
</TableCell>
<TableCell>
<Badge
className={`border ${STATUS_COLORS[t.status] ?? "bg-neutral-500/10 text-neutral-400 border-neutral-500/30"}`}
variant="outline"
>
{t.status.replace("_", " ")}
</Badge>
</TableCell>
<TableCell className="text-neutral-400 text-xs">
{formatDate(t.created_at)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>New Ticket</DialogTitle>
<DialogDescription>
Create a new ticket by providing a subject and queue.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="subject">Subject</Label>
<Input
id="subject"
placeholder="Enter ticket subject"
value={newSubject}
onChange={(e) => setNewSubject(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="queue">Queue</Label>
<Select value={newQueueId} onValueChange={(v) => setNewQueueId(v ?? "")}>
<SelectTrigger id="queue">
<SelectValue placeholder="Select a queue" />
</SelectTrigger>
<SelectContent>
{queues.map((q) => (
<SelectItem key={q.id} value={q.id}>
{q.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{createError && (
<div className="text-sm text-red-400">{createError}</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>
Cancel
</Button>
<Button
onClick={handleCreate}
disabled={!newSubject.trim() || !newQueueId || creating}
>
{creating ? "Creating..." : "Create Ticket"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}