# Deal board

> Deals as cards in pipeline stage columns, moved by drag and drop.

- Blocks: [deals](https://bettersupabase.com/blocks/deals.md), [pipelines](https://bettersupabase.com/blocks/pipelines.md)
- Kind: screen
- Data: loaded on the server through the block's client
- Page: https://bettersupabase.com/recipes/deal-board

## Install the blocks

```bash
npx better-supabase add deals pipelines
```

## Web (Next.js)

Add the shadcn components:

```bash
npx shadcn add card empty skeleton
```

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

```tsx
/**
 * Render inside `<Suspense>`: one column per stage of the default deal
 * pipeline. Dragging a deal saves its new stage through `moveDeal`.
 */
export async function DealBoard() {
  const [session, t] = await Promise.all([getSession(), getExtracted("crm")]);

  if (!can(session, "deals.read")) {
    forbidden();
  }

  const board = await settle(loadDealBoard);

  if (board.kind === "no-organization") {
    return null;
  }

  const pipeline = board.kind === "ok" ? board.data.pipeline : null;

  const openStages =
    board.kind === "ok"
      ? board.data.stages
          .filter((stage) => stage.category === "open")
          .map((stage) => ({ value: stage.id, label: stage.name }))
      : [];

  return (
    <Card data-recipe="deal-board">
      <CardHeader>
        <CardTitle>{pipeline?.name ?? t("Deals")}</CardTitle>
        <CardDescription>
          {t("Drag a deal to another stage to move it.")}
        </CardDescription>
        {pipeline && can(session, "deals.write") ? (
          <CardAction>
            <CreateDealDialog pipelineId={pipeline.id} stages={openStages} />
          </CardAction>
        ) : null}
      </CardHeader>
      <CardContent>
        {board.kind === "error" ? (
          <CrmLoadError title={t("The board didn't load")} />
        ) : board.data.pipeline === null ? (
          <Empty className="border">
            <EmptyHeader>
              <EmptyMedia variant="icon">
                <KanbanIcon />
              </EmptyMedia>
              <EmptyTitle>{t("No pipeline yet")}</EmptyTitle>
              <EmptyDescription>
                {can(session, "pipelines.manage")
                  ? t(
                      "Start with six stages, from qualified to closed won or lost.",
                    )
                  : t("Ask an admin to create the sales pipeline.")}
              </EmptyDescription>
            </EmptyHeader>
            {can(session, "pipelines.manage") ? (
              <EmptyContent>
                <CreatePipelineButton />
              </EmptyContent>
            ) : null}
          </Empty>
        ) : (
          <DealKanban
            key={board.data.deals
              .map((deal) => `${deal.id}:${deal.stageId}`)
              .join(",")}
            stages={board.data.stages}
            deals={board.data.deals}
            canMove={can(session, "deals.write")}
          />
        )}
      </CardContent>
    </Card>
  );
}
```

## Prompt

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

### Web

```text
Build "Deal board" in a React web app that uses shadcn/ui components. Deals as cards in pipeline stage columns, moved by drag and drop.

It uses the better-supabase `deals` block (Deals) with `pipelines`. 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 deals pipelines` and apply the migration.
2. Add the shadcn components the recipe uses: `npx shadcn add card empty skeleton`.
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/crm/components/deal-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/deal-board.md
- Block docs: https://bettersupabase.com/docs/blocks/deals
```
