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:
parent
32a8dfa89e
commit
ab5af479c7
2 changed files with 256 additions and 0 deletions
189
src/components/frontend/helpdesk/EditTicketDialog.tsx
Normal file
189
src/components/frontend/helpdesk/EditTicketDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
src/components/frontend/helpdesk/TicketVoteButton.tsx
Normal file
67
src/components/frontend/helpdesk/TicketVoteButton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue