1
0
Fork 0

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>
This commit is contained in:
Jason Fraley 2026-08-16 18:12:44 -04:00
parent 14d92bf277
commit c80ecf9141
6 changed files with 916 additions and 0 deletions

View file

@ -0,0 +1,236 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { PlusIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { createTicket } from "@/app/(frontend)/helpdesk/actions";
import {
TICKET_CATEGORIES,
TICKET_PRIORITIES,
type TicketCategory,
type TicketPriority,
} from "@/lib/tickets/ticketMeta";
import { cn } from "@/lib/utils";
function isTicketCategory(value: string): value is TicketCategory {
return TICKET_CATEGORIES.some((option) => option.value === value);
}
function isTicketPriority(value: string): value is TicketPriority {
return TICKET_PRIORITIES.some((option) => option.value === value);
}
export function CreateTicketDialog() {
const router = useRouter();
const [open, setOpen] = useState(false);
const [title, setTitle] = useState("");
const [category, setCategory] = useState<TicketCategory>("support_request");
const [priority, setPriority] = useState<TicketPriority>("medium");
const [description, setDescription] = useState("");
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
function reset() {
setTitle("");
setCategory("support_request");
setPriority("medium");
setDescription("");
setPending(false);
setError(null);
}
async function handleSubmit() {
const trimmedTitle = title.trim();
const trimmedDescription = description.trim();
if (!trimmedTitle) {
setError("Add a short title for your request.");
return;
}
if (!trimmedDescription) {
setError("Describe the issue or request so the team can help.");
return;
}
setPending(true);
setError(null);
const result = await createTicket({
title: trimmedTitle,
description: trimmedDescription,
category,
priority,
});
if (result.success) {
setOpen(false);
reset();
toast.success("Ticket created");
router.refresh();
} else {
setError(result.error ?? "Could not create the ticket.");
setPending(false);
}
}
const getColorForCategoryLabel = (label: string) => {
switch (label.toLowerCase()) {
case "support_request":
return "text-blue-300";
case "feature_request":
return "text-green-300";
case "bug_report":
return "text-red-300";
default:
return "text-muted-foreground";
}
};
const getColorForPriorityLabel = (label: string) => {
switch (label.toLowerCase()) {
case "low":
return "text-zinc-300";
case "medium":
return "text-blue-300";
case "high":
return "text-amber-300";
case "critical":
return "text-red-300";
default:
return "text-muted-foreground";
}
}
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
setOpen(nextOpen);
if (!nextOpen) reset();
}}
>
<DialogTrigger asChild>
<Button size="sm">
<PlusIcon className="size-4" />
New ticket
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>New ticket</DialogTitle>
<DialogDescription>
Share the details of what you need help with. The helpdesk will follow up here.
</DialogDescription>
</DialogHeader>
<form
className="flex flex-col gap-4"
onSubmit={(event) => {
event.preventDefault();
void handleSubmit();
}}
>
<div className="flex flex-col gap-1.5">
<Label htmlFor="ticket-title">Title</Label>
<Input
id="ticket-title"
placeholder="What do you need help with?"
value={title}
onChange={(event) => setTitle(event.target.value)}
disabled={pending}
/>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="flex flex-col gap-1.5">
<Label htmlFor="ticket-category">Category</Label>
<Select
value={category}
onValueChange={(value) => {
if (isTicketCategory(value)) setCategory(value);
}}
disabled={pending}
>
<SelectTrigger id="ticket-category" className={cn("w-full", getColorForCategoryLabel(category))}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{TICKET_CATEGORIES.map((option) => (
<SelectItem key={option.value} value={option.value} className={getColorForCategoryLabel(option.value)}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="ticket-priority">Priority</Label>
<Select
value={priority}
onValueChange={(value) => {
if (isTicketPriority(value)) setPriority(value);
}}
disabled={pending}
>
<SelectTrigger id="ticket-priority" className={cn("w-full", getColorForPriorityLabel(priority))}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{TICKET_PRIORITIES.map((option) => (
<SelectItem key={option.value} value={option.value} className={getColorForPriorityLabel(option.value)}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex flex-col gap-1.5">
<Message title="Please provide a description" message="Include what happened, what you expected, and any useful context." />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="ticket-description">Description</Label>
<Textarea
id="ticket-description"
rows={5}
placeholder="Include what happened, what you expected, and any useful context."
value={description}
onChange={(event) => setDescription(event.target.value)}
disabled={pending}
/>
</div>
{error && (
<p role="alert" className="text-sm text-red-400">
{error}
</p>
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)} disabled={pending}>
Cancel
</Button>
<Button type="submit" disabled={pending}>
{pending ? "Creating..." : "Create ticket"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,65 @@
import type { Ticket } from "@/payload-types";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import {
CATEGORY_LABEL,
CATEGORY_STYLES,
PRIORITY_LABEL,
PRIORITY_STYLES,
STATUS_LABEL,
STATUS_STYLES,
type TicketCategory,
type TicketPriority,
type TicketStatus,
} from "@/lib/tickets/ticketMeta";
interface TicketStatusBadgeProps {
readonly status: TicketStatus;
}
interface TicketPriorityBadgeProps {
readonly priority: TicketPriority;
}
interface TicketCategoryBadgeProps {
readonly category: TicketCategory;
}
interface TicketBadgesProps {
readonly ticket: Pick<Ticket, "status" | "priority">;
}
const badgeTextClass = "text-[10px] uppercase tracking-wide";
export function TicketStatusBadge({ status }: TicketStatusBadgeProps) {
return (
<Badge variant="outline" className={cn(badgeTextClass, STATUS_STYLES[status])}>
{STATUS_LABEL[status]}
</Badge>
);
}
export function TicketPriorityBadge({ priority }: TicketPriorityBadgeProps) {
return (
<Badge variant="outline" className={cn(badgeTextClass, PRIORITY_STYLES[priority])}>
{PRIORITY_LABEL[priority]}
</Badge>
);
}
export function TicketCategoryBadge({ category }: TicketCategoryBadgeProps) {
return (
<Badge variant="outline" className={cn(badgeTextClass, CATEGORY_STYLES[category])}>
{CATEGORY_LABEL[category]}
</Badge>
);
}
export function TicketBadges({ ticket }: TicketBadgesProps) {
return (
<div className="flex flex-wrap items-center gap-1.5">
<TicketStatusBadge status={ticket.status} />
<TicketPriorityBadge priority={ticket.priority} />
</div>
);
}

View file

@ -0,0 +1,79 @@
import Link from "next/link";
import type { Ticket, User } from "@/payload-types";
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
import {
Item,
ItemContent,
ItemDescription,
ItemFooter,
ItemHeader,
ItemTitle,
} from "@/components/ui/item";
import {
TicketBadges,
TicketCategoryBadge,
} from "@/components/frontend/helpdesk/TicketBadges";
interface TicketCardProps {
readonly ticket: Ticket;
}
type UserRelation = number | User | null | undefined;
function resolveUser(relation: UserRelation): User | null {
return typeof relation === "object" && relation ? relation : null;
}
function userName(relation: UserRelation, fallback: string): string {
const user = resolveUser(relation);
if (user) return user.payloadDisplayName || user.username || user.displayName;
if (typeof relation === "number") return `User #${relation}`;
return fallback;
}
function timeAgo(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return "just now";
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
export function TicketCard({ ticket }: TicketCardProps) {
const description = convertLexicalToPlaintext({ data: ticket.description });
const reporterName = userName(ticket.reporter, "Unknown reporter");
const assigneeName = userName(ticket.assignee, "Unassigned");
const hasAssignee = ticket.assignee !== null && ticket.assignee !== undefined;
return (
<Link href={`/helpdesk/${ticket.id}`} className="group block h-full">
<Item variant="outline" className="h-full min-h-48 transition-colors group-hover:bg-accent/50">
<ItemHeader className="items-start">
<TicketCategoryBadge category={ticket.category} />
<span className="font-mono text-[10px] text-muted-foreground">#{ticket.id}</span>
</ItemHeader>
<ItemContent className="w-full min-w-0 flex-1 gap-3">
<ItemTitle className="line-clamp-2 min-w-0 max-w-full text-base font-semibold">
{ticket.title}
</ItemTitle>
<ItemDescription>
{description || "No description provided."}
</ItemDescription>
<TicketBadges ticket={ticket} />
</ItemContent>
<ItemFooter className="w-full flex-wrap items-end">
<div className="flex min-w-0 flex-1 flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span>opened by {reporterName}</span>
{hasAssignee && <span>assigned to {assigneeName}</span>}
</div>
<span className="shrink-0 text-right font-mono text-[10px] text-muted-foreground">
{timeAgo(ticket.updatedAt)}
</span>
</ItemFooter>
</Item>
</Link>
);
}

View file

@ -0,0 +1,227 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { RichText } from "@payloadcms/richtext-lexical/react";
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import type { Ticket, User } from "@/payload-types";
import { Button } from "@/components/ui/button";
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { TicketBadges, TicketCategoryBadge } from "@/components/frontend/helpdesk/TicketBadges";
import { TicketTimeline } from "@/components/frontend/helpdesk/TicketTimeline";
import { replyToTicket, updateTicketStatus, assignTicket } from "@/app/(frontend)/helpdesk/actions";
import {
CLOSING_STATUSES,
TICKET_STATUSES,
type TicketStatus,
} from "@/lib/tickets/ticketMeta";
import { CalendarClockIcon, HistoryIcon, SendIcon, Settings2Icon } from "lucide-react";
interface AssigneeOption {
readonly id: number;
readonly label: string;
}
interface TicketDetailProps {
readonly ticket: Ticket;
readonly currentUserId: number;
readonly isStaff: boolean;
readonly assigneeOptions: readonly AssigneeOption[];
}
type UserRelation = number | User | null | undefined;
type ControlKind = "status" | "assignee";
function relationId(relation: UserRelation): number | null {
return typeof relation === "object" && relation ? relation.id : relation ?? null;
}
function displayName(relation: UserRelation, fallback: string): string {
if (typeof relation === "object" && relation) {
return relation.payloadDisplayName || relation.username || relation.displayName;
}
return typeof relation === "number" ? `User #${relation}` : fallback;
}
function formatDate(iso: string | null | undefined): string {
return iso
? new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(
new Date(iso),
)
: "—";
}
function isTicketStatus(value: string): value is TicketStatus {
return TICKET_STATUSES.some((option) => option.value === value);
}
function MetaItem({ label, value }: { readonly label: string; readonly value: string }) {
return (
<div className="flex min-w-0 flex-col gap-1">
<span className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">{label}</span>
<span className="truncate text-sm">{value}</span>
</div>
);
}
export function TicketDetail({
ticket,
currentUserId,
isStaff,
assigneeOptions,
}: TicketDetailProps) {
const router = useRouter();
const [status, setStatus] = useState<TicketStatus>(ticket.status);
const [assignee, setAssignee] = useState(() => {
const id = relationId(ticket.assignee);
return id === null ? "unassigned" : String(id);
});
const [controlBusy, setControlBusy] = useState<ControlKind | null>(null);
const [controlError, setControlError] = useState<string | null>(null);
const [reply, setReply] = useState("");
const [replyBusy, setReplyBusy] = useState(false);
const [replyError, setReplyError] = useState<string | null>(null);
const [confirmCancel, setConfirmCancel] = useState(false);
const [cancelBusy, setCancelBusy] = useState(false);
const cancelTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => () => {
if (cancelTimerRef.current) clearTimeout(cancelTimerRef.current);
}, []);
const reporterName = displayName(ticket.reporter, "Unknown reporter");
const assigneeName = displayName(ticket.assignee, "Unassigned");
const canCancel = relationId(ticket.reporter) === currentUserId && !CLOSING_STATUSES.includes(ticket.status);
const descriptionText = convertLexicalToPlaintext({ data: ticket.description });
async function applyControl(kind: ControlKind) {
setControlBusy(kind);
setControlError(null);
const assigneeId = assignee === "unassigned" ? null : Number(assignee);
if (kind === "assignee" && (assigneeId === null || !Number.isInteger(assigneeId))) {
setControlError("Choose a valid assignee.");
setControlBusy(null);
return;
}
const result =
kind === "status"
? await updateTicketStatus(ticket.id, status)
: await assignTicket(ticket.id, assigneeId);
setControlBusy(null);
if (result.success) {
toast.success(kind === "status" ? "Ticket status updated" : "Ticket assignment updated");
router.refresh();
} else {
setControlError(result.error ?? "Could not update the ticket.");
}
}
async function handleReply() {
const content = reply.trim();
if (!content) {
setReplyError("Write a reply before sending.");
return;
}
setReplyBusy(true);
setReplyError(null);
const result = await replyToTicket(ticket.id, content);
setReplyBusy(false);
if (result.success) {
setReply("");
toast.success("Reply sent");
router.refresh();
} else {
setReplyError(result.error ?? "Could not send the reply.");
}
}
async function handleCancel() {
setCancelBusy(true);
const result = await updateTicketStatus(ticket.id, "canceled");
setCancelBusy(false);
if (result.success) {
setConfirmCancel(false);
toast.success("Ticket canceled");
router.refresh();
} else {
setReplyError(result.error ?? "Could not cancel the ticket.");
}
}
function requestCancel() {
if (confirmCancel) {
void handleCancel();
return;
}
setConfirmCancel(true);
if (cancelTimerRef.current) clearTimeout(cancelTimerRef.current);
cancelTimerRef.current = setTimeout(() => {
setConfirmCancel(false);
cancelTimerRef.current = null;
}, 3000);
}
return (
<div className="flex w-full max-w-5xl flex-col gap-6">
<section className="flex flex-col gap-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<h2 className="min-w-0 flex-1 text-xl font-semibold leading-tight">{ticket.title}</h2>
<div className="flex flex-wrap items-center gap-1.5"><TicketCategoryBadge category={ticket.category} /><TicketBadges ticket={ticket} /></div>
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-3 rounded-md border bg-muted/20 p-4 sm:grid-cols-4">
<MetaItem label="Reporter" value={reporterName} /><MetaItem label="Assignee" value={assigneeName} />
<MetaItem label="Created" value={formatDate(ticket.createdAt)} /><MetaItem label="Updated" value={formatDate(ticket.updatedAt)} />
<MetaItem label="Closed" value={formatDate(ticket.closedAt)} />
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2"><CalendarClockIcon className="size-4 text-muted-foreground" /><h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Description</h3></div>
<div className="rounded-md border bg-muted/20 p-4 text-sm leading-relaxed text-muted-foreground [&_p]:mb-2 [&_p:last-child]:mb-0 [&_strong]:text-foreground [&_em]:italic">
{descriptionText ? <RichText data={ticket.description} /> : <p>No description provided.</p>}
</div>
</div>
</section>
{isStaff && (
<Item variant="outline" className="bg-muted/20">
<ItemHeader><div className="flex items-center gap-2"><Settings2Icon className="size-4 text-muted-foreground" /><ItemTitle>Staff controls</ItemTitle></div></ItemHeader>
<ItemContent className="grid w-full grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-1.5"><Label htmlFor="ticket-status">Status</Label><div className="flex gap-2">
<Select value={status} onValueChange={(value) => { if (isTicketStatus(value)) setStatus(value); }} disabled={controlBusy !== null}>
<SelectTrigger id="ticket-status" className="min-w-0 flex-1"><SelectValue /></SelectTrigger>
<SelectContent>{TICKET_STATUSES.map((option) => <SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>)}</SelectContent>
</Select>
<Button size="sm" onClick={() => void applyControl("status")} disabled={controlBusy !== null}>{controlBusy === "status" ? "Applying..." : "Apply"}</Button>
</div></div>
<div className="flex flex-col gap-1.5"><Label htmlFor="ticket-assignee">Assignee</Label><div className="flex gap-2">
<Select value={assignee} onValueChange={setAssignee} disabled={controlBusy !== null}>
<SelectTrigger id="ticket-assignee" className="min-w-0 flex-1"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="unassigned">Unassigned</SelectItem>{assigneeOptions.map((option) => <SelectItem key={option.id} value={String(option.id)}>{option.label}</SelectItem>)}</SelectContent>
</Select>
<Button size="sm" onClick={() => void applyControl("assignee")} disabled={controlBusy !== null}>{controlBusy === "assignee" ? "Applying..." : "Apply"}</Button>
</div></div>
{controlError && <p role="alert" className="text-sm text-red-400 sm:col-span-2">{controlError}</p>}
</ItemContent>
</Item>
)}
<section className="flex flex-col gap-3">
<div className="flex items-center gap-2"><HistoryIcon className="size-4 text-muted-foreground" /><h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Activity</h3><span className="text-xs text-muted-foreground">{ticket.activity?.length ?? 0} entries</span></div>
<Item variant="outline" className="bg-muted/20"><ItemContent className="w-full"><TicketTimeline ticket={ticket} currentUserId={currentUserId} /></ItemContent></Item>
</section>
<Item variant="outline">
<ItemHeader><div className="flex items-center gap-2"><SendIcon className="size-4 text-muted-foreground" /><ItemTitle>Reply</ItemTitle></div><span className="text-xs text-muted-foreground">{isStaff ? "Reply as staff" : "Send reply"}</span></ItemHeader>
<ItemContent className="w-full gap-3">
<Textarea rows={4} value={reply} onChange={(event) => setReply(event.target.value)} placeholder={isStaff ? "Write an update for the reporter..." : "Add more information..."} disabled={replyBusy} />
<div className="flex flex-wrap items-center justify-between gap-2">{replyError && <p role="alert" className="text-sm text-red-400">{replyError}</p>}<Button className="ml-auto" onClick={() => void handleReply()} disabled={replyBusy}>{replyBusy ? "Sending..." : isStaff ? "Reply as staff" : "Send reply"}</Button></div>
</ItemContent>
</Item>
{canCancel && <div className="flex justify-end"><Button variant="destructive" onClick={requestCancel} disabled={cancelBusy}>{cancelBusy ? "Canceling..." : confirmCancel ? "Confirm cancel?" : "Cancel ticket"}</Button></div>}
</div>
);
}

View file

@ -0,0 +1,113 @@
"use client";
import { RichText } from "@payloadcms/richtext-lexical/react";
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
import type { Ticket, User } from "@/payload-types";
import { cn } from "@/lib/utils";
import { HistoryIcon } from "lucide-react";
interface TicketTimelineProps {
readonly ticket: Pick<Ticket, "activity" | "reporter">;
readonly currentUserId: number;
}
type UserRelation = number | User | null | undefined;
function resolveUser(relation: UserRelation): User | null {
return typeof relation === "object" && relation ? relation : null;
}
function userId(relation: UserRelation): number | null {
return typeof relation === "object" && relation ? relation.id : relation ?? null;
}
function displayName(relation: UserRelation, fallback: string): string {
const user = resolveUser(relation);
if (user) return user.payloadDisplayName || user.username || user.displayName;
if (typeof relation === "number") return `User #${relation}`;
return fallback;
}
function formatDate(iso: string | null | undefined): string {
if (!iso) return "Unknown time";
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(iso));
}
export function TicketTimeline({ ticket, currentUserId }: TicketTimelineProps) {
const activities = [...(ticket.activity ?? [])].sort(
(left, right) =>
new Date(left.created_at ?? 0).getTime() - new Date(right.created_at ?? 0).getTime(),
);
const reporterName = displayName(ticket.reporter, "Reporter");
return (
<div className="flex flex-col gap-3">
{activities.length === 0 && (
<p className="py-4 text-center text-sm italic text-muted-foreground">No activity yet.</p>
)}
{activities.map((activity, index) => {
const key = activity.id ?? `${activity.type}-${activity.created_at ?? "unknown"}-${index}`;
const content = convertLexicalToPlaintext({ data: activity.content });
if (activity.type === "system") {
return (
<div
key={key}
className="flex flex-wrap items-center justify-center gap-2 px-2 py-2 text-center text-xs text-muted-foreground"
>
<span aria-hidden="true" className="size-1.5 rounded-full bg-muted-foreground/50" />
<HistoryIcon className="size-3.5" />
<span>{content || "Ticket activity updated"}</span>
<time className="font-mono text-[10px]" dateTime={activity.created_at ?? undefined}>
{formatDate(activity.created_at)}
</time>
</div>
);
}
const isClientMessage = activity.type === "client_message";
const authorId = userId(activity.author);
const authorName = displayName(
activity.author,
isClientMessage ? reporterName : "Staff member",
);
const bubbleLabel = isClientMessage
? authorId === currentUserId
? "You"
: reporterName
: "Staff";
return (
<div key={key} className={cn("flex", isClientMessage ? "justify-end" : "justify-start")}>
<div
className={cn(
"flex max-w-[min(85%,42rem)] flex-col gap-1 rounded-lg border px-3 py-2 text-sm",
isClientMessage
? "border-blue-500/30 bg-blue-500/10"
: "border-amber-500/30 bg-amber-500/5",
)}
>
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
{bubbleLabel}
</span>
<div
className={cn(
"prose prose-sm max-w-none prose-invert [&_p]:my-1 [&_ul]:ml-4 [&_ul]:list-disc [&_ol]:ml-4 [&_ol]:list-decimal",
isClientMessage ? "text-blue-100/90" : "text-amber-100/90",
)}
>
<RichText data={activity.content} />
</div>
<div className="text-[10px] text-muted-foreground">
{authorName} · {formatDate(activity.created_at)}
</div>
</div>
</div>
);
})}
</div>
);
}

View file

@ -0,0 +1,196 @@
"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>
);
}