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,
|
ItemTitle,
|
||||||
} from "@/components/ui/item";
|
} from "@/components/ui/item";
|
||||||
import { TicketBadges, TicketCategoryBadge } from "@/components/frontend/helpdesk/TicketBadges";
|
import { TicketBadges, TicketCategoryBadge } from "@/components/frontend/helpdesk/TicketBadges";
|
||||||
|
import { TicketVoteButton } from "@/components/frontend/helpdesk/TicketVoteButton";
|
||||||
|
import type { TicketVoteSummary } from "@/lib/tickets/voteState";
|
||||||
|
|
||||||
interface TicketCardProps {
|
interface TicketCardProps {
|
||||||
readonly ticket: Ticket;
|
readonly ticket: Ticket;
|
||||||
|
readonly voteSummary: TicketVoteSummary;
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserRelation = number | User | null | undefined;
|
type UserRelation = number | User | null | undefined;
|
||||||
|
|
@ -39,25 +42,23 @@ function timeAgo(iso: string): string {
|
||||||
return `${days}d ago`;
|
return `${days}d ago`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TicketCard({ ticket }: TicketCardProps) {
|
export function TicketCard({ ticket, voteSummary }: TicketCardProps) {
|
||||||
const description = convertLexicalToPlaintext({ data: ticket.description });
|
const description = convertLexicalToPlaintext({ data: ticket.description });
|
||||||
const reporterName = userName(ticket.reporter, "Unknown reporter");
|
const reporterName = userName(ticket.reporter, "Unknown reporter");
|
||||||
const assigneeName = userName(ticket.assignee, "Unassigned");
|
const assigneeName = userName(ticket.assignee, "Unassigned");
|
||||||
const hasAssignee = ticket.assignee !== null && ticket.assignee !== undefined;
|
const hasAssignee = ticket.assignee !== null && ticket.assignee !== undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href={`/helpdesk/${ticket.id}`} className="group block h-full">
|
<Item variant="outline" className="h-full min-h-48 transition-colors hover:bg-accent/50">
|
||||||
<Item
|
|
||||||
variant="outline"
|
|
||||||
className="h-full min-h-48 transition-colors group-hover:bg-accent/50"
|
|
||||||
>
|
|
||||||
<ItemHeader className="items-start">
|
<ItemHeader className="items-start">
|
||||||
<TicketCategoryBadge category={ticket.category} />
|
<TicketCategoryBadge category={ticket.category} />
|
||||||
<span className="font-mono text-[10px] text-muted-foreground">#{ticket.id}</span>
|
<span className="font-mono text-[10px] text-muted-foreground">#{ticket.id}</span>
|
||||||
</ItemHeader>
|
</ItemHeader>
|
||||||
<ItemContent className="w-full min-w-0 flex-1 gap-3">
|
<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">
|
<ItemTitle className="line-clamp-2 min-w-0 max-w-full text-base font-semibold">
|
||||||
|
<Link href={`/helpdesk/${ticket.id}`} className="hover:underline">
|
||||||
{ticket.title}
|
{ticket.title}
|
||||||
|
</Link>
|
||||||
</ItemTitle>
|
</ItemTitle>
|
||||||
<ItemDescription>{description || "No description provided."}</ItemDescription>
|
<ItemDescription>{description || "No description provided."}</ItemDescription>
|
||||||
<TicketBadges ticket={ticket} />
|
<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">
|
<span className="shrink-0 text-right font-mono text-[10px] text-muted-foreground">
|
||||||
{timeAgo(ticket.updatedAt)}
|
{timeAgo(ticket.updatedAt)}
|
||||||
</span>
|
</span>
|
||||||
|
<TicketVoteButton
|
||||||
|
ticketId={ticket.id}
|
||||||
|
voteCount={voteSummary.voteCount}
|
||||||
|
voted={voteSummary.voted}
|
||||||
|
disabled={ticket.status === "canceled"}
|
||||||
|
/>
|
||||||
</ItemFooter>
|
</ItemFooter>
|
||||||
</Item>
|
</Item>
|
||||||
</Link>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,9 @@ import { TicketTimeline } from "@/components/frontend/helpdesk/TicketTimeline";
|
||||||
import { assignTicket, replyToTicket, updateTicketStatus } from "@/app/(frontend)/helpdesk/actions";
|
import { assignTicket, replyToTicket, updateTicketStatus } from "@/app/(frontend)/helpdesk/actions";
|
||||||
import { CLOSING_STATUSES, TICKET_STATUSES, type TicketStatus } from "@/lib/tickets/ticketMeta";
|
import { CLOSING_STATUSES, TICKET_STATUSES, type TicketStatus } from "@/lib/tickets/ticketMeta";
|
||||||
import { CalendarClockIcon, HistoryIcon, SendIcon, Settings2Icon } from "lucide-react";
|
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 {
|
interface AssigneeOption {
|
||||||
readonly id: number;
|
readonly id: number;
|
||||||
|
|
@ -33,6 +36,7 @@ interface TicketDetailProps {
|
||||||
readonly currentUserId: number;
|
readonly currentUserId: number;
|
||||||
readonly isStaff: boolean;
|
readonly isStaff: boolean;
|
||||||
readonly assigneeOptions: readonly AssigneeOption[];
|
readonly assigneeOptions: readonly AssigneeOption[];
|
||||||
|
readonly voteSummary: TicketVoteSummary;
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserRelation = number | User | null | undefined;
|
type UserRelation = number | User | null | undefined;
|
||||||
|
|
@ -77,6 +81,7 @@ export function TicketDetail({
|
||||||
currentUserId,
|
currentUserId,
|
||||||
isStaff,
|
isStaff,
|
||||||
assigneeOptions,
|
assigneeOptions,
|
||||||
|
voteSummary,
|
||||||
}: TicketDetailProps) {
|
}: TicketDetailProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [status, setStatus] = useState<TicketStatus>(ticket.status);
|
const [status, setStatus] = useState<TicketStatus>(ticket.status);
|
||||||
|
|
@ -105,6 +110,7 @@ export function TicketDetail({
|
||||||
const canCancel =
|
const canCancel =
|
||||||
relationId(ticket.reporter) === currentUserId && !CLOSING_STATUSES.includes(ticket.status);
|
relationId(ticket.reporter) === currentUserId && !CLOSING_STATUSES.includes(ticket.status);
|
||||||
const descriptionText = convertLexicalToPlaintext({ data: ticket.description });
|
const descriptionText = convertLexicalToPlaintext({ data: ticket.description });
|
||||||
|
const isAuthor = relationId(ticket.reporter) === currentUserId;
|
||||||
|
|
||||||
async function applyControl(kind: ControlKind) {
|
async function applyControl(kind: ControlKind) {
|
||||||
setControlBusy(kind);
|
setControlBusy(kind);
|
||||||
|
|
@ -183,6 +189,15 @@ export function TicketDetail({
|
||||||
<TicketBadges ticket={ticket} />
|
<TicketBadges ticket={ticket} />
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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="Reporter" value={reporterName} />
|
||||||
<MetaItem label="Assignee" value={assigneeName} />
|
<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 { InboxIcon, PlusIcon, SearchIcon } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { usePageCommands } from "@/components/command-palette/CommandPaletteContext";
|
import { usePageCommands } from "@/components/command-palette/CommandPaletteContext";
|
||||||
|
import type { TicketVoteSummary } from "@/lib/tickets/voteState";
|
||||||
|
|
||||||
type TicketFilter = "all" | "open" | "resolved" | "closed" | "unassigned";
|
type TicketFilter = "all" | "open" | "resolved" | "closed" | "unassigned";
|
||||||
|
|
||||||
interface TicketsViewProps {
|
interface TicketsViewProps {
|
||||||
readonly tickets: readonly Ticket[];
|
readonly tickets: readonly Ticket[];
|
||||||
readonly currentUserId: number;
|
readonly voteSummaries: readonly TicketVoteSummary[];
|
||||||
readonly isStaff: boolean;
|
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 [activeFilter, setActiveFilter] = useState<TicketFilter>("all");
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const votesByTicket = useMemo(
|
||||||
|
() => new Map(voteSummaries.map((summary) => [summary.ticketId, summary])),
|
||||||
|
[voteSummaries],
|
||||||
|
);
|
||||||
|
|
||||||
usePageCommands([
|
usePageCommands([
|
||||||
{
|
{
|
||||||
|
|
@ -200,7 +205,17 @@ export function TicketsView({ tickets, currentUserId: _currentUserId, isStaff }:
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
|
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
|
||||||
{filteredTickets.map((ticket) => (
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue