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"
}