dodil/erp-ui
src/components/kanban/KanbanBoard.tsx
/**
* KanbanBoard — records in columns by their state: deals by stage, tickets by status.
*
* Moving a card is a COMMAND the API may refuse ("a won deal needs an accepted quote"), so the board never
* moves a card by itself: it calls `onMove` and the screen decides (optimistically only where the command is
* idempotent). Every move has a keyboard path — each card carries a "Move to" menu — and dragging is the
* mouse shortcut for the same call. A column can carry a footer (a total the screen computed exactly).
*
* On a narrow screen the board scrolls sideways inside itself, never the page.
*/
import { Badge, Box, Card, DropdownMenu, Flex, Heading, IconButton, Text } from "@radix-ui/themes";
import { useState, type ReactNode } from "react";
import { EmptyState, ErrorState, LoadingState } from "../states/States";
export interface KanbanColumn {
id: string;
title: string;
/** Under the title, e.g. "50% · best case". */
subtitle?: string;
footer?: ReactNode;
/** Cards may not be moved INTO this column; the reason is shown in the menu. */
closedReason?: string;
}
export interface KanbanBoardProps<T> {
label: string;
columns: KanbanColumn[];
items: T[] | undefined;
itemKey: (item: T) => string;
columnOf: (item: T) => string;
itemLabel: (item: T) => string;
renderCard: (item: T) => ReactNode;
/** Omit for a read-only board. */
onMove?: (item: T, to: string) => void;
/** Why this card cannot be moved (e.g. no permission, a closed deal) — shown in its menu; undefined = it can. */
moveRefusal?: (item: T) => string | undefined;
loading?: boolean;
error?: string | null;
empty?: ReactNode;
columnWidth?: number;
/** The columns' heading level: one below the page's title (h1 → h2, the default). */
headingAs?: "h2" | "h3" | "h4";
}
export function KanbanBoard<T>({
label, columns, items, itemKey, columnOf, itemLabel, renderCard, onMove, moveRefusal, loading, error, empty, columnWidth = 260, headingAs = "h2",
}: KanbanBoardProps<T>) {
const [dragging, setDragging] = useState<string | null>(null);
const [over, setOver] = useState<string | null>(null);
if (error) return <ErrorState error={error} />;
if (loading && !items) return <LoadingState />;
if (!items?.length) return <>{empty ?? <EmptyState title="Nothing on the board yet" />}</>;
const byKey = new Map(items.map((i) => [itemKey(i), i] as const));
return (
<Box role="region" aria-label={label} style={{ overflowX: "auto", paddingBottom: "var(--space-2)" }}>
<Flex gap="3" align="start" style={{ minWidth: "min-content" }}>
{columns.map((col) => {
const cards = items.filter((i) => columnOf(i) === col.id);
const droppable = !!onMove && !col.closedReason;
return (
<Box
key={col.id}
role="group"
aria-label={`${col.title}: ${cards.length}`}
style={{
width: columnWidth, flex: `0 0 ${columnWidth}px`, background: over === col.id ? "var(--accent-a3)" : "var(--gray-a2)",
borderRadius: "var(--radius-3)", padding: "var(--space-2)",
}}
onDragOver={droppable ? (e) => { e.preventDefault(); setOver(col.id); } : undefined}
onDragLeave={() => setOver(null)}
onDrop={droppable ? (e) => {
e.preventDefault();
setOver(null);
const item = dragging ? byKey.get(dragging) : undefined;
setDragging(null);
if (item && columnOf(item) !== col.id && !moveRefusal?.(item)) onMove!(item, col.id);
} : undefined}
>
<Flex justify="between" align="center" px="1" mb="2">
<Box>
<Heading as={headingAs} size="2">{col.title}</Heading>
{col.subtitle && <Text size="1" color="gray">{col.subtitle}</Text>}
</Box>
<Badge variant="soft" color="gray" radius="full">{cards.length}</Badge>
</Flex>
<Flex direction="column" gap="2" asChild>
<ul style={{ listStyle: "none", margin: 0, padding: 0, minHeight: 40 }}>
{cards.map((item) => {
const key = itemKey(item);
const refused = moveRefusal?.(item);
return (
<li key={key} data-card={key}>
<Card
draggable={!!onMove && !refused}
onDragStart={() => setDragging(key)}
onDragEnd={() => { setDragging(null); setOver(null); }}
style={{ opacity: dragging === key ? 0.5 : 1 }}
>
<Flex gap="2" align="start" justify="between">
<Box style={{ minWidth: 0, flex: 1 }}>{renderCard(item)}</Box>
{onMove && (
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<IconButton size="1" variant="ghost" color="gray" aria-label={`Move ${itemLabel(item)}`}>
<span aria-hidden="true">⋯</span>
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Label>{refused ?? "Move to"}</DropdownMenu.Label>
{columns.filter((c) => c.id !== col.id).map((c) => (
<DropdownMenu.Item key={c.id} disabled={!!refused || !!c.closedReason}
onSelect={() => onMove(item, c.id)}>
{c.title}{c.closedReason ? ` — ${c.closedReason}` : ""}
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
)}
</Flex>
</Card>
</li>
);
})}
</ul>
</Flex>
{col.footer && <Box px="1" pt="2">{col.footer}</Box>}
</Box>
);
})}
</Flex>
</Box>
);
}