1
0
Fork 0

feat(helpdesk): add ticket edit and vote controls

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-09-01 17:48:52 -04:00
parent 32a8dfa89e
commit ab5af479c7
2 changed files with 256 additions and 0 deletions

View file

@ -0,0 +1,189 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { PencilIcon } from "lucide-react";
import { toast } from "sonner";
import type { Ticket } from "@/payload-types";
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,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { editTicket } from "@/app/(frontend)/helpdesk/actions";
import {
TICKET_CATEGORIES,
TICKET_PRIORITIES,
type TicketCategory,
type TicketPriority,
} from "@/lib/tickets/ticketMeta";
interface EditTicketDialogProps {
readonly ticket: Ticket;
readonly description: string;
}
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 EditTicketDialog({ ticket, description }: EditTicketDialogProps) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [title, setTitle] = useState(ticket.title);
const [details, setDetails] = useState(description);
const [category, setCategory] = useState<TicketCategory>(ticket.category);
const [priority, setPriority] = useState<TicketPriority>(ticket.priority);
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit() {
setPending(true);
setError(null);
const result = await editTicket({
ticketId: ticket.id,
title,
description: details,
category,
priority,
});
setPending(false);
if (!result.success) {
setError(result.error ?? "Could not edit the ticket.");
return;
}
setOpen(false);
toast.success("Ticket updated");
router.refresh();
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button type="button" size="sm" variant="outline">
<PencilIcon data-icon="inline-start" />
Edit
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit ticket</DialogTitle>
<DialogDescription>
Update the request details. The previous and new values are retained in the audit trail.
</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-${ticket.id}-edit-title`}>Title</Label>
<Input
id={`ticket-${ticket.id}-edit-title`}
value={title}
maxLength={200}
disabled={pending}
onChange={(event) => setTitle(event.target.value)}
/>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="flex flex-col gap-1.5">
<Label htmlFor={`ticket-${ticket.id}-edit-category`}>Category</Label>
<Select
value={category}
disabled={pending}
onValueChange={(value) => {
if (isTicketCategory(value)) setCategory(value);
}}
>
<SelectTrigger id={`ticket-${ticket.id}-edit-category`} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{TICKET_CATEGORIES.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor={`ticket-${ticket.id}-edit-priority`}>Priority</Label>
<Select
value={priority}
disabled={pending}
onValueChange={(value) => {
if (isTicketPriority(value)) setPriority(value);
}}
>
<SelectTrigger id={`ticket-${ticket.id}-edit-priority`} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{TICKET_PRIORITIES.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor={`ticket-${ticket.id}-edit-description`}>Description</Label>
<Textarea
id={`ticket-${ticket.id}-edit-description`}
rows={6}
value={details}
disabled={pending}
onChange={(event) => setDetails(event.target.value)}
/>
</div>
{error && (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
)}
<DialogFooter>
<Button type="button" variant="outline" disabled={pending} onClick={() => setOpen(false)}>
Cancel
</Button>
<Button type="submit" disabled={pending}>
{pending ? "Saving..." : "Save changes"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,67 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { ArrowBigUpIcon } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { setTicketVote } from "@/app/(frontend)/helpdesk/actions";
interface TicketVoteButtonProps {
readonly ticketId: number;
readonly voteCount: number;
readonly voted: boolean;
readonly disabled?: boolean;
}
export function TicketVoteButton({
ticketId,
voteCount,
voted,
disabled = false,
}: TicketVoteButtonProps) {
const router = useRouter();
const [selected, setSelected] = useState(voted);
const [count, setCount] = useState(voteCount);
const [pending, setPending] = useState(false);
useEffect(() => {
setSelected(voted);
setCount(voteCount);
}, [voteCount, voted]);
async function handleVote() {
const nextVoted = !selected;
setPending(true);
const result = await setTicketVote(ticketId, nextVoted);
setPending(false);
if (!result.success || !result.data) {
toast.error(result.error ?? "Could not update your vote.");
return;
}
setSelected(result.data.voted);
setCount(result.data.voteCount);
router.refresh();
}
const label = selected ? `Remove upvote from ticket ${ticketId}` : `Upvote ticket ${ticketId}`;
return (
<Button
type="button"
size="sm"
variant={selected ? "default" : "outline"}
aria-label={label}
aria-pressed={selected}
disabled={disabled || pending}
title={disabled ? "Canceled tickets cannot receive votes" : undefined}
onClick={() => void handleVote()}
>
<ArrowBigUpIcon data-icon="inline-start" />
<span>{count.toLocaleString()}</span>
<span className="sr-only">{count === 1 ? "upvote" : "upvotes"}</span>
</Button>
);
}