Add TicketsView, TicketCard, TicketDetail, TicketTimeline, CreateTicketDialog, and TicketBadges components. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
196 lines
7.2 KiB
TypeScript
196 lines
7.2 KiB
TypeScript
"use client";
|
|
|
|
import { useMemo, useState } from "react";
|
|
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
|
|
import type { Ticket } from "@/payload-types";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Item, ItemContent, ItemDescription, ItemMedia, ItemTitle } from "@/components/ui/item";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import { CreateTicketDialog } from "@/components/frontend/helpdesk/CreateTicketDialog";
|
|
import { TicketCard } from "@/components/frontend/helpdesk/TicketCard";
|
|
import { CLOSING_STATUSES, STATUS_STYLES, type TicketStatus } from "@/lib/tickets/ticketMeta";
|
|
import { InboxIcon, SearchIcon } from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
type TicketFilter = "all" | "open" | "resolved" | "closed" | "unassigned";
|
|
|
|
interface TicketsViewProps {
|
|
readonly tickets: readonly Ticket[];
|
|
readonly currentUserId: number;
|
|
readonly isStaff: boolean;
|
|
}
|
|
|
|
const OPEN_STATUSES: readonly TicketStatus[] = [
|
|
"open",
|
|
"in_review",
|
|
"planning",
|
|
"in_progress",
|
|
"waiting_on_reporter",
|
|
"in_testing",
|
|
];
|
|
|
|
const FILTER_OPTIONS = [
|
|
{ label: "All", value: "all" },
|
|
{ label: "Open", value: "open" },
|
|
{ label: "Resolved", value: "resolved" },
|
|
{ label: "Closed", value: "closed" },
|
|
{ label: "Unassigned", value: "unassigned" },
|
|
] as const satisfies readonly { label: string; value: TicketFilter }[];
|
|
|
|
function isTicketFilter(value: string): value is TicketFilter {
|
|
return FILTER_OPTIONS.some((option) => option.value === value);
|
|
}
|
|
|
|
function isOpenTicket(ticket: Ticket): boolean {
|
|
return OPEN_STATUSES.includes(ticket.status);
|
|
}
|
|
|
|
function isUnassignedTicket(ticket: Ticket): boolean {
|
|
return (
|
|
(ticket.assignee === null || ticket.assignee === undefined) &&
|
|
!CLOSING_STATUSES.includes(ticket.status)
|
|
);
|
|
}
|
|
|
|
export function TicketsView({ tickets, currentUserId: _currentUserId, isStaff }: TicketsViewProps) {
|
|
const [activeFilter, setActiveFilter] = useState<TicketFilter>("all");
|
|
const [search, setSearch] = useState("");
|
|
|
|
const counts = useMemo<Record<TicketFilter, number>>(
|
|
() => ({
|
|
all: tickets.length,
|
|
open: tickets.filter(isOpenTicket).length,
|
|
resolved: tickets.filter((ticket) => ticket.status === "resolved").length,
|
|
closed: tickets.filter((ticket) => ticket.status === "canceled" || ticket.status === "closed")
|
|
.length,
|
|
unassigned: tickets.filter(isUnassignedTicket).length,
|
|
}),
|
|
[tickets],
|
|
);
|
|
|
|
const filteredTickets = useMemo(() => {
|
|
const query = search.trim().toLowerCase();
|
|
return tickets.filter((ticket) => {
|
|
const matchesFilter =
|
|
activeFilter === "all" ||
|
|
(activeFilter === "open" && isOpenTicket(ticket)) ||
|
|
(activeFilter === "resolved" && ticket.status === "resolved") ||
|
|
(activeFilter === "closed" &&
|
|
(ticket.status === "canceled" || ticket.status === "closed")) ||
|
|
(activeFilter === "unassigned" && isUnassignedTicket(ticket));
|
|
if (!matchesFilter) return false;
|
|
if (!query) return true;
|
|
|
|
const description = convertLexicalToPlaintext({ data: ticket.description });
|
|
return `${ticket.title} ${description}`.toLowerCase().includes(query);
|
|
});
|
|
}, [activeFilter, search, tickets]);
|
|
|
|
const visibleFilters = isStaff
|
|
? FILTER_OPTIONS
|
|
: FILTER_OPTIONS.filter((option) => option.value !== "unassigned");
|
|
|
|
// Helper to derive a small per-filter class from STATUS_STYLES.
|
|
// STATUS_STYLES is keyed by TicketStatus and contains a space-separated
|
|
// class string (e.g. "bg-emerald-50 text-emerald-700"). The original
|
|
// implementation attempted to build a complex mapping but had type errors
|
|
// and didn't return values from the map. Keep this simple and typed: pick
|
|
// the second token (usually the foreground color) when available.
|
|
const statusStyles = STATUS_STYLES as Record<string, string>;
|
|
|
|
function classForFilter(filter: TicketFilter): string {
|
|
switch (filter) {
|
|
case "open":
|
|
return (statusStyles.open ?? "").split(" ")[1] ?? "";
|
|
case "resolved":
|
|
return (statusStyles.resolved ?? "").split(" ")[1] ?? "";
|
|
case "closed":
|
|
// prefer explicit closed style, fall back to canceled if present
|
|
return (statusStyles.closed ?? statusStyles.canceled ?? "").split(" ")[1] ?? "";
|
|
case "unassigned":
|
|
return "text-muted-foreground";
|
|
case "all":
|
|
default:
|
|
return "";
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-5">
|
|
<div className="flex flex-wrap items-end justify-between gap-3">
|
|
<div className="flex flex-col gap-1">
|
|
<div className="flex items-center gap-2">
|
|
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
|
{isStaff ? "Ticket queue" : "Your tickets"}
|
|
</h2>
|
|
<span className="text-xs text-muted-foreground">{tickets.length} total</span>
|
|
</div>
|
|
<p className="text-sm text-muted-foreground">
|
|
{isStaff
|
|
? "Review incoming requests and keep members updated."
|
|
: "Keep an eye on your open requests."}
|
|
</p>
|
|
</div>
|
|
<CreateTicketDialog />
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
|
<Tabs
|
|
value={activeFilter}
|
|
onValueChange={(value) => {
|
|
if (isTicketFilter(value)) setActiveFilter(value);
|
|
}}
|
|
>
|
|
<TabsList className="max-w-full justify-start sm:w-fit gap-1">
|
|
{visibleFilters.map((option) => (
|
|
<TabsTrigger
|
|
key={option.value}
|
|
value={option.value}
|
|
className={cn("flex-none", classForFilter(option.value))}
|
|
>
|
|
{option.label}
|
|
<Badge variant="secondary" className="px-1.5 text-[10px]">
|
|
{counts[option.value]}
|
|
</Badge>
|
|
</TabsTrigger>
|
|
))}
|
|
</TabsList>
|
|
</Tabs>
|
|
|
|
<div className="relative w-full lg:max-w-xs">
|
|
<SearchIcon className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
|
<Input
|
|
aria-label="Search tickets"
|
|
className="pl-8"
|
|
placeholder="Search tickets..."
|
|
value={search}
|
|
onChange={(event) => setSearch(event.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{filteredTickets.length === 0 ? (
|
|
<Item variant="outline" className="">
|
|
<ItemMedia variant="icon">
|
|
<InboxIcon className="text-muted-foreground" />
|
|
</ItemMedia>
|
|
<ItemContent className="items-center gap-1">
|
|
<ItemTitle>{tickets.length === 0 ? "No tickets yet" : "No matching tickets"}</ItemTitle>
|
|
<ItemDescription>
|
|
{tickets.length === 0
|
|
? "Create a ticket to send a request to the helpdesk."
|
|
: "Try a different filter or search phrase."}
|
|
</ItemDescription>
|
|
</ItemContent>
|
|
</Item>
|
|
) : (
|
|
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
|
|
{filteredTickets.map((ticket) => (
|
|
<TicketCard key={ticket.id} ticket={ticket} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|