163 lines
5.4 KiB
TypeScript
163 lines
5.4 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { useDraggable } from "@dnd-kit/core";
|
|
import { CSS } from "@dnd-kit/utilities";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
|
import {
|
|
Item,
|
|
ItemContent,
|
|
ItemFooter,
|
|
ItemHeader,
|
|
ItemTitle,
|
|
} from "@/components/ui/item";
|
|
import {
|
|
PRIORITY_LABEL,
|
|
PRIORITY_STYLES,
|
|
STATUS_LABEL,
|
|
STATUS_STYLES,
|
|
type TicketPriority,
|
|
type TicketStatus,
|
|
} from "@/lib/tickets/ticketMeta";
|
|
import { TYPE_LABEL, TYPE_STYLES } from "@/lib/projects/projectMeta";
|
|
import { cn } from "@/lib/utils";
|
|
import type { KanbanTicket } from "./KanbanBoard";
|
|
|
|
interface KanbanTicketCardProps {
|
|
readonly ticket: KanbanTicket;
|
|
readonly overlay?: boolean;
|
|
}
|
|
|
|
function isTicketStatus(value: string): value is TicketStatus {
|
|
return value in STATUS_STYLES;
|
|
}
|
|
|
|
function isTicketPriority(value: string): value is TicketPriority {
|
|
return value in PRIORITY_STYLES;
|
|
}
|
|
|
|
function initials(displayName: string): string {
|
|
const parts = displayName.trim().split(/\s+/).filter(Boolean);
|
|
const first = parts[0]?.[0] ?? "?";
|
|
const last = parts.length > 1 ? (parts.at(-1)?.[0] ?? "") : "";
|
|
return `${first}${last}`.toUpperCase();
|
|
}
|
|
|
|
function badgeStyle(value: string, styles: Readonly<Record<string, string>>): string {
|
|
return styles[value] ?? "bg-muted/50 text-muted-foreground border-border";
|
|
}
|
|
|
|
function ticketTypeLabel(type: string): string {
|
|
return TYPE_LABEL[type] ?? type.replaceAll("_", " ");
|
|
}
|
|
|
|
function KanbanTicketCardBody({ ticket, overlay }: KanbanTicketCardProps) {
|
|
const priorityLabel = isTicketPriority(ticket.priority)
|
|
? PRIORITY_LABEL[ticket.priority]
|
|
: ticket.priority;
|
|
const statusStyle = isTicketStatus(ticket.status) ? STATUS_STYLES[ticket.status] : "";
|
|
const statusLabel = isTicketStatus(ticket.status)
|
|
? STATUS_LABEL[ticket.status]
|
|
: ticket.status.replaceAll("_", " ");
|
|
|
|
return (
|
|
<Item
|
|
variant="outline"
|
|
size="sm"
|
|
className={cn(
|
|
"items-start gap-3 bg-background/50 transition-colors hover:bg-accent/50",
|
|
overlay && "opacity-90 shadow-lg",
|
|
)}
|
|
>
|
|
<ItemHeader className="items-center">
|
|
<Badge
|
|
variant="outline"
|
|
className={cn(
|
|
"text-[10px] uppercase tracking-wide",
|
|
TYPE_STYLES[ticket.type] ?? "bg-muted/50 text-muted-foreground border-border",
|
|
)}
|
|
>
|
|
{ticketTypeLabel(ticket.type)}
|
|
</Badge>
|
|
<span className="font-mono text-[10px] text-muted-foreground">#{ticket.id}</span>
|
|
</ItemHeader>
|
|
|
|
<ItemContent className="w-full min-w-0 gap-2">
|
|
<ItemTitle className="w-full min-w-0 text-sm font-semibold">
|
|
<Link href={`/helpdesk/${ticket.id}`} className="line-clamp-2 hover:underline">
|
|
{ticket.title}
|
|
</Link>
|
|
</ItemTitle>
|
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
<Badge variant="outline" className={cn("text-[10px] uppercase tracking-wide", statusStyle)}>
|
|
{statusLabel}
|
|
</Badge>
|
|
<Badge
|
|
variant="outline"
|
|
className={cn("text-[10px] uppercase tracking-wide", badgeStyle(ticket.priority, PRIORITY_STYLES))}
|
|
>
|
|
{priorityLabel}
|
|
</Badge>
|
|
{ticket.storyPoints != null && (
|
|
<Badge variant="secondary" className="font-mono text-[10px]">
|
|
{ticket.storyPoints} pts
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</ItemContent>
|
|
|
|
<ItemFooter className="w-full min-w-0 flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
|
<div className="flex min-w-0 flex-1 items-center gap-2">
|
|
{ticket.assignee ? (
|
|
<>
|
|
<Avatar className="size-5">
|
|
<AvatarFallback className="text-[9px]">{initials(ticket.assignee.displayName)}</AvatarFallback>
|
|
</Avatar>
|
|
<span className="truncate">{ticket.assignee.displayName}</span>
|
|
</>
|
|
) : (
|
|
<span>Unassigned</span>
|
|
)}
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-1" aria-label="Ticket labels">
|
|
{ticket.labels.map((label) => (
|
|
<span
|
|
key={label.id}
|
|
title={label.name}
|
|
aria-label={label.name}
|
|
className="size-2 rounded-full border border-border"
|
|
style={{ backgroundColor: label.color }}
|
|
/>
|
|
))}
|
|
</div>
|
|
{ticket.reporter && <span className="max-w-full truncate">reported by {ticket.reporter.displayName}</span>}
|
|
</ItemFooter>
|
|
</Item>
|
|
);
|
|
}
|
|
|
|
export function KanbanTicketCard({ ticket, overlay = false }: KanbanTicketCardProps) {
|
|
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: ticket.id });
|
|
const style = transform ? { transform: CSS.Translate.toString(transform) } : undefined;
|
|
|
|
return (
|
|
<div
|
|
ref={setNodeRef}
|
|
style={style}
|
|
{...listeners}
|
|
{...attributes}
|
|
className={cn("touch-none outline-none", isDragging && "opacity-40")}
|
|
>
|
|
<KanbanTicketCardBody ticket={ticket} overlay={overlay} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function KanbanTicketOverlay({ ticket }: { readonly ticket: KanbanTicket }) {
|
|
return (
|
|
<div className="touch-none">
|
|
<KanbanTicketCardBody ticket={ticket} overlay />
|
|
</div>
|
|
);
|
|
}
|