1
0
Fork 0
polaris-task-force/src/components/frontend/helpdesk/TicketsView.tsx
Z8MB1E c80ecf9141 feat(helpdesk): add helpdesk UI components
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>
2026-08-16 18:12:44 -04:00

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>
);
}