# Work item board

> Work items in state columns, ordered by rank and moved through allowed transitions.

Source: https://bettersupabase.com/docs/registry/ui-recipes/work-item-board

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [work-items](https://bettersupabase.com/blocks/work-items), [state-machine](https://bettersupabase.com/blocks/state-machine), [rank](https://bettersupabase.com/blocks/rank) blocks must be installed (`npx better-supabase add work-items state-machine rank`). Its page on bettersupabase.com is [/recipes/work-item-board](https://bettersupabase.com/recipes/work-item-board).

Web, with shadcn components:

```bash
npx shadcn add badge card @reui/kanban
```

```tsx title="apps/examples/nextjs/src/features/work/components/work-item-board.tsx"
/**
 * Work items in the columns of the workflow's stages, each column labelled with
 * the state its stage stands for. Dragging to another column changes the stage,
 * which the pipelines trigger checks against the allowed transitions; dragging
 * within a column saves the order through the rank block. A refused move puts
 * the card back. The parent keys it by the items' positions, so a server
 * refresh starts it from the saved board.
 */
export function WorkItemBoard({
  stages,
  items,
  canMove,
}: {
  stages: readonly StageColumn[];
  items: readonly WorkItemRow[];
  canMove: boolean;
}) {
  const t = useExtracted("work");
  const errorMessage = useErrorMessage();
  const stateLabel = useStateLabel();
  const workErrorHint = useWorkErrorHint();
  const [columns, setColumns] = useState(() => columnsOf(stages, items));
  const move = useAction(moveWorkItem);
  const byId = new Map(items.map((item) => [item.id, item]));

  return (
    <div data-recipe="work-item-board">
      <Kanban
        value={columns}
        onValueChange={setColumns}
        getItemValue={(item) => item.id}
        onValueCommit={(value, meta) => {
          if (meta.kind !== "item") {
            return;
          }

          const column = value[meta.overContainer] ?? [];
          const previous = column[meta.overIndex - 1];
          const next = column[meta.overIndex + 1];

          void move
            .run({
              id: String(meta.event.active.id),
              stageId: meta.overContainer,
              stageChanged: meta.activeContainer !== meta.overContainer,
              previousId: previous?.id ?? null,
              nextId: next?.id ?? null,
            })
            .then((result) => {
              if (!result.ok) {
                setColumns(meta.previousValue);
                toast.error(
                  workErrorHint(result.error.hint) ??
                    errorMessage(result.error),
                );

                return;
              }

              if (!result.data.ordered) {
                toast.warning(
                  t("Moved, but its place in the column wasn't saved."),
                );
              }
            });
        }}
      >
        <KanbanBoard className="flex overflow-x-auto">
          {stages.map((stage) => {
            const state = stateOf(stage.category);
            const count = columns[stage.id]?.length ?? 0;

            return (
              <KanbanColumn
                key={stage.id}
                value={stage.id}
                className="w-72 shrink-0 gap-3 rounded-lg bg-muted/50 p-3"
              >
                <div className="flex items-start justify-between gap-2">
                  <div className="flex min-w-0 flex-col gap-1">
                    <span className="truncate text-sm font-medium">
                      {stage.name}
                    </span>
                    {state === null ? null : (
                      <Badge variant={stateBadgeVariant(state)}>
                        {stateLabel(state)}
                      </Badge>
                    )}
                  </div>
                  <Badge
                    variant={
                      stage.wipLimit !== null && count > stage.wipLimit
                        ? "destructive-light"
                        : "outline"
                    }
                  >
                    {stage.wipLimit === null
                      ? count
                      : t("{count, number} of {limit, number}", {
                          count,
                          limit: stage.wipLimit,
                        })}
                  </Badge>
                </div>
                <KanbanColumnContent value={stage.id} className="min-h-16">
                  {(columns[stage.id] ?? []).map((item) => (
                    <KanbanItem
                      key={item.id}
                      value={item.id}
                      disabled={!canMove || move.pending}
                    >
                      <KanbanItemHandle cursor={canMove}>
                        <WorkItemCard item={item} />
                      </KanbanItemHandle>
                    </KanbanItem>
                  ))}
                  {count === 0 ? (
                    <p className="text-xs text-muted-foreground">
                      {t("Drop an item here.")}
                    </p>
                  ) : null}
                </KanbanColumnContent>
              </KanbanColumn>
            );
          })}
        </KanbanBoard>
        <KanbanOverlay>
          {({ value, variant }) => {
            const item =
              variant === "item" ? byId.get(String(value)) : undefined;

            return item ? <WorkItemCard item={item} /> : null;
          }}
        </KanbanOverlay>
      </Kanban>
    </div>
  );
}
```