# Meeting minutes

> One meeting with the minutes of each agenda item, its decisions with the votes so far and a Settle button, the tasks it opened, and Start and Hold buttons for the people who run it.

- Block: [meetings](https://bettersupabase.com/blocks/meetings.md)
- Kind: component
- Data: loaded on the server through the block's client
- Page: https://bettersupabase.com/recipes/meeting-minutes

## Install the block

```bash
npx better-supabase add meetings
```

## Web (Next.js)

Add the shadcn components:

```bash
npx shadcn add alert badge button empty @reui/frame
```

From `apps/examples/nextjs/src/features/meetings/components/meeting-minutes.tsx` (https://github.com/ScaleDockHQ/better-supabase/blob/main/apps/examples/nextjs/src/features/meetings/components/meeting-minutes.tsx):

```tsx
/**
 * One meeting with the minutes of each agenda item, its decisions with the
 * votes so far, and the tasks it opened. The chair starts and holds the meeting
 * and settles a decision from the votes; the database checks the caller's role
 * again when it runs.
 */
export function MeetingMinutes({
  state,
  canRun,
}: {
  state: Loaded<MeetingMinutesView>;
  canRun: boolean;
}) {
  const t = useExtracted("meetings");
  const router = useRouter();
  const [retrying, startRetry] = useTransition();
  const errorMessage = useErrorMessage();

  const start = useAction(startMeeting, {
    onSuccess: () => {
      toast.success(t("Meeting started"));
    },
    onError: (error) => {
      toast.error(errorMessage(error));
    },
  });

  const hold = useAction(holdMeeting, {
    onSuccess: () => {
      toast.success(t("Meeting held"));
    },
    onError: (error) => {
      toast.error(errorMessage(error));
    },
  });

  const settle = useAction(settleDecision, {
    onSuccess: () => {
      toast.success(t("Decision settled"));
    },
    onError: (error) => {
      toast.error(errorMessage(error));
    },
  });

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

  if (state.kind === "error") {
    return (
      <Frame spacing="sm">
        <FramePanel>
          <Alert variant="destructive">
            <TriangleAlertIcon />
            <AlertTitle>{t("The meeting didn't load")}</AlertTitle>
            <AlertDescription>{t("Try again in a moment.")}</AlertDescription>
            <AlertAction>
              <Button
                size="sm"
                variant="outline"
                type="button"
                disabled={retrying}
                onClick={() => {
                  startRetry(() => {
                    router.refresh();
                  });
                }}
              >
                {t("Retry")}
              </Button>
            </AlertAction>
          </Alert>
        </FramePanel>
      </Frame>
    );
  }

  const meeting = state.data;

  return (
    <Frame spacing="sm" data-recipe="meeting-minutes">
      <FrameHeader className="gap-0.5">
        <FrameTitle>{meeting.title}</FrameTitle>
        <FrameDescription>
          {t(
            "{count, plural, =0 {No attendees yet} one {# attendee} other {# attendees}}",
            { count: meeting.attendees.length },
          )}
        </FrameDescription>
      </FrameHeader>
      <FramePanel className="flex flex-col gap-4">
        <div className="flex flex-wrap items-center gap-2">
          <MeetingStatusBadge status={meeting.status} />
        </div>
        {meeting.agenda.length === 0 ? (
          <Empty>
            <EmptyHeader>
              <EmptyMedia variant="icon">
                <ListChecksIcon />
              </EmptyMedia>
              <EmptyTitle>{t("No agenda yet")}</EmptyTitle>
              <EmptyDescription>
                {t("The chair adds the agenda items before the meeting.")}
              </EmptyDescription>
            </EmptyHeader>
          </Empty>
        ) : (
          <ol className="flex flex-col gap-3">
            {meeting.agenda.map((item) => (
              <li key={item.id} className="flex flex-col gap-1 text-sm">
                <span className="flex items-center gap-2 font-medium">
                  <span className="font-mono text-xs text-muted-foreground tabular-nums">
                    {item.ordinal}
                  </span>
                  {item.title}
                </span>
                {item.minutes === null ? null : (
                  <p className="text-muted-foreground">{item.minutes}</p>
                )}
                {item.outcome === null ? null : (
                  <Badge variant="outline">{item.outcome}</Badge>
                )}
              </li>
            ))}
          </ol>
        )}
        {meeting.decisions.length === 0 ? null : (
          <ul className="flex flex-col gap-2">
            {meeting.decisions.map((decision) => (
              <li
                key={decision.id}
                className="flex items-center justify-between gap-2 text-sm"
              >
                <span className="flex items-center gap-2">
                  <DecisionStatusBadge status={decision.status} />
                  {decision.title}
                  <span className="text-xs text-muted-foreground tabular-nums">
                    {t("{up} for, {down} against", {
                      up: String(decision.up),
                      down: String(decision.down),
                    })}
                  </span>
                </span>
                {decision.status === "proposed" && canRun ? (
                  <Button
                    type="button"
                    size="sm"
                    variant="outline"
                    disabled={settle.pending}
                    onClick={() => {
                      void settle.run({ decisionId: decision.id });
                    }}
                  >
                    {t("Settle from votes")}
                  </Button>
                ) : null}
              </li>
            ))}
          </ul>
        )}
        {meeting.actionItems.length === 0 ? null : (
          <ul className="flex flex-col gap-1 text-sm">
            {meeting.actionItems.map((item) => (
              <li key={item.workItemId} className="flex items-center gap-2">
                <Badge variant="secondary">{item.state}</Badge>
                {item.title}
              </li>
            ))}
          </ul>
        )}
      </FramePanel>
      {canRun &&
      (meeting.status === "planned" || meeting.status === "in_progress") ? (
        <FrameFooter>
          {meeting.status === "planned" ? (
            <Button
              type="button"
              size="sm"
              disabled={start.pending}
              onClick={() => {
                void start.run({ meetingId: meeting.id });
              }}
            >
              {t("Start meeting")}
            </Button>
          ) : (
            <Button
              type="button"
              size="sm"
              disabled={hold.pending}
              onClick={() => {
                void hold.run({ meetingId: meeting.id });
              }}
            >
              {t("Hold meeting")}
            </Button>
          )}
        </FrameFooter>
      ) : null}
    </Frame>
  );
}
```

## Prompt

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

### Web

```text
Build "Meeting minutes" in a React web app that uses shadcn/ui components. One meeting with the minutes of each agenda item, its decisions with the votes so far and a Settle button, the tasks it opened, and Start and Hold buttons for the people who run it.

It uses the better-supabase `meetings` block (Meetings). 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 meetings` and apply the migration.
2. Add the shadcn components the recipe uses: `npx shadcn add alert badge button empty @reui/frame`.
3. The recipe needs no glue: it calls the block's client on the server.
4. Write the component. Load the data on the server with the block's client, starting from the recipe's snippet (`apps/examples/nextjs/src/features/meetings/components/meeting-minutes.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/meeting-minutes.md
- Block docs: https://bettersupabase.com/docs/blocks/meetings
```
