Blocks

Seat Management

A member roster crossed with the seat ledger — who holds a billable, free, invited or deactivated seat, the cost of adding or removing one previewed before it is committed, an overage warning with both of its exits, and a bulk deactivation that says what it frees before it says what it saves.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  CircleDashed,
  Minus,
  OctagonAlert,
  Plus,
  RotateCw,
  TriangleAlert,
  UserRoundCheck,
  Users,
} from "lucide-react"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/seat-management.json

Prompt

Build a React + TypeScript + Tailwind "SeatManagement" block (lucide-react
icons, zod, shadcn Button / Badge / Input / AlertDialog).

Contract
- One zod schema is the single source of truth:
  { status: "loading" | "empty" | "error" | "ready";
    asOf: ISO instant with offset — the block's ONLY clock;
    members: { id, name, email, role, lastActiveAt: ISO | null, seat }[];
    seat: "billable" | "free_viewer" | "invited" | "deactivated";
    plan: { seats, pricePerSeat, currency, interval: "month" | "year" } | null;
    thresholds: { inactiveDays };
    deactivationNote?, errorMessage? }.
- Money is an integer in the currency's MINOR unit; the number of minor-unit
  digits comes from Intl for the currency code, never a hard-coded /100 (JPY
  has none, KWD has three). Never floats.
- Props = z.infer of the schema plus heading, locale, timeZone, protectedRole
  (default "Owner"), skeletonRows, and the handlers onChangeSeats(next),
  onDeactivate(members), onReactivate(member), onRetry. Every handler is
  optional and its absence removes the control it drives — never a dead button.
- superRefine: ready requires a plan, and member ids must be unique (they key
  the rows and the selection set).

Behavior
- Seat rule, from which every figure derives: billable and invited members hold
  a seat (an invitation reserves one so nobody oversells the plan); free viewers
  and deactivated members hold none. assigned = billable + invited.
- Four first-class branches. loading: ledger + roster skeleton, aria-busy.
  empty: the plan STILL renders, because seats paid for and unassigned are the
  point of that branch. error: says nobody was deactivated and no count changed.
  ready with zero members falls back to empty.
- Composition bar + legend over the roster, integer percentages apportioned by
  LARGEST REMAINDER so the visible shares total exactly 100 and the bar fills
  the track. Each legend chip is an aria-pressed filter over the table; a filter
  whose state no longer exists deactivates itself instead of emptying the table.
