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:
parent
f869f4e4cc
commit
32a8dfa89e
3 changed files with 47 additions and 11 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Reference in a new issue