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