- Seat stepper: −, a numeric field, +, and a Review button. The line underneath
  previews the change live — "Adding 2 seats: $80.00 → $96.00 per month, $16.00
  per month ($192.00 per year) more at list price" — and turns into the BLOCKER
  only when the draft LOWERS the count below the assignment count ("12 seats are
  assigned, so the count cannot go below 12. Deactivate 2 members first"). An
  already over-allocated plan therefore opens on its price line, not on a red
  blocker for a change nobody made; the overage banner is what reports that gap.
  The button is aria-disabled, never natively disabled, so a keyboard user can
  still hear why.
- Overage: when assigned > purchased, a banner states the gap and BOTH exits —
  buy the difference (priced) or free that many seats — and the buy button
  targets exactly `assigned`.
- Idle sweep: whole days between asOf and lastActiveAt, floored; a null
  lastActiveAt is "never signed in" and counts as idle; an unparseable instant
  is "Last active unknown" and is never swept; a future instant clamps to today.
  One button selects everyone past thresholds.inactiveDays.
- Rows that cannot be deactivated carry NO checkbox and a reason instead —
  an invitation is revoked rather than deactivated, a deactivated row has already
  lost access, and the last active holder of protectedRole must keep access. One
  footnote under the table explains the rule once, built from the blockers the
  roster actually contains so it never recites a rule nothing on screen shows.
- Bulk bar states the two-step truth before the confirm: how many PAID seats are
  freed (a free viewer frees none), whether that clears the overage, and that
  freeing a seat saves nothing until the purchased count is lowered — with the
  figure that second step is worth.
- Confirmation (AlertDialog) for deactivate, seat change and reactivation: names
  the people (capped, then "and N more"), states the consequence, prints the
  host's deactivationNote rather than inventing a data-retention promise, and
  ends every money sentence with "at list price" — proration, tax and effective
  dates belong to the billing provider. A ref guard read and written inside the
  handler swallows the second half of a double click.
- Reactivation is priced against the ledger it lands in, never assumed: an idle
  seat covers it and the invoice does not move; an exactly full plan goes one
  seat over and costs one seat; a plan ALREADY over says how many it is over,
  that this makes it one more, and prices all of them together.
- Focus: the dialog has no trigger, so onCloseAutoFocus is taken over — back to
  the opener if it survived, otherwise to the ledger card (tabIndex={-1}).
  Actions are announced in one polite live region.

Rendering & styling
- Semantic tokens only: bg-card panels, text-muted-foreground supporting copy,
  destructive for the overage banner and the deactivate button, var(--chart-1..3)
  plus var(--muted-foreground) for the composition segments and legend dots.
- A real <table> with a sr-only <caption> and scope="col" headers; the Role and
  Last active columns collapse below sm/md and fold back into the member cell,
  so only one copy is ever displayed. Numbers are tabular-nums, long addresses
  wrap-anywhere, nothing truncates.
- cn() merges className, remaining props spread on the root, focus-visible rings
  on every control, transitions disabled under motion-reduce.

Customization levers
- Seat vocabulary: the four seat states are a zod enum plus four lookup maps
  (label, meaning, bar tone, badge variant). Add "contractor" or "pending
  removal" by extending the enum and the maps; only the `assigned` formula
  decides whether the new state costs money.
- Which seats count: swap `assigned = billable + invited` for billable-only if
  your invitations do not reserve a seat — every downstream figure follows.
- Blocking policy: protectedRole is one prop; extend blockerOf() with your own
  rules (SCIM-managed users, the billing contact) and add a short label.
- Density: drop the composition bar for a compact card, or the Role/Last-active
  columns for a narrow sidebar; the ledger and the table are separate cards.
- Money presentation: rate() adds the annualised figure only for monthly plans —
  drop it for a single-line price, or extend it with a per-user-per-day figure.
- Sweep aggressiveness: thresholds.inactiveDays is data, not a constant, and
  whether "never signed in" counts as idle is one branch in describeActivity().

Concepts

  • A row is not a seat — billable and invited members consume the plan, free viewers and deactivated rows do not. Every count, percentage and price on the card derives from that one rule, so the ledger cannot disagree with the table under it.
  • Cost delta before commit — the stepper rewrites its own sentence as you step: old total, new total, the difference per interval and per year, all at list price. The confirmation repeats it, so nobody buys a seat to find out what it cost.
  • Freeing a seat is not a saving — the purchased count is what is billed, so the sweep states two steps: deactivating frees seats (and may clear an overage), and only lowering the count afterwards is worth money. Products that skip the second sentence teach admins to distrust the first.
  • Blocked, not disabled — a row that cannot be deactivated has no checkbox and a reason beside it, and the footnote under the table names only the rules that roster actually demonstrates; a count lowered below the assignments explains itself where the price preview would be, while a plan that was already over-allocated is left to the overage banner rather than blocking a change nobody asked for. A greyed-out control that answers no questions is the failure this replaces.
  • Largest remainder — the composition shares are floored and the leftover points handed to the biggest remainders, so the legend totals exactly 100 and the bar fills its track; rounding each share alone reads 63 + 13 + 13 + 13 = 102.
  • asOf is the only clock — "74 days ago", the idle set and the sweep selection all derive from the payload's instant, never from Date.now(), so the server and the browser select the same people and the screenshot is reproducible.

On This Page