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

198 lines
7.4 KiB
TypeScript

"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<SprintFilter>("all");
const [internalLabelIds, setInternalLabelIds] = useState<readonly number[]>([]);
const [activeTicket, setActiveTicket] = useState<KanbanTicket | null>(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 (
<div className="flex min-w-0 flex-col gap-4" aria-label={`${project.name} Kanban board`}>
{showFilters && (
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<FilterIcon className="size-4" data-icon="inline-start" />
<span>Board filters</span>
</div>
<KanbanFilters
sprints={sprints}
labels={labels}
sprintFilter={sprintFilter}
selectedLabelIds={selectedLabelIds}
onSprintFilterChange={setSprintFilter}
onLabelToggle={toggleLabel}
/>
</div>
)}
<DndContext sensors={sensors} onDragStart={handleDragStart} onDragEnd={(event) => void handleDragEnd(event)} onDragCancel={() => setActiveTicket(null)}>
<div className="flex min-w-0 gap-4 overflow-x-auto pb-2" role="region" aria-label="Kanban columns" tabIndex={0}>
{columns.map((column) => (
<KanbanColumn key={column.status} column={column} tickets={filteredTicketsByColumn.get(column.status) ?? []} />
))}
</div>
<DragOverlay>{activeTicket ? <KanbanTicketOverlay ticket={activeTicket} /> : null}</DragOverlay>
</DndContext>
</div>
);
}