# Quote builder

> A quote with line items, discounts and totals in minor units, sent for acceptance.

- Block: [quotes](https://bettersupabase.com/blocks/quotes.md)
- Kind: screen
- Data: loaded on the server through the block's client
- Page: https://bettersupabase.com/recipes/quote-builder

## Install the block

```bash
npx better-supabase add quotes
```

## Web (Next.js)

Add the shadcn components:

```bash
npx shadcn add card skeleton table
```

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

```tsx
/**
 * Render inside `<Suspense>`: a quote's line items and totals. A draft (or
 * a quote with changes requested) is editable; the status decides what
 * comes next: send, record the customer's answer, or revise.
 */
export async function QuoteBuilder({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const [{ id }, session, t, locale] = await Promise.all([
    params,
    getSession(),
    getExtracted("crm"),
    getLocale(),
  ]);

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

  const loaded = await settle(() => loadQuote(id));

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

  if (loaded.kind === "error") {
    return <CrmLoadError title={t("The quote didn't load")} />;
  }

  const quote = loaded.data;

  if (!quote) {
    notFound();
  }

  const canWrite = can(session, "quotes.write");

  const editable =
    canWrite &&
    (quote.status === "draft" || quote.status === "changes_requested");

  const totals = [
    { label: t("Subtotal"), value: quote.subtotal },
    { label: t("Discount"), value: quote.discountTotal },
    { label: t("Tax"), value: quote.taxTotal },
  ];

  return (
    <Card data-recipe="quote-builder">
      <CardHeader>
        <CardTitle>{quote.title}</CardTitle>
        <CardDescription className="flex flex-wrap items-center gap-2">
          <span className="tabular-nums">
            {t("{identifier} rev. {revision, number}", {
              identifier: quote.identifier,
              revision: quote.revision,
            })}
          </span>
          <QuoteStatusBadge status={quote.status} />
          {quote.validUntil ? (
            <span>
              {t("Valid until {date}", {
                date: Temporal.PlainDate.from(quote.validUntil).toLocaleString(
                  locale,
                  { dateStyle: "medium" },
                ),
              })}
            </span>
          ) : null}
        </CardDescription>
        {canWrite ? (
          <CardAction>
            <QuoteStatusActions
              quoteId={quote.id}
              status={quote.status}
              hasLines={quote.lines.length > 0}
            />
          </CardAction>
        ) : null}
      </CardHeader>
      <CardContent>
        {editable ? (
          <QuoteLinesEditor
            key={quote.lines
              .map((line) => `${line.description}:${line.unitAmount.amount}`)
              .join(",")}
            quoteId={quote.id}
            currency={quote.currency}
            initialLines={quote.lines}
          />
        ) : (
          <Table>
            <TableHeader>
              <TableRow>
                <TableHead>{t("Description")}</TableHead>
                <TableHead className="text-right">{t("Qty")}</TableHead>
                <TableHead className="text-right">{t("Unit price")}</TableHead>
                <TableHead className="text-right">{t("Discount %")}</TableHead>
                <TableHead className="text-right">{t("Tax %")}</TableHead>
              </TableRow>
            </TableHeader>
            <TableBody>
              {quote.lines.length === 0 ? (
                <TableRow>
                  <TableCell
                    colSpan={5}
                    className="text-center text-sm text-muted-foreground"
                  >
                    {t("This quote has no lines.")}
                  </TableCell>
                </TableRow>
              ) : null}
              {quote.lines.map((line) => (
                <TableRow key={line.position}>
                  <TableCell>{line.description}</TableCell>
                  <TableCell className="text-right tabular-nums">
                    {line.quantity}
                  </TableCell>
                  <TableCell className="text-right tabular-nums">
                    {formatMoney(line.unitAmount, locale)}
                  </TableCell>
                  <TableCell className="text-right tabular-nums">
                    {line.discountBps / 100}
                  </TableCell>
                  <TableCell className="text-right tabular-nums">
                    {line.taxRateBps / 100}
                  </TableCell>
                </TableRow>
              ))}
            </TableBody>
          </Table>
        )}
      </CardContent>
      <CardFooter className="justify-end">
        <dl className="grid w-full max-w-xs grid-cols-[1fr_auto] gap-x-6 gap-y-1 text-sm">
          {totals.map((total) => (
            <div key={total.label} className="contents">
              <dt className="text-muted-foreground">{total.label}</dt>
              <dd className="text-right tabular-nums">
                {formatMoney(total.value, locale)}
              </dd>
            </div>
          ))}
          <dt className="font-semibold">{t("Total")}</dt>
          <dd className="text-right font-semibold tabular-nums">
            {formatMoney(quote.total, locale)}
          </dd>
        </dl>
      </CardFooter>
    </Card>
  );
}
```

## Prompt

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

### Web

```text
Build "Quote builder" in a React web app that uses shadcn/ui components. A quote with line items, discounts and totals in minor units, sent for acceptance.

It uses the better-supabase `quotes` block (Quotes). It loads its data on the server through the block's client.

Steps:

1. If the app doesn't have the block yet, add the SQL with `npx better-supabase add quotes` and apply the migration.
2. Add the shadcn components the recipe uses: `npx shadcn add card skeleton table`.
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/quote-builder.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/quote-builder.md
- Block docs: https://bettersupabase.com/docs/blocks/quotes
```
