Quote builder
A quote with line items, discounts and totals in minor units, sent for acceptance. It loads its data on the server through the block's client.
pnpm dev:example) to see it.Add the components
terminal npx shadcn add card skeleton tableInstall the glue
Install the block with
npx better-supabase add quotes. The glue items are copied into your app, so edit them as you need.None: the page calls the block's client on the server.
Write the screen
React and shadcn
View in the examplequote-builder.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> ); }