# Work item board

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

- Blocks: [work-items](https://bettersupabase.com/blocks/work-items.md), [state-machine](https://bettersupabase.com/blocks/state-machine.md), [rank](https://bettersupabase.com/blocks/rank.md)
- Kind: screen
- Data: loaded on the server through the block's client
- Page: https://bettersupabase.com/recipes/work-item-board

## Install the blocks

```bash
npx better-supabase add work-items state-machine rank
```

## Web (Next.js)

Add the shadcn components:

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

From `apps/examples/nextjs/src/features/work/components/work-item-board.tsx` (https://github.com/ScaleDockHQ/better-supabase/blob/main/apps/examples/nextjs/src/features/work/components/work-item-board.tsx):

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

## Prompt

Paste this into a coding agent to build the recipe in your app.

### Web

```text
Build "Work item board" in a React web app that uses shadcn/ui components. Work items in state columns, ordered by rank and moved through allowed transitions.

It uses the better-supabase `work-items` block (Work items) with `state-machine`, `rank`. It loads its data on the server through the block's client.

Steps:

1. If the app doesn't have the blocks yet, add the SQL with `npx better-supabase add work-items state-machine rank` and apply the migration.
2. Add the shadcn components the recipe uses: `npx shadcn add badge card @reui/kanban`.
3. The recipe needs no glue: it calls the block's client on the server.
4. Write the screen. Load the data on the server with the block's client, starting from the recipe's snippet (`apps/examples/nextjs/src/features/work/components/work-item-board.tsx`). Adapt it to the app's existing components, styling and data loading instead of copying it as is.
5. Check the result against the recipe and the block's docs: it typechecks, it reads the same block data, and it handles the loading and empty states the snippet handles.

Read the full code first:

- Recipe: https://bettersupabase.com/recipes/work-item-board.md
- Block docs: https://bettersupabase.com/docs/blocks/work-items
```
