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