1
0
Fork 0
polaris-task-force/src/components/frontend/projects/KanbanTicketCard.tsx

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>
);
}