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:
parent
14d92bf277
commit
c80ecf9141
6 changed files with 916 additions and 0 deletions
236
src/components/frontend/helpdesk/CreateTicketDialog.tsx
Normal file
236
src/components/frontend/helpdesk/CreateTicketDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
65
src/components/frontend/helpdesk/TicketBadges.tsx
Normal file
65
src/components/frontend/helpdesk/TicketBadges.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
src/components/frontend/helpdesk/TicketCard.tsx
Normal file
79
src/components/frontend/helpdesk/TicketCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
227
src/components/frontend/helpdesk/TicketDetail.tsx
Normal file
227
src/components/frontend/helpdesk/TicketDetail.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
src/components/frontend/helpdesk/TicketTimeline.tsx
Normal file
113
src/components/frontend/helpdesk/TicketTimeline.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
196
src/components/frontend/helpdesk/TicketsView.tsx
Normal file
196
src/components/frontend/helpdesk/TicketsView.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue