1
0
Fork 0

feat(helpdesk): add ticket card and detail 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:48 -04:00
parent f869f4e4cc
commit 32a8dfa89e
3 changed files with 47 additions and 11 deletions

View file

@ -10,9 +10,12 @@ import {
ItemTitle,
} from "@/components/ui/item";
import { TicketBadges, TicketCategoryBadge } from "@/components/frontend/helpdesk/TicketBadges";
import { TicketVoteButton } from "@/components/frontend/helpdesk/TicketVoteButton";
import type { TicketVoteSummary } from "@/lib/tickets/voteState";
interface TicketCardProps {
readonly ticket: Ticket;
readonly voteSummary: TicketVoteSummary;
}
type UserRelation = number | User | null | undefined;
@ -39,25 +42,23 @@ function timeAgo(iso: string): string {
return `${days}d ago`;
}
export function TicketCard({ ticket }: TicketCardProps) {
export function TicketCard({ ticket, voteSummary }: 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"
>
<Item variant="outline" className="h-full min-h-48 transition-colors 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}
<Link href={`/helpdesk/${ticket.id}`} className="hover:underline">
{ticket.title}
</Link>
</ItemTitle>
<ItemDescription>{description || "No description provided."}</ItemDescription>
<TicketBadges ticket={ticket} />
@ -70,8 +71,13 @@ export function TicketCard({ ticket }: TicketCardProps) {
<span className="shrink-0 text-right font-mono text-[10px] text-muted-foreground">
{timeAgo(ticket.updatedAt)}
</span>
<TicketVoteButton
ticketId={ticket.id}
voteCount={voteSummary.voteCount}
voted={voteSummary.voted}
disabled={ticket.status === "canceled"}
/>
</ItemFooter>
</Item>
</Link>
);
}

View file

@ -22,6 +22,9 @@ import { TicketTimeline } from "@/components/frontend/helpdesk/TicketTimeline";
import { assignTicket, replyToTicket, updateTicketStatus } from "@/app/(frontend)/helpdesk/actions";
import { CLOSING_STATUSES, TICKET_STATUSES, type TicketStatus } from "@/lib/tickets/ticketMeta";
import { CalendarClockIcon, HistoryIcon, SendIcon, Settings2Icon } from "lucide-react";
import { EditTicketDialog } from "@/components/frontend/helpdesk/EditTicketDialog";
import { TicketVoteButton } from "@/components/frontend/helpdesk/TicketVoteButton";
import type { TicketVoteSummary } from "@/lib/tickets/voteState";
interface AssigneeOption {
readonly id: number;
@ -33,6 +36,7 @@ interface TicketDetailProps {
readonly currentUserId: number;
readonly isStaff: boolean;
readonly assigneeOptions: readonly AssigneeOption[];
readonly voteSummary: TicketVoteSummary;
}
type UserRelation = number | User | null | undefined;
@ -77,6 +81,7 @@ export function TicketDetail({
currentUserId,
isStaff,
assigneeOptions,
voteSummary,
}: TicketDetailProps) {
const router = useRouter();
const [status, setStatus] = useState<TicketStatus>(ticket.status);
@ -105,6 +110,7 @@ export function TicketDetail({
const canCancel =
relationId(ticket.reporter) === currentUserId && !CLOSING_STATUSES.includes(ticket.status);
const descriptionText = convertLexicalToPlaintext({ data: ticket.description });
const isAuthor = relationId(ticket.reporter) === currentUserId;
async function applyControl(kind: ControlKind) {
setControlBusy(kind);
@ -183,6 +189,15 @@ export function TicketDetail({
<TicketBadges ticket={ticket} />
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<TicketVoteButton
ticketId={ticket.id}
voteCount={voteSummary.voteCount}
voted={voteSummary.voted}
disabled={ticket.status === "canceled"}
/>
{isAuthor && <EditTicketDialog ticket={ticket} description={descriptionText} />}
</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} />

View file

@ -13,12 +13,13 @@ import { CLOSING_STATUSES, STATUS_STYLES, type TicketStatus } from "@/lib/ticket
import { InboxIcon, PlusIcon, SearchIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { usePageCommands } from "@/components/command-palette/CommandPaletteContext";
import type { TicketVoteSummary } from "@/lib/tickets/voteState";
type TicketFilter = "all" | "open" | "resolved" | "closed" | "unassigned";
interface TicketsViewProps {
readonly tickets: readonly Ticket[];
readonly currentUserId: number;
readonly voteSummaries: readonly TicketVoteSummary[];
readonly isStaff: boolean;
}
@ -54,10 +55,14 @@ function isUnassignedTicket(ticket: Ticket): boolean {
);
}
export function TicketsView({ tickets, currentUserId: _currentUserId, isStaff }: TicketsViewProps) {
export function TicketsView({ tickets, voteSummaries, isStaff }: TicketsViewProps) {
const [activeFilter, setActiveFilter] = useState<TicketFilter>("all");
const [search, setSearch] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const votesByTicket = useMemo(
() => new Map(voteSummaries.map((summary) => [summary.ticketId, summary])),
[voteSummaries],
);
usePageCommands([
{
@ -200,7 +205,17 @@ export function TicketsView({ tickets, currentUserId: _currentUserId, isStaff }:
) : (
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
{filteredTickets.map((ticket) => (
<TicketCard key={ticket.id} ticket={ticket} />
<TicketCard
key={ticket.id}
ticket={ticket}
voteSummary={
votesByTicket.get(ticket.id) ?? {
ticketId: ticket.id,
voteCount: 0,
voted: false,
}
}
/>
))}
</div>
)}