"use client"; import { useMemo, useState } from "react"; import { DndContext, DragOverlay, KeyboardSensor, PointerSensor, useSensor, useSensors, } from "@dnd-kit/core"; import type { DragEndEvent, DragStartEvent, UniqueIdentifier } from "@dnd-kit/core"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; import { FilterIcon } from "lucide-react"; import { updateTicketStatus } from "@/app/(frontend)/helpdesk/actions"; import { TICKET_STATUSES, type TicketStatus } from "@/lib/tickets/ticketMeta"; import { KanbanColumn } from "./KanbanColumn"; import { KanbanFilters } from "./KanbanFilters"; import { KanbanTicketOverlay } from "./KanbanTicketCard"; export interface KanbanTicket { readonly id: number; readonly title: string; readonly status: string; readonly priority: string; readonly type: string; readonly storyPoints?: number | null; readonly sprintId?: number | null; readonly sprint?: { readonly id: number } | number | null; readonly labels: readonly { id: number; name: string; color: string }[]; readonly assignee: { id: number; displayName: string } | null; readonly reporter: { id: number; displayName: string } | null; } export interface KanbanColumn { readonly status: string; readonly label: string; readonly tickets: readonly KanbanTicket[]; } export type SprintFilter = "all" | number; export interface KanbanFiltersProps { readonly sprints: readonly { id: number; name: string; status: string; startDate?: string; endDate?: string }[]; readonly labels: readonly { id: number; name: string; color: string; project?: number | null }[]; readonly sprintFilter: SprintFilter; readonly selectedLabelIds: readonly number[]; readonly onSprintFilterChange: (value: SprintFilter) => void; readonly onLabelToggle: (labelId: number) => void; } export interface KanbanBoardProps { readonly project: { id: number; name: string; key: string }; readonly columns: readonly KanbanColumn[]; readonly sprints: readonly { id: number; name: string; status: string; startDate?: string; endDate?: string }[]; readonly labels: readonly { id: number; name: string; color: string; project?: number | null }[]; readonly currentUserId: number; readonly isAdmin: boolean; readonly showFilters?: boolean; readonly sprintFilter?: SprintFilter; readonly selectedLabelIds?: readonly number[]; readonly onSprintFilterChange?: (value: SprintFilter) => void; readonly onLabelToggle?: (labelId: number) => void; } function toNumber(value: UniqueIdentifier): number | null { if (typeof value === "number") return Number.isInteger(value) ? value : null; const parsed = Number(value); return Number.isInteger(parsed) ? parsed : null; } function toTicketStatus(value: UniqueIdentifier): TicketStatus | null { const status = String(value); return TICKET_STATUSES.some((option) => option.value === status) ? TICKET_STATUSES.find((option) => option.value === status)?.value ?? null : null; } function ticketSprintId(ticket: KanbanTicket): number | null { if (ticket.sprintId != null) return ticket.sprintId; if (typeof ticket.sprint === "number") return ticket.sprint; return ticket.sprint?.id ?? null; } export function KanbanBoard({ project, columns, sprints, labels, currentUserId: _currentUserId, isAdmin: _isAdmin, showFilters = true, sprintFilter: controlledSprintFilter, selectedLabelIds: controlledLabelIds, onSprintFilterChange, onLabelToggle, }: KanbanBoardProps) { const router = useRouter(); const [internalSprintFilter, setInternalSprintFilter] = useState("all"); const [internalLabelIds, setInternalLabelIds] = useState([]); const [activeTicket, setActiveTicket] = useState(null); const sprintFilter = controlledSprintFilter ?? internalSprintFilter; const selectedLabelIds = controlledLabelIds ?? internalLabelIds; const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), useSensor(KeyboardSensor), ); const allTickets = useMemo(() => columns.flatMap((column) => column.tickets), [columns]); const ticketsById = useMemo(() => new Map(allTickets.map((ticket) => [ticket.id, ticket])), [allTickets]); const filteredTicketsByColumn = useMemo( () => new Map( columns.map((column) => [ column.status, column.tickets.filter((ticket) => { const matchesSprint = sprintFilter === "all" || ticketSprintId(ticket) === sprintFilter; const matchesLabels = selectedLabelIds.length === 0 || ticket.labels.some((label) => selectedLabelIds.includes(label.id)); return matchesSprint && matchesLabels; }), ]), ), [columns, selectedLabelIds, sprintFilter], ); function setSprintFilter(value: SprintFilter) { onSprintFilterChange?.(value); if (!onSprintFilterChange) setInternalSprintFilter(value); } function toggleLabel(labelId: number) { onLabelToggle?.(labelId); if (!onLabelToggle) { setInternalLabelIds((current) => current.includes(labelId) ? current.filter((id) => id !== labelId) : [...current, labelId], ); } } function handleDragStart(event: DragStartEvent) { const ticketId = toNumber(event.active.id); setActiveTicket(ticketId === null ? null : (ticketsById.get(ticketId) ?? null)); } async function handleDragEnd(event: DragEndEvent) { const ticketId = toNumber(event.active.id); const nextStatus = event.over ? toTicketStatus(event.over.id) : null; setActiveTicket(null); if (ticketId === null || nextStatus === null) return; const ticket = ticketsById.get(ticketId); if (!ticket || ticket.status === nextStatus) return; try { const result = await updateTicketStatus(ticketId, nextStatus); if (!result.success) { toast.error(result.error ?? "Could not update the ticket status."); return; } toast.success(`Ticket moved to ${nextStatus.replaceAll("_", " ")}`); router.refresh(); } catch (error) { toast.error(error instanceof Error ? error.message : "Could not update the ticket status."); } } return (
{showFilters && (
Board filters
)} void handleDragEnd(event)} onDragCancel={() => setActiveTicket(null)}>
{columns.map((column) => ( ))}
{activeTicket ? : null}
); }