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>
  );
}