198 lines
7.4 KiB
TypeScript
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>
|
|
);
|
|
}
|