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 (
|
return (
|
||||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
<div className="flex flex-col gap-6">
|
||||||
<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">
|
<div className="flex items-center justify-between">
|
||||||
<Image
|
<h1 className="text-2xl font-semibold tracking-tight">Tickets</h1>
|
||||||
className="dark:invert"
|
<Button onClick={() => setDialogOpen(true)}>
|
||||||
src="/next.svg"
|
<Plus className="size-4" />
|
||||||
alt="Next.js logo"
|
New Ticket
|
||||||
width={100}
|
</Button>
|
||||||
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>
|
</div>
|
||||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
|
||||||
<a
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
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]"
|
<Select value={filterQueue} onValueChange={(v) => setFilterQueue(!v || v === "_all" ? "" : v)}>
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
<SelectTrigger className="w-44">
|
||||||
target="_blank"
|
<SelectValue placeholder="All queues" />
|
||||||
rel="noopener noreferrer"
|
</SelectTrigger>
|
||||||
>
|
<SelectContent>
|
||||||
<Image
|
<SelectItem value="_all">All queues</SelectItem>
|
||||||
className="dark:invert"
|
{queues.map((q) => (
|
||||||
src="/vercel.svg"
|
<SelectItem key={q.id} value={q.id}>
|
||||||
alt="Vercel logomark"
|
{q.name}
|
||||||
width={16}
|
</SelectItem>
|
||||||
height={16}
|
))}
|
||||||
/>
|
</SelectContent>
|
||||||
Deploy Now
|
</Select>
|
||||||
</a>
|
|
||||||
<a
|
<Select value={filterStatus} onValueChange={(v) => setFilterStatus(!v || v === "_all" ? "" : v)}>
|
||||||
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]"
|
<SelectTrigger className="w-40">
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
<SelectValue placeholder="All statuses" />
|
||||||
target="_blank"
|
</SelectTrigger>
|
||||||
rel="noopener noreferrer"
|
<SelectContent>
|
||||||
>
|
<SelectItem value="_all">All statuses</SelectItem>
|
||||||
Documentation
|
<SelectItem value="new">New</SelectItem>
|
||||||
</a>
|
<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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user