Add ticket list page with filters, status badges, create dialog
This commit is contained in:
@@ -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 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"
|
||||
>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</main>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-500/30 bg-red-500/10 p-4 text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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"
|
||||
>
|
||||
Create your first ticket
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user