Tables
Rows of records with sorting, status and actions, and a footer that pages through them.
Compact table with facets and bulk actions
tables-01Every lead in dense rows: a search box, status and source facets with counts, sortable columns, paging buttons, and a selection whose addresses copy or save as a CSV.
import * as React from "react"
import { formatCurrency, formatNumber } from "@/lib/format"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { LeadsTable } from "./tables-01.client"
import { exportDate, leadRows } from "./tables-01.data"
/**
* Every lead in one compact table on the page ground: the strip names the
* book and what is still in play, and the sheet holds a search box, two
* facets, sortable columns, paging buttons and a selection that can be copied
* or saved as a CSV.
*
* A server component: the rows come from `./tables-01.data`, and the island
* takes them as props with the heading's id, which names the table.
*/
export function Tables01() {
const titleId = React.useId()
const rows = leadRows()
const open = rows.filter((row) => row.status !== "won" && row.status !== "lost")
const inPlay = open.reduce((total, row) => total + row.valueCents, 0)
return (
<section
data-section="tables-01"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>Leads</h2>
</CardTitle>
<CardDescription className="tabular-nums">
{formatNumber(open.length)} open of {formatNumber(rows.length)} ·{" "}
{formatCurrency(inPlay / 100, "USD", { maximumFractionDigits: 0 })} still in play
</CardDescription>
</CardHeader>
<CardContent className="p-0">
<LeadsTable rows={rows} titleId={titleId} exportDate={exportDate()} />
</CardContent>
</Card>
</div>
</section>
)
}
export default Tables01"use client"
import * as React from "react"
import { useTable } from "@tanstack/react-table"
import { ClipboardCopyIcon, DownloadIcon, SearchIcon, XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"
import { downloadText, rowsToCsv } from "@/lib/export"
import { formatCurrency, formatNumber } from "@/lib/format"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
DataTableColumnHeader,
DataTableFacetedFilter,
DataTablePagination,
dataTableDefaultColumn,
dataTableFeatures,
type DataTableColumnDef,
} from "@/components/ui/data-table"
import { ariaSortOf } from "@/components/ui/data-table/columns"
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group"
import { StatusBadge, type StatusVariant } from "@/components/ui/status-badge"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { UserCell } from "@/components/ui/user-cell"
import type { LeadRow, LeadSource, LeadStatus } from "./tables-01.data"
export const STATUS_LABELS: Record<LeadStatus, string> = {
new: "New",
contacted: "Contacted",
qualified: "Qualified",
won: "Won",
lost: "Lost",
}
/** Where a lead came from, in the words a sales team uses; no network is named. */
export const SOURCE_LABELS: Record<LeadSource, string> = {
website: "Website",
referral: "Referral",
ads: "Paid ads",
social: "Social",
event: "Event",
}
const TONES: Record<LeadStatus, StatusVariant> = {
new: "info",
contacted: "neutral",
qualified: "primary",
won: "success",
lost: "danger",
}
const DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" })
const money = (cents: number) => formatCurrency(cents / 100, "USD", { maximumFractionDigits: 0 })
const plural = (count: number, one: string, many = `${one}s`) => `${formatNumber(count)} ${count === 1 ? one : many}`
/**
* The pinned column below md: it keeps the lead in view while the figures
* scroll past, on an opaque plane in every state — at rest the card, under
* the pointer the same half-muted wash the row takes, selected the accent
* tint — so nothing scrolling underneath ever shows through it.
*/
const PINNED =
"max-md:sticky max-md:start-0 max-md:z-10 max-md:after:absolute max-md:after:inset-y-0 max-md:after:end-0 max-md:after:w-px max-md:after:bg-border"
const PINNED_CELL = cn(
PINNED,
"bg-card group-hover/row:bg-[color-mix(in_oklab,var(--muted)_50%,var(--card))] group-data-[state=selected]/row:bg-brand-muted"
)
const columns: DataTableColumnDef<LeadRow>[] = [
{
id: "lead",
accessorFn: (row) => row.name,
enableHiding: false,
header: ({ column, table }) => (
<div className="flex items-center gap-3">
<Checkbox
checked={table.getIsAllPageRowsSelected()}
indeterminate={table.getIsSomePageRowsSelected() && !table.getIsAllPageRowsSelected()}
onCheckedChange={(checked) => table.toggleAllPageRowsSelected(checked)}
aria-label="Select every lead on this page"
/>
<DataTableColumnHeader column={column} title="Lead" />
</div>
),
cell: ({ row }) => (
<div className="flex items-center gap-3">
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(checked) => row.toggleSelected(checked)}
aria-label={`Select ${row.original.name}, ${row.original.company}`}
/>
<UserCell size="sm" name={row.original.name} description={row.original.company} src={row.original.avatarUrl} className="max-w-36 sm:max-w-52" />
</div>
),
},
{
accessorKey: "status",
filterFn: "arrHas",
enableSorting: false,
enableHiding: false,
header: () => "Status",
cell: ({ row }) => (
<StatusBadge size="sm" status={row.original.status} variant={TONES[row.original.status]} label={STATUS_LABELS[row.original.status]} />
),
},
{
accessorKey: "source",
filterFn: "arrHas",
enableSorting: false,
enableHiding: false,
header: () => "Source",
cell: ({ row }) => <span className="text-muted-foreground">{SOURCE_LABELS[row.original.source]}</span>,
},
{
accessorKey: "owner",
enableHiding: false,
header: ({ column }) => <DataTableColumnHeader column={column} title="Owner" />,
},
{
accessorKey: "valueCents",
enableHiding: false,
header: ({ column }) => <DataTableColumnHeader column={column} title="Value" />,
cell: ({ row }) => money(row.original.valueCents),
meta: { align: "right" },
},
{
accessorKey: "createdAt",
enableHiding: false,
header: ({ column }) => <DataTableColumnHeader column={column} title="Created" />,
cell: ({ row }) => <time dateTime={row.original.createdAt.toISOString()}>{DAY.format(row.original.createdAt)}</time>,
meta: { align: "right" },
},
{
id: "next",
accessorFn: (row) => row.nextActionAt?.getTime() ?? 0,
enableSorting: false,
enableHiding: false,
header: () => "Next step",
cell: ({ row }) => {
const next = row.original.nextActionAt
if (!next) return <span className="text-muted-foreground">—</span>
return (
<time dateTime={next.toISOString()} className={cn(row.original.overdue && "font-medium text-danger")}>
{row.original.overdue ? `Overdue, ${DAY.format(next)}` : DAY.format(next)}
</time>
)
},
meta: { align: "right" },
},
]
const STATUS_OPTIONS = (Object.keys(STATUS_LABELS) as LeadStatus[]).map((value) => ({ value, label: STATUS_LABELS[value] }))
const SOURCE_OPTIONS = (Object.keys(SOURCE_LABELS) as LeadSource[]).map((value) => ({ value, label: SOURCE_LABELS[value] }))
const matches = (row: LeadRow, query: string) =>
[row.name, row.company, row.email].some((field) => field.toLowerCase().includes(query))
/**
* The leads table: a search box and two facets over compact rows, sorted by
* the column headers, paged by buttons, and a selection that survives both —
* copy the selected leads' addresses or save them as a CSV. One status line
* says how many rows the filters leave, and then what the last bulk action
* did; it never moves focus.
*/
export function LeadsTable({ rows, titleId, exportDate }: { rows: LeadRow[]; titleId: string; exportDate: string }) {
const [query, setQuery] = React.useState("")
// A notice answers the action that raised it, for as long as the view it
// was raised on lasts: the next change of selection or filters hands the
// line back to the count.
const [notice, setNotice] = React.useState<{ text: string; view: string } | null>(null)
const { copy } = useCopyToClipboard()
const needle = query.trim().toLowerCase()
const data = React.useMemo(() => (needle ? rows.filter((row) => matches(row, needle)) : rows), [rows, needle])
const table = useTable({
features: dataTableFeatures,
columns,
data,
getRowId: (row) => row.id,
enableRowSelection: true,
defaultColumn: dataTableDefaultColumn,
initialState: { sorting: [{ id: "createdAt", desc: true }], pagination: { pageIndex: 0, pageSize: 10 } },
})
const selectedIds = Object.keys(table.state.rowSelection).filter((id) => table.state.rowSelection[id])
const selected = rows.filter((row) => selectedIds.includes(row.id))
const visible = table.getFilteredRowModel().rows.length
const filtered = table.state.columnFilters.length > 0 || needle !== ""
const view = `${selectedIds.join(",")}|${visible}|${needle}`
const say = (text: string) => setNotice({ text, view })
const summary =
visible === rows.length ? `${plural(rows.length, "lead")}` : `${formatNumber(visible)} of ${plural(rows.length, "lead")}`
async function copyEmails() {
const ok = await copy(selected.map((row) => row.email).join(", "))
say(
ok
? `Copied ${plural(selected.length, "email address", "email addresses")}.`
: "The browser blocked the clipboard, so nothing was copied."
)
}
function exportCsv() {
const filename = `leads-${exportDate}.csv`
const csv = rowsToCsv(
selected.map((row) => ({
name: row.name,
company: row.company,
email: row.email,
status: STATUS_LABELS[row.status],
source: SOURCE_LABELS[row.source],
owner: row.owner,
value: (row.valueCents / 100).toFixed(2),
created: row.createdAt.toISOString().slice(0, 10),
})),
[
{ key: "name", label: "Name" },
{ key: "company", label: "Company" },
{ key: "email", label: "Email" },
{ key: "status", label: "Status" },
{ key: "source", label: "Source" },
{ key: "owner", label: "Owner" },
{ key: "value", label: "Value (USD)" },
{ key: "created", label: "Created" },
]
)
const saved = downloadText(filename, csv)
say(saved ? `Saved ${plural(selected.length, "lead")} to ${filename}.` : "This browser cannot save files from the page.")
}
function clearFilters() {
setQuery("")
table.resetColumnFilters()
}
return (
<div data-slot="leads-table">
<div className="flex flex-wrap items-center gap-2 border-b border-rule p-3">
<InputGroup className="w-full sm:w-60">
<InputGroupAddon>
<SearchIcon aria-hidden="true" />
</InputGroupAddon>
<InputGroupInput
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Name, company or email"
aria-label="Search leads"
/>
</InputGroup>
<DataTableFacetedFilter column={table.getColumn("status")} title="Status" options={STATUS_OPTIONS} />
<DataTableFacetedFilter column={table.getColumn("source")} title="Source" options={SOURCE_OPTIONS} />
{filtered ? (
<Button variant="ghost" size="sm" onClick={clearFilters}>
Clear
<XIcon data-icon="inline-end" aria-hidden="true" />
</Button>
) : null}
<div className="flex min-h-7 w-full items-center gap-2 sm:ml-auto sm:w-auto">
{selected.length > 0 ? (
<div role="group" aria-label="Selected leads" className="flex flex-wrap items-center gap-1.5">
<span className="text-label font-medium tabular-nums">{formatNumber(selected.length)} selected</span>
<Button variant="outline" size="sm" onClick={copyEmails}>
<ClipboardCopyIcon data-icon="inline-start" aria-hidden="true" />
Copy emails
</Button>
<Button variant="outline" size="sm" onClick={exportCsv}>
<DownloadIcon data-icon="inline-start" aria-hidden="true" />
Export CSV
</Button>
<Button variant="ghost" size="icon-sm" aria-label="Clear the selection" onClick={() => table.resetRowSelection()}>
<XIcon aria-hidden="true" />
</Button>
</div>
) : null}
<p role="status" aria-live="polite" className="text-xs text-muted-foreground tabular-nums">
{notice?.view === view ? notice.text : summary}
</p>
</div>
</div>
<Table aria-labelledby={titleId} className="text-label">
<TableHeader>
{table.getHeaderGroups().map((group) => (
<TableRow key={group.id} className="hover:bg-transparent">
{group.headers.map((header, index) => {
const align = header.column.columnDef.meta?.align
return (
<TableHead
key={header.id}
aria-sort={ariaSortOf(header.column)}
className={cn(
"h-9 bg-surface px-3 text-foreground",
align === "right" && "text-right",
index === 0 && cn(PINNED, "bg-surface")
)}
>
<table.FlexRender header={header} />
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length === 0 ? (
<TableRow className="hover:bg-transparent">
<TableCell colSpan={columns.length} className="px-3 py-8 text-center">
<p className="text-sm text-muted-foreground">No leads match these filters.</p>
<Button variant="link" size="sm" onClick={clearFilters}>
Clear the filters
</Button>
</TableCell>
</TableRow>
) : (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} data-state={row.getIsSelected() ? "selected" : undefined} className="group/row h-11">
{row.getVisibleCells().map((cell, index) => {
const align = cell.column.columnDef.meta?.align
return (
<TableCell
key={cell.id}
className={cn("px-3 py-1.5", align === "right" && "text-right tabular-nums", index === 0 && PINNED_CELL)}
>
<table.FlexRender cell={cell} />
</TableCell>
)
})}
</TableRow>
))
)}
</TableBody>
</Table>
<div className="border-t border-rule px-3 py-2.5">
<DataTablePagination table={table} showSelectedCount={false} pageSizeOptions={[10, 20, 50]} />
</div>
</div>
)
}/**
* What `tables-01` reads: every lead in `db.leads`, each with the rep who is
* working it, as the compact table lists them. "Now" is `REFERENCE_DATE`, so a
* follow-up that fell before it reads as overdue on the server and in the
* browser alike.
*
* Selectors only, reading `db` inside functions and run on the server. The
* island takes these rows as props; it never imports this module.
*/
import { REFERENCE_DATE, db, type Lead } from "@/lib/sample-data"
export type LeadStatus = Lead["status"]
export type LeadSource = Lead["source"]
/** One lead as a row of the table. */
export type LeadRow = {
id: string
name: string
email: string
company: string
avatarUrl?: string
status: LeadStatus
source: LeadSource
/** What the deal would be worth, in cents. */
valueCents: number
/** The rep working it, by name. */
owner: string
createdAt: Date
/** The next follow-up, when one is booked. */
nextActionAt?: Date
/** True when the follow-up fell before `REFERENCE_DATE` and has not been done. */
overdue: boolean
}
/** Every lead, newest first, with its rep's name and whether its follow-up is late. */
export function leadRows(): LeadRow[] {
const members = new Map(db.members.all().map((member) => [member.id, member.name]))
const now = REFERENCE_DATE.getTime()
return db.leads
.all()
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
.map((lead) => ({
id: lead.id,
name: lead.name,
email: lead.email,
company: lead.company,
avatarUrl: lead.avatarUrl,
status: lead.status,
source: lead.source,
valueCents: lead.valueCents,
owner: members.get(lead.ownerId) ?? "Unassigned",
createdAt: lead.createdAt,
nextActionAt: lead.nextActionAt ?? undefined,
overdue: lead.nextActionAt ? lead.nextActionAt.getTime() < now : false,
}))
}
/** The day the export is dated: `REFERENCE_DATE`, never the clock. */
export function exportDate(): string {
return REFERENCE_DATE.toISOString().slice(0, 10)
}npx shadcn@latest add @vibra/tables-01Orders that open onto their lines
tables-02The newest orders, eight to a page; each row's chevron opens it in place onto its lines with their pictures, its totals, how it was paid and where it went.
import * as React from "react"
import { formatCurrency } from "@/lib/format"
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { ProductArt } from "@/components/ui/product-art"
import { ExpandAll, OrderTable, OrdersView } from "./tables-02.client"
import { recentOrders } from "./tables-02.data"
const DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" })
/**
* The newest orders as rows that open in place: each chevron shows the
* order's lines with their pictures, its totals, how it was paid and where it
* went, without leaving the table. The strip says what the page holds and
* opens or closes every order on the page at once.
*
* A server component: it draws the card and its heading, the orders come
* from `./tables-02.data`, and every product picture is drawn here and handed
* to the island by product id. The island is what opens: the strip's Expand
* all and the table on the card's sheet.
*/
export function Tables02() {
const titleId = React.useId()
const orders = recentOrders()
const art: Record<string, React.ReactNode> = {}
for (const line of orders.flatMap((order) => order.lines)) {
art[line.productId] ??= <ProductArt name={line.name} category={line.category} className="rounded-md" />
}
const total = orders.reduce((sum, order) => sum + order.totalCents, 0)
const oldest = orders.at(-1)?.placedAt
return (
<section
data-section="tables-02"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<OrdersView orders={orders}>
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>Recent orders</h2>
</CardTitle>
<CardDescription className="tabular-nums">
{orders.length} orders{oldest ? ` since ${DAY.format(oldest)}` : ""} ·{" "}
{formatCurrency(total / 100, "USD", { maximumFractionDigits: 0 })}
</CardDescription>
<CardAction>
<ExpandAll />
</CardAction>
</CardHeader>
{/* A container, so an open order's detail can hold at the sheet's width. */}
<CardContent className="@container p-0">
<OrderTable art={art} titleId={titleId} />
</CardContent>
</Card>
</OrdersView>
</div>
</section>
)
}
export default Tables02"use client"
import * as React from "react"
import { ChevronRightIcon, ChevronsDownUpIcon, ChevronsUpDownIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { formatCurrency, formatNumber } from "@/lib/format"
import { Button } from "@/components/ui/button"
import { StatusBadge } from "@/components/ui/status-badge"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { UserCell } from "@/components/ui/user-cell"
import type { RecentOrder } from "./tables-02.data"
export const PAGE_SIZE = 8
const STATUS_TONES = { fulfilled: "success", refunded: "warning", paid: "info" } as const
const PAYMENT = { card: "Card", wallet: "Wallet", bank: "Bank transfer", cash: "Cash", voucher: "Gift voucher" } as const
const money = (cents: number) => formatCurrency(cents / 100)
const DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" })
const TIME = new Intl.DateTimeFormat("en-US", { hour: "2-digit", minute: "2-digit", hourCycle: "h23", timeZone: "UTC" })
const when = (date: Date) => `${DAY.format(date)}, ${TIME.format(date)}`
const lineCount = (count: number) => `${count} ${count === 1 ? "line" : "lines"}`
const HEAD = "h-10 bg-surface px-3 text-foreground"
// On a narrow phone the table scrolls sideways: the order stays put on an
// opaque plane that follows the row's own — the card, and the half-muted wash
// the kit's row takes on hover and while its order is open.
const PINNED =
"max-md:sticky max-md:left-0 max-md:z-10 max-md:after:absolute max-md:after:inset-y-0 max-md:after:right-0 max-md:after:w-px max-md:after:bg-border"
const PLANE =
"bg-card group-hover/row:bg-[color-mix(in_oklab,var(--muted)_50%,var(--card))] group-has-aria-expanded/row:bg-[color-mix(in_oklab,var(--muted)_50%,var(--card))]"
/**
* What an order holds: its lines with their pictures, then the totals and
* where it went. Where the table scrolls sideways — a phone narrower than
* 390px — it holds still at the sheet's width, as the pinned order does, so
* scrolling to the totals never slides the lines out of view.
*/
function OrderDetail({ order, art }: { order: RecentOrder; art: Record<string, React.ReactNode> }) {
return (
<div className="grid gap-3 px-3 py-3 max-md:sticky max-md:left-0 max-md:w-[100cqw] md:grid-cols-[minmax(0,1fr)_15rem] md:gap-6 md:ps-14">
<ul aria-label={`Lines of ${order.number}`} className="divide-y divide-border overflow-hidden rounded-lg bg-card ring-1 ring-border">
{order.lines.map((line) => (
<li key={line.productId} className="flex items-center gap-3 px-3 py-2">
<span className="w-9 shrink-0">{art[line.productId]}</span>
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-medium">{line.name}</span>
<span className="truncate text-xs text-muted-foreground tabular-nums">
<span className="font-mono">{line.sku}</span>
{/* On a phone the quantity joins the SKU, leaving the name its room. */}
<span className="sm:hidden">
{" "}
· {formatNumber(line.qty)} × {money(line.unitCents)}
</span>
</span>
</span>
<span className="text-xs whitespace-nowrap text-muted-foreground tabular-nums max-sm:hidden">
{formatNumber(line.qty)} × {money(line.unitCents)}
</span>
<span className="w-20 text-right text-sm font-medium tabular-nums">{money(line.totalCents)}</span>
</li>
))}
</ul>
<dl className="grid grid-cols-[minmax(0,1fr)_auto] content-start gap-x-4 gap-y-1 text-sm tabular-nums">
<dt className="text-muted-foreground">Subtotal</dt>
<dd className="text-right">{money(order.subtotalCents)}</dd>
{order.discountCents > 0 ? (
<>
<dt className="text-muted-foreground">Discount</dt>
<dd className="text-right">−{money(order.discountCents)}</dd>
</>
) : null}
{order.taxCents > 0 ? (
<>
<dt className="text-muted-foreground">Tax</dt>
<dd className="text-right">{money(order.taxCents)}</dd>
</>
) : null}
<div className="col-span-2 grid grid-cols-subgrid border-t border-rule pt-1 font-medium">
<dt>Total</dt>
<dd className="text-right">{money(order.totalCents)}</dd>
</div>
<dt className="mt-2 text-muted-foreground">Paid by</dt>
<dd className="mt-2 text-right">{PAYMENT[order.paymentMethod]}</dd>
<dt className="text-muted-foreground">Ships to</dt>
<dd className="text-right">{order.country}</dd>
{order.fulfilledAt ? (
<>
<dt className="text-muted-foreground">Fulfilled</dt>
<dd className="text-right">
<time dateTime={order.fulfilledAt.toISOString()}>{when(order.fulfilledAt)} UTC</time>
</dd>
</>
) : null}
</dl>
</div>
)
}
type Orders = {
orders: RecentOrder[]
shown: RecentOrder[]
open: ReadonlySet<string>
page: number
pages: number
allOpen: boolean
setPage: React.Dispatch<React.SetStateAction<number>>
toggle: (id: string) => void
toggleAll: () => void
}
const OrdersContext = React.createContext<Orders | null>(null)
function useOrders(): Orders {
const orders = React.useContext(OrdersContext)
if (!orders) throw new Error("ExpandAll and OrderTable belong inside OrdersView")
return orders
}
/**
* Which page is in view and which orders are open, shared by the Expand all
* button in the card's strip and the table on its sheet — the card itself,
* its heading and its description are the server's, passed through as
* children. The newest order of more than one line opens with the table, so
* the reader sees what a row holds before they reach for a chevron; what is
* open stays open across pages.
*/
export function OrdersView({ orders, children }: { orders: RecentOrder[]; children: React.ReactNode }) {
const [page, setPage] = React.useState(0)
const [open, setOpen] = React.useState<ReadonlySet<string>>(() => {
const first = orders.slice(0, PAGE_SIZE).find((order) => order.lines.length > 1)
return new Set(first ? [first.id] : [])
})
const pages = Math.max(1, Math.ceil(orders.length / PAGE_SIZE))
const shown = orders.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE)
const allOpen = shown.length > 0 && shown.every((order) => open.has(order.id))
function toggle(id: string) {
setOpen((current) => {
const next = new Set(current)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
function toggleAll() {
setOpen((current) => {
const next = new Set(current)
for (const order of shown) {
if (allOpen) next.delete(order.id)
else next.add(order.id)
}
return next
})
}
const value: Orders = { orders, shown, open, page, pages, allOpen, setPage, toggle, toggleAll }
return <OrdersContext.Provider value={value}>{children}</OrdersContext.Provider>
}
/** Opens every order on the page in view, or closes them all again. */
export function ExpandAll() {
const { allOpen, toggleAll } = useOrders()
return (
<Button variant="outline" size="sm" onClick={toggleAll}>
{allOpen ? (
<ChevronsDownUpIcon data-icon="inline-start" aria-hidden="true" />
) : (
<ChevronsUpDownIcon data-icon="inline-start" aria-hidden="true" />
)}
{allOpen ? "Collapse all" : "Expand all"}
</Button>
)
}
/**
* The recent orders, eight to a page, on the card's sheet. Each row opens in
* place onto its lines, its totals and where it went: the chevron is a
* disclosure button naming the order it opens, and the detail row it
* controls stays in the table, hidden, so the reference always resolves.
* Paging is two buttons and a polite line that says which orders are showing.
*/
export function OrderTable({ art, titleId }: { art: Record<string, React.ReactNode>; titleId: string }) {
const { orders, shown, open, page, pages, setPage, toggle } = useOrders()
const baseId = React.useId()
const first = page * PAGE_SIZE + 1
const last = Math.min((page + 1) * PAGE_SIZE, orders.length)
return (
<>
<Table aria-labelledby={titleId}>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className={cn(HEAD, PINNED)}>Order</TableHead>
<TableHead className={cn(HEAD, "hidden md:table-cell")}>Customer</TableHead>
<TableHead className={cn(HEAD, "hidden md:table-cell")}>Placed</TableHead>
<TableHead className={HEAD}>Status</TableHead>
<TableHead className={cn(HEAD, "hidden text-right lg:table-cell")}>Items</TableHead>
<TableHead className={cn(HEAD, "text-right")}>Total</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{shown.map((order) => {
const expanded = open.has(order.id)
const detailId = `${baseId}-${order.id}`
return (
<React.Fragment key={order.id}>
<TableRow className="group/row">
<TableCell className={cn(PINNED, PLANE, "px-3 py-2")}>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon-sm"
aria-expanded={expanded}
aria-controls={detailId}
aria-label={`Lines of ${order.number}`}
onClick={() => toggle(order.id)}
>
<ChevronRightIcon
aria-hidden="true"
className="transition-transform duration-(--duration-fast) ease-(--ease-standard) group-aria-expanded/button:rotate-90"
/>
</Button>
<span className="flex min-w-0 flex-col">
<span className="font-mono text-xs font-medium">{order.number}</span>
<span className="truncate text-xs text-muted-foreground md:hidden">
{order.customer.name} · {DAY.format(order.placedAt)}
</span>
</span>
</div>
</TableCell>
<TableCell className="hidden px-3 py-2 md:table-cell">
<UserCell size="sm" name={order.customer.name} description={order.customer.company} src={order.customer.avatarUrl} />
</TableCell>
<TableCell className="hidden px-3 py-2 text-muted-foreground tabular-nums md:table-cell">
<time dateTime={order.placedAt.toISOString()}>{when(order.placedAt)}</time>
</TableCell>
<TableCell className="px-3 py-2">
<StatusBadge size="sm" status={order.status} map={STATUS_TONES} />
</TableCell>
<TableCell className="hidden px-3 py-2 text-right text-muted-foreground tabular-nums lg:table-cell">
{lineCount(order.lines.length)} · {formatNumber(order.units)} {order.units === 1 ? "unit" : "units"}
</TableCell>
<TableCell className="px-3 py-2 text-right font-medium tabular-nums">{money(order.totalCents)}</TableCell>
</TableRow>
<TableRow id={detailId} hidden={!expanded} data-slot="order-detail" className="bg-surface hover:bg-surface">
<TableCell colSpan={6} className="p-0 whitespace-normal">
{expanded ? <OrderDetail order={order} art={art} /> : null}
</TableCell>
</TableRow>
</React.Fragment>
)
})}
</TableBody>
</Table>
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-rule px-3 py-2.5">
<p role="status" aria-live="polite" className="text-sm text-muted-foreground tabular-nums">
Orders {first}–{last} of {orders.length}
</p>
<div className="flex items-center gap-1.5">
<Button variant="outline" size="sm" disabled={page === 0} focusableWhenDisabled onClick={() => setPage((current) => Math.max(0, current - 1))}>
Previous
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= pages - 1}
focusableWhenDisabled
onClick={() => setPage((current) => Math.min(pages - 1, current + 1))}
>
Next
</Button>
</div>
</div>
</>
)
}/**
* What `tables-02` reads: the most recent orders in `db.orders`, each with
* the customer who placed it and its lines resolved against `db.products`, so
* a row opens onto what was bought without a second read.
*
* Selectors only, reading `db` inside functions and run on the server. The
* island takes these rows as props; it never imports this module.
*/
import { db, type Order } from "@/lib/sample-data"
/** How many orders the table holds. */
export const RECENT_COUNT = 24
export type OrderLine = {
productId: string
name: string
category: string
sku: string
qty: number
unitCents: number
totalCents: number
}
export type RecentOrder = {
id: string
number: string
status: Order["status"]
placedAt: Date
fulfilledAt?: Date
customer: { name: string; company: string; avatarUrl?: string }
country: string
paymentMethod: Order["paymentMethod"]
units: number
/** The lines before any discount or tax. */
subtotalCents: number
discountCents: number
taxCents: number
totalCents: number
lines: OrderLine[]
}
/** The `count` most recent orders, newest first, with their lines. */
export function recentOrders(count = RECENT_COUNT): RecentOrder[] {
const customers = new Map(db.customers.all().map((customer) => [customer.id, customer]))
const products = new Map(db.products.all().map((product) => [product.id, product]))
return db.orders
.all()
.sort((a, b) => b.placedAt.getTime() - a.placedAt.getTime())
.slice(0, count)
.map((order) => {
const customer = customers.get(order.customerId)
const lines = order.items.map((item) => {
const product = products.get(item.productId)
return {
productId: item.productId,
name: product?.name ?? item.productId,
category: product?.category ?? "",
sku: product?.sku ?? item.productId,
qty: item.qty,
unitCents: item.unitCents,
totalCents: item.qty * item.unitCents,
}
})
return {
id: order.id,
number: order.number,
status: order.status,
placedAt: order.placedAt,
fulfilledAt: order.fulfilledAt,
customer: {
name: customer?.name ?? "Walk-in customer",
company: customer?.company ?? "",
avatarUrl: customer?.avatarUrl,
},
country: order.country,
paymentMethod: order.paymentMethod,
units: lines.reduce((total, line) => total + line.qty, 0),
subtotalCents: lines.reduce((total, line) => total + line.totalCents, 0),
discountCents: order.discountCents ?? 0,
taxCents: order.taxCents ?? 0,
totalCents: order.totalCents,
lines,
}
})
}npx shadcn@latest add @vibra/tables-02Seats edited in place
tables-03An account manager's book with every seat count editable in the cell: saved through a server action that prices the change, refused on the row when the account's plan or status does not allow it.
import * as React from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { SeatTable } from "./tables-03.client"
import {
ACCOUNT_MANAGER,
MAX_SEATS,
changeSeats,
seatAccounts,
type Result,
type SeatChange,
type SeatSaved,
} from "./tables-03.data"
/**
* An account manager's book, with each account's seats editable in place:
* the strip names whose accounts they are, and the sheet holds the table and
* a status line that says what the last change did to the bill.
*
* A server component. It reads the rows through `./tables-03.data` and hands
* the island `saveSeats`, a server function defined here: the island calls it
* with an account and a number, and every rule — the plan's limit, a
* suspended account, what a seat costs — is decided on the server from the
* stored row, answered as a `Result`.
*/
export function Tables03() {
const titleId = React.useId()
async function saveSeats(change: SeatChange): Promise<Result<SeatSaved>> {
"use server"
return changeSeats(change)
}
return (
<section
data-section="tables-03"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>Seats by account</h2>
</CardTitle>
<CardDescription>
{ACCOUNT_MANAGER}’s accounts. Press a seat count to change it; the bill follows.
</CardDescription>
</CardHeader>
<CardContent className="p-0">
<SeatTable rows={seatAccounts()} onSave={saveSeats} titleId={titleId} maxSeats={MAX_SEATS} />
</CardContent>
</Card>
</div>
</section>
)
}
export default Tables03"use client"
import * as React from "react"
import { CheckIcon, PencilIcon, XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { formatCurrency, formatNumber } from "@/lib/format"
import { Button } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
import { Input } from "@/components/ui/input"
import { Spinner } from "@/components/ui/spinner"
import { StatusBadge } from "@/components/ui/status-badge"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { UserCell } from "@/components/ui/user-cell"
import type { AccountPlan, Result, SeatAccount, SeatChange, SeatSaved } from "./tables-03.data"
export const PAGE_SIZE = 10
const PLAN_LABELS: Record<AccountPlan, string> = { free: "Free", starter: "Starter", team: "Team", enterprise: "Enterprise" }
const STATUS_TONES = { active: "success", trial: "info", suspended: "warning", churned: "neutral" } as const
const DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" })
const money = (cents: number) => formatCurrency(cents / 100, "USD", { maximumFractionDigits: 0 })
const seatsWord = (count: number) => `${formatNumber(count)} ${count === 1 ? "seat" : "seats"}`
const HEAD = "h-10 bg-surface px-3 text-foreground"
const PINNED = "max-md:sticky max-md:left-0 max-md:z-10 max-md:after:absolute max-md:after:inset-y-0 max-md:after:right-0 max-md:after:w-px max-md:after:bg-border"
type Editing = { id: string; draft: string }
/**
* The seats column edits in place. A seat count is a button naming the
* account it belongs to; pressing it swaps in a field with Save and Cancel —
* Enter saves, Escape puts the number back and returns focus to it. The field
* refuses what is not a whole number itself; everything else is the server
* action's to decide, and a refusal opens a danger callout on the row under
* the account, named and read out, while the field keeps what was typed. A
* saved change updates the row and says what the account now pays.
*/
export function SeatTable({
rows,
onSave,
titleId,
maxSeats,
}: {
rows: SeatAccount[]
onSave: (change: SeatChange) => Promise<Result<SeatSaved>>
titleId: string
maxSeats: number
}) {
const [accounts, setAccounts] = React.useState(rows)
const [editing, setEditing] = React.useState<Editing | null>(null)
const [invalid, setInvalid] = React.useState<string | null>(null)
const [refusal, setRefusal] = React.useState<{ id: string; message: string } | null>(null)
const [pending, setPending] = React.useState(false)
const [notice, setNotice] = React.useState("")
const [page, setPage] = React.useState(0)
const buttons = React.useRef(new Map<string, HTMLButtonElement | null>())
const field = React.useRef<HTMLInputElement>(null)
const returnTo = React.useRef<string | null>(null)
const errorId = React.useId()
const refusalId = React.useId()
const editingId = editing?.id
React.useEffect(() => {
if (editingId) field.current?.select()
}, [editingId])
React.useEffect(() => {
if (editingId || !returnTo.current) return
buttons.current.get(returnTo.current)?.focus()
returnTo.current = null
}, [editingId])
const pages = Math.max(1, Math.ceil(accounts.length / PAGE_SIZE))
const shown = accounts.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE)
const monthly = accounts.reduce((total, account) => total + account.mrrCents, 0)
function start(account: SeatAccount) {
setEditing({ id: account.id, draft: String(account.seats) })
setInvalid(null)
setRefusal(null)
}
function stop() {
returnTo.current = editing?.id ?? null
setEditing(null)
setInvalid(null)
setRefusal(null)
}
async function save() {
if (!editing || pending) return
const account = accounts.find((entry) => entry.id === editing.id)
if (!account) return
const draft = editing.draft.trim()
if (!/^\d+$/.test(draft)) return setInvalid("Enter a whole number of seats.")
const seats = Number(draft)
if (seats < 1) return setInvalid("An account keeps at least one seat.")
if (seats > maxSeats) return setInvalid(`One change can ask for at most ${formatNumber(maxSeats)} seats.`)
if (seats === account.seats) return stop()
setInvalid(null)
setRefusal(null)
setPending(true)
let result: Result<SeatSaved>
try {
result = await onSave({ id: account.id, seats })
} catch {
result = { ok: false, error: { code: "network", message: "The change did not reach the server. Try again." } }
}
setPending(false)
if (!result.ok) return setRefusal({ id: account.id, message: result.error.message })
const saved = result.data
setAccounts((current) =>
current.map((entry) => (entry.id === saved.id ? { ...entry, seats: saved.seats, mrrCents: saved.mrrCents } : entry))
)
const direction = saved.seats > saved.previousSeats ? "up" : "down"
const bill =
saved.mrrCents === saved.previousMrrCents
? `The monthly bill stays at ${money(saved.mrrCents)}.`
: `Monthly bill ${money(saved.previousMrrCents)} → ${money(saved.mrrCents)}.`
setNotice(`${saved.company} now has ${seatsWord(saved.seats)}, ${direction} from ${formatNumber(saved.previousSeats)}. ${bill}`)
returnTo.current = saved.id
setEditing(null)
}
function turn(to: number) {
setEditing(null)
setInvalid(null)
setRefusal(null)
setPage(to)
}
return (
<div data-slot="seat-table">
<Table aria-labelledby={titleId}>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className={cn(HEAD, PINNED)}>Account</TableHead>
<TableHead className={HEAD}>Plan</TableHead>
<TableHead className={HEAD}>Status</TableHead>
<TableHead className={cn(HEAD, "text-right")}>Seats</TableHead>
<TableHead className={cn(HEAD, "text-right")}>Monthly</TableHead>
<TableHead className={cn(HEAD, "text-right")}>Renews</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{shown.map((account) => {
const open = editing?.id === account.id
const refused = refusal?.id === account.id
return (
<React.Fragment key={account.id}>
<TableRow className="group/row">
<TableCell
className={cn(
"px-3 py-2",
PINNED,
"bg-card group-hover/row:bg-[color-mix(in_oklab,var(--muted)_50%,var(--card))]"
)}
>
<UserCell size="sm" name={account.company} description={account.contact} src={account.avatarUrl} className="max-w-44 sm:max-w-none" />
</TableCell>
<TableCell className="px-3 py-2">
<span className="flex flex-col leading-tight">
<span>{PLAN_LABELS[account.plan]}</span>
<span className="text-xs text-muted-foreground tabular-nums">
{account.seatCents > 0 ? `${money(account.seatCents)} a seat` : "No charge"}
</span>
</span>
</TableCell>
<TableCell className="px-3 py-2">
<StatusBadge size="sm" status={account.status} map={STATUS_TONES} />
</TableCell>
<TableCell className="px-3 py-1.5 text-right">
{open ? (
<div className="inline-flex flex-col items-end gap-1">
<div className="flex items-center gap-1">
<Input
ref={field}
autoFocus
inputMode="numeric"
value={editing.draft}
readOnly={pending}
onChange={(event) => {
setEditing({ id: account.id, draft: event.target.value })
setInvalid(null)
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault()
void save()
} else if (event.key === "Escape") {
event.preventDefault()
stop()
}
}}
aria-label={`Seats for ${account.company}`}
aria-invalid={invalid || refused ? true : undefined}
aria-describedby={invalid ? errorId : refused ? refusalId : undefined}
className="h-7 w-20 text-right tabular-nums"
/>
<Button
size="icon-sm"
aria-label={`Save seats for ${account.company}`}
aria-busy={pending || undefined}
onClick={() => void save()}
>
{pending ? <Spinner aria-hidden="true" className="size-3.5" /> : <CheckIcon aria-hidden="true" />}
</Button>
<Button variant="ghost" size="icon-sm" aria-label="Cancel" disabled={pending} focusableWhenDisabled onClick={stop}>
<XIcon aria-hidden="true" />
</Button>
</div>
{invalid ? (
<p id={errorId} className="text-xs whitespace-normal text-danger">
{invalid}
</p>
) : null}
</div>
) : (
<Button
ref={(node) => {
buttons.current.set(account.id, node)
}}
variant="ghost"
size="sm"
aria-label={`Edit seats for ${account.company}, ${account.seats}`}
onClick={() => start(account)}
// The dotted rule says the number can be changed before the pointer finds the pencil.
className="-me-2 font-normal tabular-nums underline decoration-muted-foreground/50 decoration-dotted underline-offset-4"
>
{formatNumber(account.seats)}
<PencilIcon
data-icon="inline-end"
aria-hidden="true"
className="text-muted-foreground opacity-0 transition-opacity group-hover/row:opacity-100 group-focus-visible/button:opacity-100"
/>
</Button>
)}
</TableCell>
<TableCell className="px-3 py-2 text-right tabular-nums">{money(account.mrrCents)}</TableCell>
<TableCell className="px-3 py-2 text-right text-muted-foreground tabular-nums">
{account.renewsAt ? <time dateTime={account.renewsAt.toISOString()}>{DAY.format(account.renewsAt)}</time> : "—"}
</TableCell>
</TableRow>
{refused ? (
<TableRow className="hover:bg-transparent">
<TableCell colSpan={6} className="px-3 pt-0 pb-3 whitespace-normal">
<Callout id={refusalId} variant="danger" role="alert" title={`${account.company} keeps ${seatsWord(account.seats)}`}>
{refusal.message}
</Callout>
</TableCell>
</TableRow>
) : null}
</React.Fragment>
)
})}
</TableBody>
</Table>
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-rule px-3 py-2.5">
<p role="status" aria-live="polite" className="min-w-0 text-sm text-muted-foreground tabular-nums">
{notice || `${accounts.length} accounts · ${money(monthly)} a month`}
</p>
<div className="flex items-center gap-1.5">
<span className="me-1 text-sm text-muted-foreground tabular-nums">
Page {page + 1} of {pages}
</span>
<Button variant="outline" size="sm" disabled={page === 0} focusableWhenDisabled onClick={() => turn(page - 1)}>
Previous
</Button>
<Button variant="outline" size="sm" disabled={page >= pages - 1} focusableWhenDisabled onClick={() => turn(page + 1)}>
Next
</Button>
</div>
</div>
</div>
)
}/**
* What `tables-03` reads and the one thing it writes: the accounts an account
* manager owns in `db.customers`, with the renewal each one's subscription is
* on, and `changeSeats`, which the section's server action calls to resize an
* account.
*
* Every read happens inside a function and runs on the server. The island
* takes the rows as props and calls the action it is handed; it never imports
* this module.
*/
import { db, fields, isWhole, type Customer, type Result } from "@/lib/sample-data"
export type { Result }
/** Whose book of accounts the table shows. */
export const ACCOUNT_MANAGER = "Farid Meyer"
/** The most seats a Free workspace can hold; past it the account has to move to a paid plan. */
export const FREE_SEAT_LIMIT = 3
/** The most seats one change may ask for — a typo guard, not a plan limit. */
export const MAX_SEATS = 1000
export type AccountPlan = Customer["plan"]
export type AccountStatus = Customer["status"]
export type SeatAccount = {
id: string
company: string
contact: string
avatarUrl?: string
plan: AccountPlan
status: AccountStatus
seats: number
/** What one seat costs a month on this plan, in cents. */
seatCents: number
mrrCents: number
/** When the subscription renews, if it has one that is not cancelled. */
renewsAt?: Date
}
/** What the island asks to change. */
export type SeatChange = { id: string; seats: number }
/** What a saved change sends back: the account's new seats and what it now pays. */
export type SeatSaved = { id: string; company: string; seats: number; mrrCents: number; previousSeats: number; previousMrrCents: number }
/**
* A seat's monthly price on each plan, read off the accounts that pay: every
* paying account on a plan pays the same rate per seat, so the rate is the
* one its paying accounts share.
*/
function seatPrices(): Record<AccountPlan, number> {
const prices: Record<AccountPlan, number> = { free: 0, starter: 0, team: 0, enterprise: 0 }
for (const customer of db.customers.all()) {
if (customer.status === "active" && customer.mrrCents > 0 && customer.seats > 0 && prices[customer.plan] === 0)
prices[customer.plan] = Math.round(customer.mrrCents / customer.seats)
}
return prices
}
/** The manager's accounts, A to Z, each with its plan's seat price and its renewal. */
export function seatAccounts(): SeatAccount[] {
const manager = db.members.all().find((member) => member.name === ACCOUNT_MANAGER)
if (!manager) throw new Error(`tables-03 shows the accounts of "${ACCOUNT_MANAGER}", who is not a member`)
const prices = seatPrices()
// A cancelled subscription has nothing left to renew.
const renewals = new Map(
db.subscriptions
.all()
.filter((subscription) => subscription.status !== "cancelled")
.map((subscription) => [subscription.customerId, subscription.renewsAt])
)
return db.customers
.all()
.filter((customer) => customer.owner === manager.id)
.sort((a, b) => a.company.localeCompare(b.company))
.map((customer) => ({
id: customer.id,
company: customer.company,
contact: customer.name,
avatarUrl: customer.avatarUrl,
plan: customer.plan,
status: customer.status,
seats: customer.seats,
seatCents: prices[customer.plan],
mrrCents: customer.mrrCents,
renewsAt: renewals.get(customer.id),
}))
}
const refuse = (code: string, message: string): Result<never> => ({ ok: false, error: { code, message, field: "seats" } })
/**
* Resizes an account. Everything but the number asked for is read off the
* stored row — its plan, its status, what a seat costs — never taken from the
* caller. A suspended or churned account keeps its seats; a Free workspace
* stops at three; a trial pays nothing until it converts, so its bill stays
* at zero. The subscription is resized with the account, so the two agree.
*/
export async function changeSeats(sent: SeatChange): Promise<Result<SeatSaved>> {
const change = fields(sent)
const account = typeof change.id === "string" ? await db.customers.get(change.id) : undefined
if (!account) return refuse("not_found", "That account no longer exists, so nothing was changed.")
const seats = change.seats
if (!isWhole(seats, 1, MAX_SEATS))
return refuse("invalid", `Seats have to be a whole number from 1 to ${MAX_SEATS}.`)
if (account.status === "suspended")
return refuse("suspended", `${account.company} is suspended, so its seats are frozen. Reactivate the account first.`)
if (account.status === "churned")
return refuse("churned", `${account.company} has cancelled, so there are no seats to change. Win the account back first.`)
if (account.plan === "free" && seats > FREE_SEAT_LIMIT)
return refuse(
"plan_limit",
`The Free plan stops at ${FREE_SEAT_LIMIT} seats. Move ${account.company} to Starter to give it ${seats}.`
)
const price = seatPrices()[account.plan]
const mrrCents = account.status === "trial" ? 0 : seats * price
const updated = await db.customers.update(account.id, { seats, mrrCents })
if (!updated.ok) return updated
const subscription = db.subscriptions.all().find((entry) => entry.customerId === account.id)
if (subscription) await db.subscriptions.update(subscription.id, { seats, mrrCents })
return {
ok: true,
data: {
id: account.id,
company: account.company,
seats,
mrrCents,
previousSeats: account.seats,
previousMrrCents: account.mrrCents,
},
}
}npx shadcn@latest add @vibra/tables-03Pipeline grouped by stage with subtotals
tables-04Every open deal in four row groups, one per stage: each stage folds on a header row that keeps its count and subtotals, the columns sort inside every stage, and the footer adds the pipeline up.
import * as React from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { PipelineTable } from "./tables-04.client"
import { pipelineByStage } from "./tables-04.data"
/**
* The open pipeline grouped by stage: each stage folds on its own header row,
* which carries the stage's count and subtotals, and the footer adds the
* stages up — the sum of the amounts and the sum weighted by each deal's
* chance of closing.
*
* A server component: the stages come from `./tables-04.data`; the island
* owns the sort and which stages are folded.
*/
export function Tables04() {
const titleId = React.useId()
return (
<section
data-section="tables-04"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>Pipeline by stage</h2>
</CardTitle>
<CardDescription>Every open deal. Weighted is the amount times the deal’s chance of closing.</CardDescription>
</CardHeader>
<CardContent className="p-0">
<PipelineTable stages={pipelineByStage()} titleId={titleId} />
</CardContent>
</Card>
</div>
</section>
)
}
export default Tables04"use client"
import * as React from "react"
import { ArrowDownIcon, ArrowUpIcon, ChevronRightIcon, ChevronsUpDownIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { formatCurrency, formatNumber } from "@/lib/format"
import { Button } from "@/components/ui/button"
import { Table, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { UserCell } from "@/components/ui/user-cell"
import type { OpenStage, PipelineDeal, PipelineStage } from "./tables-04.data"
export const STAGE_LABELS: Record<OpenStage, string> = {
lead: "Lead",
qualified: "Qualified",
proposal: "Proposal",
negotiation: "Negotiation",
}
type SortKey = "expectedCloseAt" | "probability" | "amountCents" | "weightedCents"
type Sort = { key: SortKey; desc: boolean }
const DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" })
const money = (cents: number) => formatCurrency(cents / 100, "USD", { maximumFractionDigits: 0 })
const dealsWord = (count: number) => `${formatNumber(count)} ${count === 1 ? "deal" : "deals"}`
const sum = (deals: PipelineDeal[], key: "amountCents" | "weightedCents") => deals.reduce((total, deal) => total + deal[key], 0)
const valueOf = (deal: PipelineDeal, key: SortKey) => (key === "expectedCloseAt" ? deal.expectedCloseAt.getTime() : deal[key])
const SORT_WORDS = { ascending: "sorted ascending", descending: "sorted descending", none: "not sorted" } as const
const HEAD = "h-10 bg-surface px-3 text-foreground"
/** Below md the deal column stays put while the figures scroll, on an opaque plane. */
const PINNED =
"max-md:sticky max-md:start-0 max-md:z-10 max-md:after:absolute max-md:after:inset-y-0 max-md:after:end-0 max-md:after:w-px max-md:after:bg-border"
/** A column title that sorts the deals inside every stage; the header cell carries `aria-sort`. */
function SortHeader({ label, column, sort, onSort }: { label: string; column: SortKey; sort: Sort; onSort: (column: SortKey) => void }) {
const state = sort.key !== column ? "none" : sort.desc ? "descending" : "ascending"
const Icon = state === "none" ? ChevronsUpDownIcon : state === "descending" ? ArrowDownIcon : ArrowUpIcon
return (
<TableHead aria-sort={state} className={cn(HEAD, "text-end")}>
<Button
variant="ghost"
size="sm"
aria-label={`${label}, ${SORT_WORDS[state]}`}
onClick={() => onSort(column)}
className="-me-2.5 text-sm font-medium text-foreground"
>
{label}
<Icon aria-hidden="true" className="size-3.5" />
</Button>
</TableHead>
)
}
/**
* The pipeline as one table in four row groups. Each stage opens on a header
* row — a disclosure button naming the stage and its count, then the stage's
* subtotals under the amount columns — so a folded stage still says what it
* holds, and the footer adds the stages up. Lead and Qualified open folded.
* The sortable columns order the deals inside each stage; the stages keep
* the order a deal moves through.
*/
export function PipelineTable({ stages, titleId }: { stages: PipelineStage[]; titleId: string }) {
const [sort, setSort] = React.useState<Sort>({ key: "expectedCloseAt", desc: false })
// The early stages open folded: they are the longest and the least likely
// to close, and their subtotals still show on their header rows.
const [folded, setFolded] = React.useState<ReadonlySet<OpenStage>>(() => new Set(["lead", "qualified"]))
const baseId = React.useId()
const everything = stages.flatMap((group) => group.deals)
const ordered = (deals: PipelineDeal[]) =>
[...deals].sort((a, b) => {
const [left, right] = [valueOf(a, sort.key), valueOf(b, sort.key)]
return (sort.desc ? right - left : left - right) || a.company.localeCompare(b.company)
})
function onSort(key: SortKey) {
// Money sorts largest first; a date and a chance start from the soonest and the least.
setSort((current) =>
current.key === key ? { key, desc: !current.desc } : { key, desc: key === "amountCents" || key === "weightedCents" }
)
}
function toggle(stage: OpenStage) {
setFolded((current) => {
const next = new Set(current)
if (next.has(stage)) next.delete(stage)
else next.add(stage)
return next
})
}
return (
<Table aria-labelledby={titleId}>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className={cn(HEAD, PINNED)}>Deal</TableHead>
<TableHead className={HEAD}>Owner</TableHead>
<SortHeader label="Close" column="expectedCloseAt" sort={sort} onSort={onSort} />
<SortHeader label="Chance" column="probability" sort={sort} onSort={onSort} />
<SortHeader label="Amount" column="amountCents" sort={sort} onSort={onSort} />
<SortHeader label="Weighted" column="weightedCents" sort={sort} onSort={onSort} />
</TableRow>
</TableHeader>
{stages.map((group) => {
const open = !folded.has(group.stage)
const rowsId = `${baseId}-${group.stage}`
const deals = ordered(group.deals)
return (
<tbody key={group.stage} data-slot="table-body" data-stage={group.stage}>
<TableRow data-slot="stage-row" className="bg-surface hover:bg-surface">
<th scope="rowgroup" colSpan={4} className="px-3 py-1.5 text-start font-normal">
{/* Sticky inside the wide cell, so on a phone the stage stays in
view while its subtotals scroll in from the right. */}
<span className="sticky start-3 inline-flex">
<Button
variant="ghost"
size="sm"
aria-expanded={open}
// The pill's count is read with its noun: "Proposal 9 deals".
aria-label={`${STAGE_LABELS[group.stage]} ${dealsWord(group.deals.length)}`}
aria-controls={deals.map((deal) => `${rowsId}-${deal.id}`).join(" ") || undefined}
onClick={() => toggle(group.stage)}
className="-ms-1.5 gap-1.5 font-semibold text-foreground"
>
<ChevronRightIcon
aria-hidden="true"
className="text-muted-foreground transition-transform duration-(--duration-fast) ease-(--ease-standard) group-aria-expanded/button:rotate-90 rtl:-scale-x-100 rtl:group-aria-expanded/button:-rotate-90"
/>
{STAGE_LABELS[group.stage]}
<span className="rounded-full bg-muted px-1.5 text-xs font-medium text-muted-foreground tabular-nums">
{formatNumber(group.deals.length)}
</span>
</Button>
</span>
</th>
<TableCell className="px-3 py-1.5 text-end font-semibold tabular-nums">{money(sum(group.deals, "amountCents"))}</TableCell>
<TableCell className="px-3 py-1.5 text-end font-semibold tabular-nums">{money(sum(group.deals, "weightedCents"))}</TableCell>
</TableRow>
{deals.map((deal) => (
<TableRow key={deal.id} id={`${rowsId}-${deal.id}`} hidden={!open} className="group/row">
<TableCell className={cn(PINNED, "bg-card px-3 py-2 group-hover/row:bg-[color-mix(in_oklab,var(--muted)_50%,var(--card))]")}>
<span className="flex min-w-0 flex-col leading-tight">
<span className="font-medium">{deal.company}</span>
<span className="text-xs text-muted-foreground">{deal.title}</span>
</span>
</TableCell>
<TableCell className="px-3 py-2">
<UserCell size="sm" name={deal.owner.name} src={deal.owner.avatarUrl} />
</TableCell>
<TableCell className="px-3 py-2 text-end text-muted-foreground tabular-nums">
<time dateTime={deal.expectedCloseAt.toISOString()}>{DAY.format(deal.expectedCloseAt)}</time>
</TableCell>
<TableCell className="px-3 py-2 text-end tabular-nums">{deal.probability}%</TableCell>
<TableCell className="px-3 py-2 text-end tabular-nums">{money(deal.amountCents)}</TableCell>
<TableCell className="px-3 py-2 text-end text-muted-foreground tabular-nums">{money(deal.weightedCents)}</TableCell>
</TableRow>
))}
</tbody>
)
})}
<TableFooter className="bg-surface">
<TableRow className="hover:bg-transparent">
<th scope="row" colSpan={4} className="px-3 py-2.5 text-start font-semibold">
<span className="sticky start-3">Open pipeline · {dealsWord(everything.length)}</span>
</th>
<TableCell className="px-3 py-2.5 text-end font-semibold tabular-nums">{money(sum(everything, "amountCents"))}</TableCell>
<TableCell className="px-3 py-2.5 text-end font-semibold tabular-nums">{money(sum(everything, "weightedCents"))}</TableCell>
</TableRow>
</TableFooter>
</Table>
)
}/**
* What `tables-04` reads: every open deal in `db.deals`, grouped by the stage
* it is at, each with the rep who owns it and what it is worth weighted by
* its chance of closing. Won and lost deals are history, not pipeline, and
* are left out.
*
* Selectors only, reading `db` inside functions and run on the server. The
* island takes these groups as props; it never imports this module.
*/
import { db, type Deal } from "@/lib/sample-data"
export type OpenStage = Exclude<Deal["stage"], "won" | "lost">
/** The stages a deal moves through before it closes, in order. */
export const OPEN_STAGES: readonly OpenStage[] = ["lead", "qualified", "proposal", "negotiation"]
export type PipelineDeal = {
id: string
company: string
/** What the deal is for: the plan and the seats. */
title: string
owner: { name: string; avatarUrl?: string }
amountCents: number
/** 0–100. */
probability: number
/** The amount times the chance of closing, in cents. */
weightedCents: number
expectedCloseAt: Date
}
export type PipelineStage = { stage: OpenStage; deals: PipelineDeal[] }
/** The open deals by stage, earliest stage first; within a stage, the soonest to close first. */
export function pipelineByStage(): PipelineStage[] {
const members = new Map(db.members.all().map((member) => [member.id, member]))
const open = db.deals.all().filter((deal): deal is Deal & { stage: OpenStage } => OPEN_STAGES.includes(deal.stage as OpenStage))
return OPEN_STAGES.map((stage) => ({
stage,
deals: open
.filter((deal) => deal.stage === stage)
.sort((a, b) => a.expectedCloseAt.getTime() - b.expectedCloseAt.getTime())
.map((deal) => {
const owner = members.get(deal.owner)
return {
id: deal.id,
company: deal.company,
// "Brightline Software · Team, 152 seats" — the company has its own line.
title: deal.name.includes(" · ") ? deal.name.slice(deal.name.indexOf(" · ") + 3) : deal.name,
owner: { name: owner?.name ?? "Unassigned", avatarUrl: owner?.avatarUrl },
amountCents: deal.amountCents,
probability: deal.probability,
weightedCents: Math.round((deal.amountCents * deal.probability) / 100),
expectedCloseAt: deal.expectedCloseAt,
}
}),
}))
}npx shadcn@latest add @vibra/tables-04Selection across pages with a bulk bar
tables-05The workspace's people with a checkbox on every row, a page that offers the whole workspace once it is selected, and a floating bar that changes roles or deactivates everyone selected through one server action.
import * as React from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { MembersActions, MembersSelection, MembersTable } from "./tables-05.client"
import { ASSIGNABLE_ROLES, changeMembers, memberRows, type MemberChange, type MemberOutcome, type Result } from "./tables-05.data"
/**
* The workspace's people with a selection that spans pages and a floating bar
* of bulk actions: change the role of everyone selected, deactivate them or
* bring them back.
*
* A server component. It draws the card and its heading, reads the rows
* through `./tables-05.data`, and hands the island `updateMembers`, a server
* function defined here, which judges every person against their stored row
* and answers with who changed and who was refused. The island is the
* selection: the table on the card's sheet and the bar under the card.
*/
export function Tables05() {
const titleId = React.useId()
async function updateMembers(ids: string[], change: MemberChange): Promise<Result<MemberOutcome>> {
"use server"
return changeMembers(ids, change)
}
const rows = memberRows()
const active = rows.filter((row) => row.status === "active").length
return (
<section
data-section="tables-05"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<MembersSelection rows={rows} onChange={updateMembers}>
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>People</h2>
</CardTitle>
<CardDescription className="tabular-nums">
{active} active of {rows.length}. Select people to change their role or deactivate them together.
</CardDescription>
</CardHeader>
<CardContent className="p-0">
<MembersTable titleId={titleId} />
</CardContent>
</Card>
<MembersActions roles={ASSIGNABLE_ROLES} />
</MembersSelection>
</div>
</section>
)
}
export default Tables05"use client"
import * as React from "react"
import { CheckIcon, ChevronDownIcon, MinusIcon, UserCheckIcon, UserXIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { formatNumber } from "@/lib/format"
import { SelectionActionBar } from "@/components/ui/action-bar"
import { Button } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
import { Checkbox } from "@/components/ui/checkbox"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { StatusBadge } from "@/components/ui/status-badge"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { UserCell } from "@/components/ui/user-cell"
import type { MemberChange, MemberOutcome, MemberRole, MemberRow, Result } from "./tables-05.data"
export const PAGE_SIZE = 8
export const ROLE_LABELS: Record<MemberRole, string> = { owner: "Owner", admin: "Admin", member: "Member", viewer: "Viewer" }
const ROLE_PLURALS: Record<MemberRole, string> = { owner: "owners", admin: "admins", member: "members", viewer: "viewers" }
const STATUS_TONES = { active: "success", invited: "info", deactivated: "neutral" } as const
const HEAD = "h-10 bg-surface px-3 text-foreground"
// Below md the checkbox and the person stay put while the rest scrolls, each
// on an opaque plane that follows the row's own: the card, the half-muted
// hover, the accent tint of a selected row.
const PIN_BOX = "max-md:sticky max-md:left-0 max-md:z-10"
const PIN_PERSON =
"max-md:sticky max-md:left-10 max-md:z-10 max-md:after:absolute max-md:after:inset-y-0 max-md:after:right-0 max-md:after:w-px max-md:after:bg-border"
const PLANE =
"bg-card group-hover/row:bg-[color-mix(in_oklab,var(--muted)_50%,var(--card))] group-data-[state=selected]/row:bg-brand-muted"
const people = (count: number) => `${formatNumber(count)} ${count === 1 ? "person" : "people"}`
const idle = (days: number) => (days === 0 ? "Today" : days === 1 ? "Yesterday" : `${days} days ago`)
/** What the status line says a change did. */
function said(change: MemberChange, changed: MemberRow[]): string {
const who = changed.length === 1 ? changed[0].name : people(changed.length)
if (change.kind === "role") return `Made ${who} ${changed.length === 1 ? `a ${ROLE_LABELS[change.role].toLowerCase()}` : ROLE_PLURALS[change.role]}.`
return `${change.kind === "deactivate" ? "Deactivated" : "Reactivated"} ${who}.`
}
type Selection = {
members: MemberRow[]
shown: MemberRow[]
selected: ReadonlySet<string>
page: number
pages: number
pending: boolean
notice: string
refused: MemberOutcome["refused"]
failure: string
boxes: React.RefObject<Map<string, HTMLElement | null>>
setPage: (page: number) => void
select: (ids: string[], on: boolean) => void
clear: () => void
run: (change: MemberChange) => Promise<void>
}
const SelectionContext = React.createContext<Selection | null>(null)
function useSelection(): Selection {
const selection = React.useContext(SelectionContext)
if (!selection) throw new Error("MembersTable and MembersActions belong inside MembersSelection")
return selection
}
/**
* The selection and the rows it is over, shared by the table in the card and
* the bar under it — the card itself, its heading and its description are the
* server's, passed through as children. Once anyone is selected the kit's
* floating selection bar holds the bulk actions — a new role, deactivate,
* reactivate — which go to the server action together. The server judges each
* person against their stored row: who could take the change gets it and
* drops out of the selection, who could not stays selected and is named in a
* danger callout with the reason. The status line says what changed.
*/
export function MembersSelection({
rows,
onChange,
children,
}: {
rows: MemberRow[]
onChange: (ids: string[], change: MemberChange) => Promise<Result<MemberOutcome>>
children: React.ReactNode
}) {
const [members, setMembers] = React.useState(rows)
const [selected, setSelected] = React.useState<ReadonlySet<string>>(() => new Set())
const [page, setPage] = React.useState(0)
const [pending, setPending] = React.useState(false)
const [notice, setNotice] = React.useState("")
const [refused, setRefused] = React.useState<MemberOutcome["refused"]>([])
const [failure, setFailure] = React.useState("")
const boxes = React.useRef(new Map<string, HTMLElement | null>())
const focusAfter = React.useRef<string | null>(null)
// When the bar goes, focus goes back to the row the change started from.
React.useEffect(() => {
if (!focusAfter.current || selected.size > 0) return
boxes.current.get(focusAfter.current)?.focus()
focusAfter.current = null
}, [selected])
const pages = Math.max(1, Math.ceil(members.length / PAGE_SIZE))
const shown = members.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE)
function select(ids: string[], on: boolean) {
setSelected((current) => {
const next = new Set(current)
for (const id of ids) {
if (on) next.add(id)
else next.delete(id)
}
return next
})
}
async function run(change: MemberChange) {
if (pending || selected.size === 0) return
setPending(true)
setRefused([])
setFailure("")
let result: Result<MemberOutcome>
try {
result = await onChange([...selected], change)
} catch {
result = { ok: false, error: { code: "network", message: "The change did not reach the server. Try again." } }
}
setPending(false)
if (!result.ok) return setFailure(result.error.message)
const { changed, refused: turnedDown } = result.data
const byId = new Map(changed.map((row) => [row.id, row]))
setMembers((current) => current.map((member) => byId.get(member.id) ?? member))
setRefused(turnedDown)
setNotice(changed.length > 0 ? said(change, changed) : "Nothing changed.")
// Who took the change leaves the selection; who was refused stays in it.
const remaining = new Set(turnedDown.map((entry) => entry.id))
if (remaining.size === 0) focusAfter.current = shown.find((member) => byId.has(member.id))?.id ?? shown[0]?.id ?? null
setSelected(remaining)
}
const value: Selection = {
members,
shown,
selected,
page,
pages,
pending,
notice,
refused,
failure,
boxes,
setPage,
select,
clear: () => setSelected(new Set()),
run,
}
return <SelectionContext.Provider value={value}>{children}</SelectionContext.Provider>
}
/**
* The people table on the card's sheet: a checkbox on every row and one on
* the header for the page; with a page selected, a row offers the whole
* workspace. Under it, the refusals and the footer with the status line and
* the pager.
*/
export function MembersTable({ titleId }: { titleId: string }) {
const { members, shown, selected, page, pages, pending, notice, refused, failure, boxes, setPage, select, clear } = useSelection()
const pageAll = shown.every((member) => selected.has(member.id))
const pageSome = shown.some((member) => selected.has(member.id))
const everyone = selected.size === members.length
return (
<>
<Table aria-labelledby={titleId} aria-busy={pending || undefined} className={cn("transition-opacity", pending && "opacity-60")}>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className={cn(HEAD, PIN_BOX, "w-10 pe-0")}>
<Checkbox
checked={pageAll}
indeterminate={pageSome && !pageAll}
onCheckedChange={(checked) => select(shown.map((member) => member.id), checked)}
aria-label="Select everyone on this page"
/>
</TableHead>
<TableHead className={cn(HEAD, PIN_PERSON)}>Member</TableHead>
<TableHead className={HEAD}>Role</TableHead>
<TableHead className={HEAD}>Status</TableHead>
<TableHead className={HEAD}>Two-step</TableHead>
<TableHead className={cn(HEAD, "text-right")}>Last active</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pageAll && !everyone && members.length > shown.length ? (
<TableRow className="hover:bg-transparent">
<TableCell colSpan={6} className="bg-surface px-3 py-2 text-center text-sm whitespace-normal">
All {people(shown.length)} on this page are selected.{" "}
<Button variant="link" size="sm" className="h-auto px-0" onClick={() => select(members.map((member) => member.id), true)}>
Select all {formatNumber(members.length)} in the workspace
</Button>
</TableCell>
</TableRow>
) : null}
{everyone ? (
<TableRow className="hover:bg-transparent">
<TableCell colSpan={6} className="bg-surface px-3 py-2 text-center text-sm whitespace-normal">
All {people(members.length)} in the workspace are selected.{" "}
<Button variant="link" size="sm" className="h-auto px-0" onClick={clear}>
Clear the selection
</Button>
</TableCell>
</TableRow>
) : null}
{shown.map((member) => {
const isSelected = selected.has(member.id)
return (
<TableRow key={member.id} data-state={isSelected ? "selected" : undefined} className="group/row">
<TableCell className={cn(PIN_BOX, PLANE, "w-10 px-3 py-2 pe-0")}>
<Checkbox
ref={(node: HTMLElement | null) => {
boxes.current.set(member.id, node)
}}
checked={isSelected}
onCheckedChange={(checked) => select([member.id], checked)}
aria-label={`Select ${member.name}`}
/>
</TableCell>
<TableCell className={cn(PIN_PERSON, PLANE, "px-3 py-2")}>
<UserCell size="sm" name={member.name} email={member.email} src={member.avatarUrl} className="max-w-40 sm:max-w-none" />
</TableCell>
<TableCell className="px-3 py-2">{ROLE_LABELS[member.role]}</TableCell>
<TableCell className="px-3 py-2">
<StatusBadge size="sm" status={member.status} map={STATUS_TONES} />
</TableCell>
<TableCell className="px-3 py-2">
<span className={cn("inline-flex items-center gap-1.5", !member.twoFactor && "text-muted-foreground")}>
{member.twoFactor ? (
<CheckIcon aria-hidden="true" className="size-3.5 text-success" />
) : (
<MinusIcon aria-hidden="true" className="size-3.5" />
)}
{member.twoFactor ? "On" : "Off"}
</span>
</TableCell>
<TableCell className="px-3 py-2 text-right text-muted-foreground tabular-nums">
<time dateTime={member.lastActiveAt.toISOString()}>{idle(member.idleDays)}</time>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
{failure || refused.length > 0 ? (
<div className="border-t border-rule p-3">
<Callout
variant="danger"
role="alert"
title={failure ? "Nothing was changed" : refused.length === 1 ? "One change was refused" : `${refused.length} changes were refused`}
>
{failure ? (
failure
) : (
<ul className="flex flex-col gap-0.5">
{refused.map((entry) => (
<li key={entry.id}>
<span className="font-medium">{entry.name}</span> {entry.reason}
</li>
))}
</ul>
)}
</Callout>
</div>
) : null}
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-rule px-3 py-2.5">
<p role="status" aria-live="polite" className="text-sm text-muted-foreground tabular-nums">
{notice || `${people(members.length)} in the workspace`}
</p>
<div className="flex items-center gap-1.5">
<span className="me-1 text-sm text-muted-foreground tabular-nums">
Page {page + 1} of {pages}
</span>
<Button variant="outline" size="sm" disabled={page === 0} focusableWhenDisabled onClick={() => setPage(page - 1)}>
Previous
</Button>
<Button variant="outline" size="sm" disabled={page >= pages - 1} focusableWhenDisabled onClick={() => setPage(page + 1)}>
Next
</Button>
</div>
</div>
</>
)
}
/**
* The bulk actions, in the kit's floating selection bar. It sits outside the
* card, whose frame clips: sticky in the band, it floats over the rows while
* the table is in view and settles under it at the end. The roles it offers
* are the ones the server gives.
*/
export function MembersActions({ roles }: { roles: readonly Exclude<MemberRole, "owner">[] }) {
const { members, selected, pending, clear, run } = useSelection()
const anyDeactivated = members.some((member) => selected.has(member.id) && member.status === "deactivated")
return (
<SelectionActionBar count={selected.size} onClear={clear} itemLabel="member" className="mt-3 max-w-xl">
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="outline" size="sm" disabled={pending} focusableWhenDisabled>
Change role
<ChevronDownIcon data-icon="inline-end" aria-hidden="true" />
</Button>
}
/>
<DropdownMenuContent align="end" className="w-auto min-w-36">
<DropdownMenuGroup>
<DropdownMenuLabel>Make them</DropdownMenuLabel>
{roles.map((role) => (
<DropdownMenuItem key={role} onClick={() => void run({ kind: "role", role })}>
{ROLE_LABELS[role]}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
{anyDeactivated ? (
<Button variant="outline" size="sm" disabled={pending} focusableWhenDisabled onClick={() => void run({ kind: "reactivate" })}>
<UserCheckIcon data-icon="inline-start" aria-hidden="true" />
Reactivate
</Button>
) : null}
<Button variant="destructive" size="sm" disabled={pending} focusableWhenDisabled onClick={() => void run({ kind: "deactivate" })}>
<UserXIcon data-icon="inline-start" aria-hidden="true" />
Deactivate
</Button>
</SelectionActionBar>
)
}/**
* What `tables-05` reads and the one thing it writes: the workspace's people
* in `db.members`, and `changeMembers`, which the section's server action
* calls to change the role or the standing of several at once. "Now" is
* `REFERENCE_DATE`, so a last visit reads the same on the server and in the
* browser.
*
* Every read happens inside a function and runs on the server. The island
* takes the rows as props and calls the action it is handed; it never imports
* this module.
*/
import { REFERENCE_DATE, db, type Member, type Result } from "@/lib/sample-data"
export type { Result }
export type MemberRole = Member["role"]
export type MemberStatus = Member["status"]
/** One person as a row of the table. */
export type MemberRow = {
id: string
name: string
email: string
avatarUrl?: string
role: MemberRole
status: MemberStatus
twoFactor: boolean
lastActiveAt: Date
/** Whole days between the last visit and `REFERENCE_DATE`. */
idleDays: number
}
/** The roles the table gives. Ownership moves by a transfer, never by a role change. */
export const ASSIGNABLE_ROLES: readonly Exclude<MemberRole, "owner">[] = ["admin", "member", "viewer"]
/** What a bulk action asks for: a new role for everyone selected, or a change of standing. */
export type MemberChange =
| { kind: "role"; role: Exclude<MemberRole, "owner"> }
| { kind: "deactivate" }
| { kind: "reactivate" }
const KINDS: readonly unknown[] = ["role", "deactivate", "reactivate"] satisfies MemberChange["kind"][]
/** What came of it: the rows that changed, as they now read, and who was refused and why. */
export type MemberOutcome = {
changed: MemberRow[]
refused: { id: string; name: string; reason: string }[]
}
const DAY_MS = 86_400_000
function toRow(member: Member): MemberRow {
return {
id: member.id,
name: member.name,
email: member.email,
avatarUrl: member.avatarUrl,
role: member.role,
status: member.status,
twoFactor: member.twoFactor,
lastActiveAt: member.lastActiveAt,
idleDays: Math.max(0, Math.floor((REFERENCE_DATE.getTime() - member.lastActiveAt.getTime()) / DAY_MS)),
}
}
/** Everyone in the workspace, the owner first and the rest A to Z. */
export function memberRows(): MemberRow[] {
return db.members
.all()
.sort((a, b) => Number(b.role === "owner") - Number(a.role === "owner") || a.name.localeCompare(b.name))
.map(toRow)
}
/** Why the stored member cannot take the change, or undefined when it can. */
function refusal(member: Member, change: MemberChange): string | undefined {
if (member.role === "owner")
return change.kind === "role"
? "owns the workspace. Transfer ownership before changing their role."
: "owns the workspace, so they cannot be deactivated."
if (change.kind === "role" && member.status === "deactivated") return "is deactivated. Reactivate them before changing their role."
if (change.kind === "deactivate" && member.status === "invited") return "has not joined yet. Revoke the invitation instead."
if (change.kind === "deactivate" && member.status === "deactivated") return "is already deactivated."
if (change.kind === "reactivate" && member.status !== "deactivated") return "is not deactivated."
return undefined
}
/**
* Why a request is not one the table makes, or undefined when it is. The
* types are erased at the action boundary, so a crafted call can send any
* shape: only a list of ids, one of the three changes, and a role the table
* gives get through — never "owner", which would leave two owners.
*/
function malformed(ids: unknown, change: unknown): string | undefined {
if (!Array.isArray(ids) || !ids.every((id) => typeof id === "string")) return "The people to change must be a list of member ids."
if (typeof change !== "object" || change === null) return "A change must say what it does."
const { kind, role } = change as { kind?: unknown; role?: unknown }
if (!KINDS.includes(kind)) return `"${String(kind)}" is not a change this table makes.`
if (kind === "role" && !(ASSIGNABLE_ROLES as readonly unknown[]).includes(role)) {
return `"${String(role)}" is not a role this table gives: it gives admin, member or viewer, and ownership moves by a transfer.`
}
return undefined
}
/**
* Applies one change to everyone asked for, one stored row at a time: each
* row is re-read and the change is judged against it — the owner keeps their
* role and their seat, a deactivated member cannot take a role, and an
* invitation is revoked rather than deactivated. Who could take the change
* gets it; who could not is sent back by name with the reason. A request the
* table does not make at all is refused whole, and nothing is written.
*/
export async function changeMembers(ids: string[], change: MemberChange): Promise<Result<MemberOutcome>> {
const wrong = malformed(ids, change)
if (wrong) return { ok: false, error: { code: "invalid", message: wrong } }
if (ids.length === 0) return { ok: false, error: { code: "empty", message: "Select at least one person first." } }
const outcome: MemberOutcome = { changed: [], refused: [] }
for (const id of new Set(ids)) {
const member = await db.members.get(id)
if (!member) {
outcome.refused.push({ id, name: "Someone", reason: "is no longer in the workspace." })
continue
}
const reason = refusal(member, change)
if (reason) {
outcome.refused.push({ id, name: member.name, reason })
continue
}
const patch: Partial<Member> =
change.kind === "role" ? { role: change.role } : { status: change.kind === "deactivate" ? "deactivated" : "active" }
const updated = await db.members.update(id, patch)
if (updated.ok) outcome.changed.push(toRow(updated.data))
else outcome.refused.push({ id, name: member.name, reason: updated.error.message })
}
return { ok: true, data: outcome }
}npx shadcn@latest add @vibra/tables-05Scroll table with a sticky header and column
tables-06Every product's stock in a scroller of its own: the header stays on top and the product on the left while ten columns of figures move under them, sorted to put what runs out first at the top.
import * as React from "react"
import { formatCurrency } from "@/lib/format"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { StockTable } from "./tables-06.client"
import { RATE_DAYS, stockLevels } from "./tables-06.data"
/**
* Every product's stock in one scroller: the header and the product column
* stay put while the rows and the figures move under them, the runs-out-first
* order puts what needs buying at the top, and a toggle keeps only that.
*
* A server component: the rows come from `./tables-06.data`.
*/
export function Tables06() {
const titleId = React.useId()
const rows = stockLevels()
const value = rows.reduce((total, row) => total + row.valueCents, 0)
return (
<section
data-section="tables-06"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>Stock levels</h2>
</CardTitle>
<CardDescription className="tabular-nums">
{formatCurrency(value / 100, "USD", { maximumFractionDigits: 0 })} on the shelves at list price. Cover is the days the
stock lasts at the last {RATE_DAYS} days’ rate.
</CardDescription>
</CardHeader>
<CardContent className="p-0">
<StockTable rows={rows} titleId={titleId} rateDays={RATE_DAYS} />
</CardContent>
</Card>
</div>
</section>
)
}
export default Tables06"use client"
import * as React from "react"
import { ArrowDownIcon, ArrowUpIcon, ChevronsUpDownIcon, TriangleAlertIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { formatCurrency, formatNumber } from "@/lib/format"
import { Button } from "@/components/ui/button"
import { StatusBadge } from "@/components/ui/status-badge"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import type { StockRow } from "./tables-06.data"
type SortKey = "name" | "stock" | "sold" | "coverDays" | "valueCents"
type Sort = { key: SortKey; desc: boolean }
const SORT_WORDS = { ascending: "sorted ascending", descending: "sorted descending", none: "not sorted" } as const
const money = (cents: number) => formatCurrency(cents / 100, "USD", { maximumFractionDigits: 0 })
const price = (cents: number) => formatCurrency(cents / 100)
const needsReorder = (row: StockRow) => !row.archived && row.state !== "ok"
/** Archived rows last whatever the order; within the rest, the chosen column, then the name. */
function compare(sort: Sort) {
return (a: StockRow, b: StockRow) => {
const archived = Number(a.archived) - Number(b.archived)
if (archived) return archived
if (sort.key === "name") return (sort.desc ? -1 : 1) * a.name.localeCompare(b.name)
const [left, right] = [a[sort.key], b[sort.key]]
// A product that sold nothing has no cover to compare; it goes after the ones that do.
if (left === null || right === null) return left === right ? a.name.localeCompare(b.name) : left === null ? 1 : -1
return (sort.desc ? right - left : left - right) || a.name.localeCompare(b.name)
}
}
/** The sticky header cell of a sortable column. */
function SortHead({
label,
column,
sort,
onSort,
className,
}: {
label: string
column: SortKey
sort: Sort
onSort: (column: SortKey) => void
className?: string
}) {
const state = sort.key !== column ? "none" : sort.desc ? "descending" : "ascending"
const Icon = state === "none" ? ChevronsUpDownIcon : state === "descending" ? ArrowDownIcon : ArrowUpIcon
return (
<TableHead aria-sort={state} className={cn(HEAD, className)}>
<Button
variant="ghost"
size="sm"
aria-label={`${label}, ${SORT_WORDS[state]}`}
onClick={() => onSort(column)}
className={cn("text-sm font-medium text-foreground", column === "name" ? "-ms-2.5" : "-me-2.5")}
>
{label}
<Icon aria-hidden="true" className="size-3.5" />
</Button>
</TableHead>
)
}
// Every header cell sticks to the top of the scroller; the rule under it is
// an inset shadow, because a border would scroll away with its row.
const HEAD = "sticky top-0 z-20 h-10 bg-surface px-3 text-foreground shadow-[inset_0_-1px_0_0_var(--rule)]"
// The product column sticks to the left at every width, over an opaque plane.
const PINNED = "sticky left-0 after:absolute after:inset-y-0 after:right-0 after:w-px after:bg-border"
/**
* Stock levels in a scroller of its own: the header stays on top while the
* rows scroll under it, and the product stays on the left while the figures
* scroll past — the scroller takes focus, so arrow keys move it too. The
* header sorts; the toggle keeps only what needs reordering.
*/
export function StockTable({ rows, titleId, rateDays }: { rows: StockRow[]; titleId: string; rateDays: number }) {
const [sort, setSort] = React.useState<Sort>({ key: "coverDays", desc: false })
const [onlyReorder, setOnlyReorder] = React.useState(false)
const flagged = rows.filter(needsReorder)
const shown = (onlyReorder ? flagged : rows).slice().sort(compare(sort))
function onSort(key: SortKey) {
setSort((current) => (current.key === key ? { key, desc: !current.desc } : { key, desc: key === "valueCents" || key === "sold" }))
}
return (
<div data-slot="stock-table">
<div className="flex flex-wrap items-center gap-2 border-b border-rule p-3">
<Button
variant="outline"
size="sm"
aria-pressed={onlyReorder}
onClick={() => setOnlyReorder((current) => !current)}
className="aria-pressed:bg-brand-muted aria-pressed:font-medium"
>
<TriangleAlertIcon data-icon="inline-start" aria-hidden="true" />
Needs reorder
<span className="rounded-full bg-muted px-1.5 text-xs text-muted-foreground tabular-nums">{flagged.length}</span>
</Button>
</div>
<div
role="region"
aria-label="Stock levels, scrollable"
tabIndex={0}
// The outline is drawn inside the scroller: the sheet clips anything outside it.
className="max-h-[28rem] overflow-auto focus-ring-inset [&_[data-slot=table-container]]:overflow-visible"
>
<Table aria-labelledby={titleId}>
<TableHeader className="[&_tr]:border-0">
<TableRow className="hover:bg-transparent">
<SortHead label="Product" column="name" sort={sort} onSort={onSort} className={cn(PINNED, "z-30")} />
<TableHead className={HEAD}>Category</TableHead>
<TableHead className={HEAD}>Supplier</TableHead>
<SortHead label="On hand" column="stock" sort={sort} onSort={onSort} className="text-right" />
<TableHead className={cn(HEAD, "text-right")}>Reorder at</TableHead>
<SortHead label={`Sold, ${rateDays} days`} column="sold" sort={sort} onSort={onSort} className="text-right" />
<SortHead label="Cover" column="coverDays" sort={sort} onSort={onSort} className="text-right" />
<TableHead className={cn(HEAD, "text-right")}>Unit price</TableHead>
<SortHead label="Stock value" column="valueCents" sort={sort} onSort={onSort} className="text-right" />
<TableHead className={HEAD}>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{shown.map((row) => (
<TableRow key={row.id} className={cn("group/row", row.archived && "text-muted-foreground")}>
<TableCell className={cn(PINNED, "z-10 bg-card px-3 py-2 group-hover/row:bg-[color-mix(in_oklab,var(--muted)_50%,var(--card))]")}>
<span className="flex flex-col leading-tight">
<span className={cn("font-medium", !row.archived && "text-foreground")}>{row.name}</span>
<span className="font-mono text-xs text-muted-foreground">{row.sku}</span>
</span>
</TableCell>
<TableCell className="px-3 py-2">{row.category}</TableCell>
<TableCell className="px-3 py-2 text-muted-foreground">{row.supplier}</TableCell>
<TableCell className={cn("px-3 py-2 text-right tabular-nums", needsReorder(row) && "font-medium text-warning")}>
{formatNumber(row.stock)}
</TableCell>
<TableCell className="px-3 py-2 text-right text-muted-foreground tabular-nums">{formatNumber(row.reorderPoint)}</TableCell>
<TableCell className="px-3 py-2 text-right tabular-nums">{formatNumber(row.sold)}</TableCell>
<TableCell className="px-3 py-2 text-right tabular-nums">
{row.coverDays === null ? <span className="text-muted-foreground">No sales</span> : `${formatNumber(row.coverDays)} days`}
</TableCell>
<TableCell className="px-3 py-2 text-right tabular-nums">{price(row.priceCents)}</TableCell>
<TableCell className="px-3 py-2 text-right tabular-nums">{money(row.valueCents)}</TableCell>
<TableCell className="px-3 py-2">
{row.archived ? (
<StatusBadge size="sm" status="archived" label="Discontinued" />
) : row.state === "out" ? (
<StatusBadge size="sm" status="out" variant="danger" label="Out of stock" />
) : row.state === "low" ? (
<StatusBadge size="sm" status="low" variant="warning" label="Reorder" />
) : (
<span className="text-muted-foreground">In stock</span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="border-t border-rule px-3 py-2.5">
<p role="status" aria-live="polite" className="text-sm text-muted-foreground tabular-nums">
{onlyReorder ? `${shown.length} of ${rows.length} products need reordering` : `${rows.length} products · scroll the table for all of them`}
</p>
</div>
</div>
)
}/**
* What `tables-06` reads: every product in `db.products` with what is on the
* shelf, what it sold over the last 30 days of paid orders, and how many days
* that stock covers at the rate it is selling. "Now" is `REFERENCE_DATE`.
*
* Selectors only, reading `db` inside functions and run on the server. The
* island takes these rows as props; it never imports this module.
*/
import { REFERENCE_DATE, db, type Order, type Product } from "@/lib/sample-data"
/** The window the selling rate is measured over, in days. */
export const RATE_DAYS = 30
const DAY_MS = 86_400_000
const SOLD: readonly Order["status"][] = ["paid", "fulfilled"]
/** Where a product stands against its reorder point. */
export type StockState = "out" | "low" | "ok"
export type StockRow = {
id: string
name: string
sku: string
category: string
supplier: string
archived: boolean
stock: number
reorderPoint: number
/** Units sold in paid orders over the last `RATE_DAYS` days. */
sold: number
/** Days the stock lasts at that rate; null when nothing sold. */
coverDays: number | null
priceCents: number
/** What the stock on the shelf is worth at list price, in cents. */
valueCents: number
state: StockState
}
function stateOf(product: Product): StockState {
if (product.stock <= 0) return "out"
return product.stock <= product.reorderPoint ? "low" : "ok"
}
/**
* Every product, the one that runs out soonest first; a product that sold
* nothing comes after the ones that did, and the archived ones — no longer
* restocked — come last.
*/
export function stockLevels(): StockRow[] {
const since = REFERENCE_DATE.getTime() - RATE_DAYS * DAY_MS
const sold = new Map<string, number>()
for (const order of db.orders.all()) {
const at = order.placedAt.getTime()
if (!SOLD.includes(order.status) || at < since || at > REFERENCE_DATE.getTime()) continue
for (const item of order.items) sold.set(item.productId, (sold.get(item.productId) ?? 0) + item.qty)
}
return db.products
.all()
.map((product) => {
const units = sold.get(product.id) ?? 0
return {
id: product.id,
name: product.name,
sku: product.sku,
category: product.category,
supplier: product.supplier,
archived: product.status === "archived",
stock: product.stock,
reorderPoint: product.reorderPoint,
sold: units,
coverDays: units > 0 ? Math.floor(product.stock / (units / RATE_DAYS)) : null,
priceCents: product.priceCents,
valueCents: product.stock * product.priceCents,
state: stateOf(product),
}
})
.sort(
(a, b) =>
Number(a.archived) - Number(b.archived) ||
(a.coverDays ?? Infinity) - (b.coverDays ?? Infinity) ||
a.name.localeCompare(b.name)
)
}npx shadcn@latest add @vibra/tables-06Directory with a Columns menu
tables-07The company directory with every column but the name in the reader's hands: a Columns menu of checkboxes that stays open while it is used, Show all and Reset, with the table, the sort and the pager following.
import * as React from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { DirectoryTable } from "./tables-07.client"
import { directory } from "./tables-07.data"
/**
* The company directory with the columns in the reader's hands: a Columns
* menu shows and hides everything but the name, and the table, the sort and
* the pager follow.
*
* A server component: the people come from `./tables-07.data`.
*/
export function Tables07() {
const titleId = React.useId()
return (
<section
data-section="tables-07"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>Directory</h2>
</CardTitle>
<CardDescription>Everyone at Northwind. Choose the columns you need from the Columns menu.</CardDescription>
</CardHeader>
<CardContent className="p-0">
<DirectoryTable people={directory()} titleId={titleId} />
</CardContent>
</Card>
</div>
</section>
)
}
export default Tables07"use client"
import * as React from "react"
import { ArrowDownIcon, ArrowUpIcon, ChevronsUpDownIcon, Columns3Icon } from "lucide-react"
import { cn } from "@/lib/utils"
import { formatNumber } from "@/lib/format"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { StatusBadge } from "@/components/ui/status-badge"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { UserCell } from "@/components/ui/user-cell"
import type { Person } from "./tables-07.data"
export const PAGE_SIZE = 10
const DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" })
const STATUS = {
active: { label: "In office", variant: "success" },
remote: { label: "Remote", variant: "info" },
on_leave: { label: "On leave", variant: "warning" },
} as const
type Column = {
id: string
label: string
align?: "right"
/** Shown until the reader hides it. */
shown: boolean
render: (person: Person) => React.ReactNode
/** What the column sorts by, when it sorts. */
sortBy?: (person: Person) => number
}
/** The columns the menu can show or hide, in the table's order. The name is always there. */
export const COLUMNS: Column[] = [
{ id: "department", label: "Department", shown: true, render: (person) => person.department },
{ id: "role", label: "Role", shown: true, render: (person) => <span className="text-muted-foreground">{person.role}</span> },
{ id: "type", label: "Contract", shown: false, render: (person) => (person.type === "fulltime" ? "Full-time" : "Contractor") },
{
id: "status",
label: "Where",
shown: true,
render: (person) => <StatusBadge size="sm" status={person.status} variant={STATUS[person.status].variant} label={STATUS[person.status].label} />,
},
{
id: "started",
label: "Started",
align: "right",
shown: true,
render: (person) => <time dateTime={person.startedAt.toISOString()}>{DAY.format(person.startedAt)}</time>,
sortBy: (person) => person.startedAt.getTime(),
},
{
id: "tenure",
label: "Tenure",
align: "right",
shown: false,
render: (person) => `${formatNumber(person.tenureYears, { minimumFractionDigits: 1, maximumFractionDigits: 1 })} yr`,
sortBy: (person) => person.tenureYears,
},
{
id: "attendance",
label: "Attendance",
align: "right",
shown: true,
// One decimal on every row, so the column lines up on its point.
render: (person) => `${formatNumber(person.attendanceRate * 100, { minimumFractionDigits: 1, maximumFractionDigits: 1 })}%`,
sortBy: (person) => person.attendanceRate,
},
{
id: "score",
label: "Review score",
align: "right",
shown: false,
render: (person) => formatNumber(person.score, { minimumFractionDigits: 1, maximumFractionDigits: 1 }),
sortBy: (person) => person.score,
},
]
const DEFAULT_SHOWN = COLUMNS.filter((column) => column.shown).map((column) => column.id)
const SORT_WORDS = { ascending: "sorted ascending", descending: "sorted descending", none: "not sorted" } as const
const HEAD = "h-10 bg-surface px-3 text-foreground"
const PINNED =
"max-md:sticky max-md:left-0 max-md:z-10 max-md:after:absolute max-md:after:inset-y-0 max-md:after:right-0 max-md:after:w-px max-md:after:bg-border"
type Sort = { id: string; desc: boolean }
function SortButton({ label, column, sort, onSort }: { label: string; column: string; sort: Sort; onSort: (column: string) => void }) {
const state = sort.id !== column ? "none" : sort.desc ? "descending" : "ascending"
const Icon = state === "none" ? ChevronsUpDownIcon : state === "descending" ? ArrowDownIcon : ArrowUpIcon
return (
<Button
variant="ghost"
size="sm"
aria-label={`${label}, ${SORT_WORDS[state]}`}
onClick={() => onSort(column)}
className={cn("text-sm font-medium text-foreground", column === "name" ? "-ms-2.5" : "-me-2.5")}
>
{label}
<Icon aria-hidden="true" className="size-3.5" />
</Button>
)
}
const ariaSort = (sort: Sort, id: string) => (sort.id !== id ? "none" : sort.desc ? "descending" : "ascending")
/**
* The directory with a Columns menu: a checkbox per column that stays open
* while it is used, Show all and Reset. The trigger says how many columns are
* on show, and so does a polite line when that changes. Sorting by a column
* and then hiding it hands the order back to the name.
*/
export function DirectoryTable({ people, titleId }: { people: Person[]; titleId: string }) {
const [shown, setShown] = React.useState<readonly string[]>(DEFAULT_SHOWN)
const [sort, setSort] = React.useState<Sort>({ id: "name", desc: false })
const [page, setPage] = React.useState(0)
const columns = COLUMNS.filter((column) => shown.includes(column.id))
// A hidden column cannot order the rows the reader sees.
const active = sort.id === "name" || columns.some((column) => column.id === sort.id) ? sort : { id: "name", desc: false }
const key = COLUMNS.find((column) => column.id === active.id)?.sortBy
const sorted = [...people].sort((a, b) => {
const order = key ? key(a) - key(b) : a.name.localeCompare(b.name)
return (active.desc ? -order : order) || a.name.localeCompare(b.name)
})
const pages = Math.max(1, Math.ceil(sorted.length / PAGE_SIZE))
const rows = sorted.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE)
function toggle(id: string, on: boolean) {
setShown((current) => COLUMNS.map((column) => column.id).filter((entry) => (entry === id ? on : current.includes(entry))))
}
function onSort(id: string) {
setSort((current) => (current.id === id ? { id, desc: !current.desc } : { id, desc: id !== "name" }))
setPage(0)
}
return (
<div data-slot="directory-table">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-rule p-3">
<p role="status" aria-live="polite" className="text-xs text-muted-foreground tabular-nums">
{formatNumber(people.length)} people · {columns.length} of {COLUMNS.length} columns shown
</p>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="outline" size="sm">
<Columns3Icon data-icon="inline-start" aria-hidden="true" />
Columns
<span className="rounded-full bg-muted px-1.5 text-xs text-muted-foreground tabular-nums">
{columns.length}/{COLUMNS.length}
</span>
</Button>
}
/>
<DropdownMenuContent align="end" className="w-auto min-w-48">
<DropdownMenuGroup>
<DropdownMenuLabel>Show columns</DropdownMenuLabel>
{COLUMNS.map((column) => (
<DropdownMenuCheckboxItem
key={column.id}
checked={shown.includes(column.id)}
onCheckedChange={(checked) => toggle(column.id, checked)}
closeOnClick={false}
>
{column.label}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem disabled={shown.length === COLUMNS.length} onClick={() => setShown(COLUMNS.map((column) => column.id))}>
Show all
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setShown(DEFAULT_SHOWN)}>Reset to default</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<Table aria-labelledby={titleId}>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead aria-sort={ariaSort(active, "name")} className={cn(HEAD, PINNED)}>
<SortButton label="Name" column="name" sort={active} onSort={onSort} />
</TableHead>
{columns.map((column) => (
<TableHead
key={column.id}
aria-sort={column.sortBy ? ariaSort(active, column.id) : undefined}
className={cn(HEAD, column.align === "right" && "text-right")}
>
{column.sortBy ? <SortButton label={column.label} column={column.id} sort={active} onSort={onSort} /> : column.label}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{rows.map((person) => (
<TableRow key={person.id} className="group/row">
<TableCell className={cn(PINNED, "bg-card px-3 py-2 group-hover/row:bg-[color-mix(in_oklab,var(--muted)_50%,var(--card))]")}>
<UserCell size="sm" name={person.name} email={person.email} src={person.avatarUrl} className="max-w-48 sm:max-w-none" />
</TableCell>
{columns.map((column) => (
<TableCell key={column.id} className={cn("px-3 py-2", column.align === "right" && "text-right tabular-nums")}>
{column.render(person)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
<div className="flex items-center justify-end gap-1.5 border-t border-rule px-3 py-2.5">
<span aria-live="polite" className="me-1 text-sm text-muted-foreground tabular-nums">
Page {page + 1} of {pages}
</span>
<Button variant="outline" size="sm" disabled={page === 0} focusableWhenDisabled onClick={() => setPage(page - 1)}>
Previous
</Button>
<Button variant="outline" size="sm" disabled={page >= pages - 1} focusableWhenDisabled onClick={() => setPage(page + 1)}>
Next
</Button>
</div>
</div>
)
}/**
* What `tables-07` reads: everyone in `db.employees`, with how long each has
* been with the company counted from `REFERENCE_DATE`.
*
* Selectors only, reading `db` inside functions and run on the server. The
* island takes these rows as props; it never imports this module.
*/
import { REFERENCE_DATE, db, type Employee } from "@/lib/sample-data"
const YEAR_MS = 365.25 * 86_400_000
export type Person = {
id: string
name: string
email: string
avatarUrl?: string
department: Employee["department"]
role: string
type: Employee["type"]
status: Employee["status"]
startedAt: Date
/** Years since they started, to one decimal. */
tenureYears: number
/** The share of working days they were in over the last quarter, 0–1. */
attendanceRate: number
/** The last review's score, 0–10. */
score: number
}
/** Everyone, A to Z by name. */
export function directory(): Person[] {
return db.employees
.all()
.sort((a, b) => a.name.localeCompare(b.name))
.map((employee) => ({
id: employee.id,
name: employee.name,
email: employee.email,
avatarUrl: employee.avatarUrl,
department: employee.department,
role: employee.role,
type: employee.type,
status: employee.status,
startedAt: employee.startedAt,
tenureYears: Math.floor(((REFERENCE_DATE.getTime() - employee.startedAt.getTime()) / YEAR_MS) * 10) / 10,
attendanceRate: employee.attendanceRate,
score: employee.kpi,
}))
}npx shadcn@latest add @vibra/tables-07Pivot of sales by department and month
tables-08Departments down the side and the last six whole months across, with row and column totals, a switch between revenue, units and orders, and every cell shaded by its size.
import * as React from "react"
import { formatCurrency } from "@/lib/format"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { PivotTable } from "./tables-08.client"
import { revenueMatrix } from "./tables-08.data"
/**
* Sales as a matrix: departments by month over the last six whole months,
* with row and column totals, a switch between revenue, units and orders,
* and cells shaded by their size.
*
* A server component: the matrix comes from `./tables-08.data`; its
* description names the span and the table's description points at it.
*/
export function Tables08() {
const titleId = React.useId()
const descriptionId = React.useId()
const matrix = revenueMatrix()
const span = `${matrix.months[0].name.split(" ")[0]} to ${matrix.months.at(-1)!.name}`
return (
<section
data-section="tables-08"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>Sales by department</h2>
</CardTitle>
<CardDescription id={descriptionId} className="tabular-nums">
{span}, paid orders · {formatCurrency(matrix.total.revenueCents / 100, "USD", { maximumFractionDigits: 0 })} in all.
Each cell is shaded by its size.
</CardDescription>
</CardHeader>
<CardContent className="p-0">
<PivotTable matrix={matrix} titleId={titleId} describedBy={descriptionId} />
</CardContent>
</Card>
</div>
</section>
)
}
export default Tables08"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { formatCurrency, formatNumber } from "@/lib/format"
import { SegmentedControl } from "@/components/ui/segmented-control"
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import type { Figures, Matrix } from "./tables-08.data"
type Measure = keyof Figures
export const MEASURES: { value: Measure; label: string; noun: string }[] = [
{ value: "revenueCents", label: "Revenue", noun: "revenue in dollars" },
{ value: "units", label: "Units", noun: "units sold" },
{ value: "orders", label: "Orders", noun: "orders placed" },
]
const show = (measure: Measure, value: number) =>
measure === "revenueCents" ? formatCurrency(value / 100, "USD", { maximumFractionDigits: 0 }) : formatNumber(value)
// One measure is one series, and a one-series chart is grey: the shading runs
// from a trace of the first chart slot to two fifths of it over the card, so
// the figure on top keeps its contrast in both modes and in every palette.
const shade = (share: number) => `color-mix(in oklab, var(--chart-1) ${Math.round(6 + share * 34)}%, var(--card))`
const HEAD = "h-10 bg-surface px-3 text-foreground"
const PINNED =
"max-md:sticky max-md:left-0 max-md:z-10 max-md:after:absolute max-md:after:inset-y-0 max-md:after:right-0 max-md:after:w-px max-md:after:bg-border"
/**
* Departments down the side, months across, a total at the end of each row
* and each column. A segmented control switches what every cell counts;
* each cell is shaded by its size against the largest, so the busiest month
* of a department stands out without a chart, and the number stays the
* thing that is read. Row and column headers are real headers, so a screen
* reader names both for every figure.
*/
export function PivotTable({ matrix, titleId, describedBy }: { matrix: Matrix; titleId: string; describedBy: string }) {
const [measure, setMeasure] = React.useState<Measure>("revenueCents")
const noun = MEASURES.find((entry) => entry.value === measure)!.noun
const largest = Math.max(1, ...matrix.rows.flatMap((row) => row.cells.map((cell) => cell[measure])))
return (
<div data-slot="pivot-table">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-rule p-3">
<SegmentedControl
aria-label="What each cell counts"
size="sm"
options={MEASURES.map(({ value, label }) => ({ value, label }))}
value={measure}
onValueChange={(value) => setMeasure(value as Measure)}
/>
<p role="status" aria-live="polite" className="text-xs text-muted-foreground">
Showing {noun}
</p>
</div>
<Table aria-labelledby={titleId} aria-describedby={describedBy}>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead scope="col" className={cn(HEAD, PINNED)}>
Department
</TableHead>
{matrix.months.map((month) => (
<TableHead key={month.key} scope="col" className={cn(HEAD, "text-right")}>
<span aria-hidden="true">{month.label}</span>
<span className="sr-only">{month.name}</span>
</TableHead>
))}
<TableHead scope="col" className={cn(HEAD, "text-right")}>
Total
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{matrix.rows.map((row) => (
<TableRow key={row.department} className="hover:bg-transparent">
<th scope="row" className={cn(PINNED, "bg-card px-3 py-2 text-left font-medium")}>
{row.department}
</th>
{row.cells.map((cell, index) => (
<TableCell
key={matrix.months[index].key}
data-share={Math.round((cell[measure] / largest) * 100)}
className="px-3 py-2 text-right tabular-nums"
style={{ backgroundColor: shade(cell[measure] / largest) }}
>
{show(measure, cell[measure])}
</TableCell>
))}
<TableCell className="bg-surface px-3 py-2 text-right font-semibold tabular-nums">{show(measure, row.total[measure])}</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter className="bg-surface">
<TableRow className="hover:bg-transparent">
<th scope="row" className={cn(PINNED, "bg-surface px-3 py-2 text-left font-semibold")}>
Total
</th>
{matrix.totals.map((figures, index) => (
<TableCell key={matrix.months[index].key} className="px-3 py-2 text-right font-semibold tabular-nums">
{show(measure, figures[measure])}
</TableCell>
))}
<TableCell className="px-3 py-2 text-right font-semibold tabular-nums">{show(measure, matrix.total[measure])}</TableCell>
</TableRow>
</TableFooter>
</Table>
</div>
)
}/**
* What `tables-08` reads: the paid orders in `db.orders` over the last six
* whole months before `REFERENCE_DATE`, cut by the department of each line's
* product (`db.products`) and the month the order was placed in — revenue,
* units and orders, so the matrix can show any of the three.
*
* Selectors only, reading `db` inside functions and run on the server. The
* island takes the matrix as props; it never imports this module.
*/
import { REFERENCE_DATE, db, type Order } from "@/lib/sample-data"
/** How many whole months the matrix covers. */
export const MONTHS = 6
const SOLD: readonly Order["status"][] = ["paid", "fulfilled"]
/** One cell: what a department sold in a month. */
export type Figures = { revenueCents: number; units: number; orders: number }
export type MatrixRow = { department: string; cells: Figures[]; total: Figures }
export type Matrix = {
/** The months, oldest first, as "2026-03" keys, "Mar" labels and "March 2026" names. */
months: { key: string; label: string; name: string }[]
/** The departments, the one that sold most first. */
rows: MatrixRow[]
/** Each month across the departments, then the whole span. */
totals: Figures[]
total: Figures
}
const MONTH = new Intl.DateTimeFormat("en-US", { month: "short", timeZone: "UTC" })
const MONTH_NAME = new Intl.DateTimeFormat("en-US", { month: "long", year: "numeric", timeZone: "UTC" })
const empty = (): Figures => ({ revenueCents: 0, units: 0, orders: 0 })
/**
* Revenue, units and orders by department and month. An order counts once in
* every department it bought from, so a month's order total is the orders
* placed that month, not the sum down the column.
*/
export function revenueMatrix(): Matrix {
const end = Date.UTC(REFERENCE_DATE.getUTCFullYear(), REFERENCE_DATE.getUTCMonth(), 1)
const months = Array.from({ length: MONTHS }, (_, index) => {
const start = new Date(Date.UTC(REFERENCE_DATE.getUTCFullYear(), REFERENCE_DATE.getUTCMonth() - MONTHS + index, 1))
return { key: start.toISOString().slice(0, 7), label: MONTH.format(start), name: MONTH_NAME.format(start) }
})
const first = Date.parse(`${months[0].key}-01T00:00:00Z`)
const departments = new Map(db.products.all().map((product) => [product.id, product.category]))
const cells = new Map<string, Figures[]>()
const totals = months.map(empty)
const total = empty()
for (const order of db.orders.all()) {
const at = order.placedAt.getTime()
if (!SOLD.includes(order.status) || at < first || at >= end) continue
const column = months.findIndex((month) => month.key === order.placedAt.toISOString().slice(0, 7))
const seen = new Set<string>()
for (const item of order.items) {
const department = departments.get(item.productId) ?? "Other"
const row = cells.get(department) ?? months.map(empty)
cells.set(department, row)
const revenue = item.qty * item.unitCents
for (const figures of [row[column], totals[column], total]) {
figures.revenueCents += revenue
figures.units += item.qty
}
if (!seen.has(department)) row[column].orders += 1
seen.add(department)
}
totals[column].orders += 1
total.orders += 1
}
const rows = [...cells.entries()].map(([department, row]) => ({
department,
cells: row,
total: row.reduce(
(sum, figures) => ({
revenueCents: sum.revenueCents + figures.revenueCents,
units: sum.units + figures.units,
orders: sum.orders + figures.orders,
}),
empty()
),
}))
rows.sort((a, b) => b.total.revenueCents - a.total.revenueCents)
return { months, rows, totals, total }
}npx shadcn@latest add @vibra/tables-08Activity log paged by the server
tables-09The workspace's audit trail twelve entries at a time, grouped under the days they happened: every filter, search, order and page is a query answered by a server action, and the newest question always wins.
import * as React from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { ActivityLog } from "./tables-09.client"
import { DEFAULT_ACTIVITY, activityActions, activityPage, type ActivityPage, type ActivityQuery } from "./tables-09.data"
/**
* The workspace's activity log, paged by the server: the first page is read
* here, and every page after it comes back through `fetchActivity`, a server
* function the island calls with the query the reader has built — the
* action, the search, the order and the page — grouped by day as it arrives.
*/
export function Tables09() {
const titleId = React.useId()
async function fetchActivity(query: ActivityQuery): Promise<ActivityPage> {
"use server"
return activityPage(query)
}
return (
<section
data-section="tables-09"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>Activity</h2>
</CardTitle>
<CardDescription>Who changed what in the workspace, and from where. Times are UTC.</CardDescription>
</CardHeader>
<CardContent className="p-0">
<ActivityLog
initial={activityPage(DEFAULT_ACTIVITY)}
initialQuery={DEFAULT_ACTIVITY}
actions={activityActions()}
fetchPage={fetchActivity}
titleId={titleId}
/>
</CardContent>
</Card>
</div>
</section>
)
}
export default Tables09"use client"
import * as React from "react"
import { ArrowDownIcon, ArrowUpIcon, SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { formatNumber } from "@/lib/format"
import { Button } from "@/components/ui/button"
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group"
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"
import { Spinner } from "@/components/ui/spinner"
import { StatusBadge } from "@/components/ui/status-badge"
import { Table, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { UserCell } from "@/components/ui/user-cell"
import type { ActivityPage, ActivityQuery, ActivityRow } from "./tables-09.data"
const TONES = {
created: "success",
enabled: "success",
updated: "info",
invited: "primary",
revoked: "warning",
deleted: "danger",
disabled: "neutral",
exported: "neutral",
} as const
const DAY = new Intl.DateTimeFormat("en-US", { weekday: "long", month: "long", day: "numeric", year: "numeric", timeZone: "UTC" })
const TIME = new Intl.DateTimeFormat("en-US", { hour: "2-digit", minute: "2-digit", hourCycle: "h23", timeZone: "UTC" })
const capital = (word: string) => word.charAt(0).toUpperCase() + word.slice(1)
const HEAD = "h-10 bg-surface px-3 text-foreground"
const PINNED =
"max-md:sticky max-md:start-0 max-md:z-10 max-md:after:absolute max-md:after:inset-y-0 max-md:after:end-0 max-md:after:w-px max-md:after:bg-border"
/** A page's rows cut into the days they happened on, in the page's own order. */
function byDay(rows: ActivityRow[]): { day: string; label: string; rows: ActivityRow[] }[] {
const days: { day: string; label: string; rows: ActivityRow[] }[] = []
for (const row of rows) {
const day = row.at.toISOString().slice(0, 10)
const last = days.at(-1)
if (last?.day === day) last.rows.push(row)
else days.push({ day, label: DAY.format(row.at), rows: [row] })
}
return days
}
/**
* The audit trail, paged by the server. Every control — the action filter,
* the search box, the order and the pager — rewrites one query and asks the
* section's server action for the page it describes; nothing is filtered in
* the browser. The query lives in a ref as well as in state, so a second
* change in the same tick builds on the first, and a page that answers a
* question the reader has since moved past is dropped rather than shown: the
* newest query wins, whatever order the answers land in. While a page is on
* its way the table is marked busy and a spinner shows beside the count.
*/
export function ActivityLog({
initial,
initialQuery,
actions,
fetchPage,
titleId,
}: {
initial: ActivityPage
initialQuery: ActivityQuery
actions: { action: string; count: number }[]
fetchPage: (query: ActivityQuery) => Promise<ActivityPage>
titleId: string
}) {
const [query, setQuery] = React.useState(initialQuery)
const [page, setPage] = React.useState(initial)
const [pending, setPending] = React.useState(false)
// The query the reader most recently asked for; an answer to any other is dropped.
const latest = React.useRef(initialQuery)
function apply(patch: Partial<ActivityQuery>) {
const next = { ...latest.current, ...patch, page: patch.page ?? 1 }
latest.current = next
setQuery(next)
setPending(true)
void fetchPage(next).then((fresh) => {
if (latest.current !== next) return
setPage(fresh)
setPending(false)
})
}
const total = actions.reduce((sum, entry) => sum + entry.count, 0)
const first = page.total === 0 ? 0 : (page.page - 1) * page.pageSize + 1
const last = Math.min(page.page * page.pageSize, page.total)
const filtered = query.action !== "all" || query.search !== ""
return (
<div data-slot="activity-log">
<div className="flex flex-wrap items-center gap-2 border-b border-rule p-3">
<InputGroup className="w-full sm:w-64">
<InputGroupAddon>
<SearchIcon aria-hidden="true" />
</InputGroupAddon>
<InputGroupInput
type="search"
value={query.search}
onChange={(event) => apply({ search: event.target.value })}
placeholder="Person, record or address"
aria-label="Search activity"
/>
</InputGroup>
<NativeSelect
aria-label="Action"
value={query.action}
onChange={(event) => apply({ action: event.target.value })}
className="w-full sm:w-auto"
>
<NativeSelectOption value="all">All actions ({formatNumber(total)})</NativeSelectOption>
{actions.map((entry) => (
<NativeSelectOption key={entry.action} value={entry.action}>
{capital(entry.action)} ({formatNumber(entry.count)})
</NativeSelectOption>
))}
</NativeSelect>
{filtered ? (
<Button variant="ghost" size="sm" onClick={() => apply({ search: "", action: "all" })}>
Clear
</Button>
) : null}
</div>
<Table aria-labelledby={titleId} aria-busy={pending || undefined} className={cn("transition-opacity", pending && "opacity-60")}>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead aria-sort={query.newestFirst ? "descending" : "ascending"} className={cn(HEAD, PINNED)}>
<Button
variant="ghost"
size="sm"
aria-label={`Time, ${query.newestFirst ? "newest first" : "oldest first"}`}
onClick={() => apply({ newestFirst: !latest.current.newestFirst })}
className="-ms-2.5 text-sm font-medium text-foreground"
>
Time
{query.newestFirst ? <ArrowDownIcon aria-hidden="true" className="size-3.5" /> : <ArrowUpIcon aria-hidden="true" className="size-3.5" />}
</Button>
</TableHead>
<TableHead className={HEAD}>Who</TableHead>
<TableHead className={HEAD}>Action</TableHead>
<TableHead className={HEAD}>Record</TableHead>
<TableHead className={cn(HEAD, "text-right")}>From</TableHead>
</TableRow>
</TableHeader>
{page.rows.length === 0 ? (
<tbody data-slot="table-body">
<TableRow className="hover:bg-transparent">
<TableCell colSpan={5} className="px-3 py-8 text-center">
<p className="text-sm text-muted-foreground">No activity matches these filters.</p>
<Button variant="link" size="sm" onClick={() => apply({ search: "", action: "all" })}>
Clear the filters
</Button>
</TableCell>
</TableRow>
</tbody>
) : (
byDay(page.rows).map((group) => (
<tbody key={group.day} data-slot="table-body">
<TableRow className="bg-surface hover:bg-surface">
<th scope="rowgroup" colSpan={5} className="px-3 py-1.5 text-left text-xs font-medium text-muted-foreground">
<span className="sticky start-3">{group.label}</span>
</th>
</TableRow>
{group.rows.map((row) => (
<TableRow key={row.id} className="group/row">
<TableCell
className={cn(PINNED, "bg-card px-3 py-2 text-muted-foreground tabular-nums group-hover/row:bg-[color-mix(in_oklab,var(--muted)_50%,var(--card))]")}
>
<time dateTime={row.at.toISOString()}>{TIME.format(row.at)}</time>
</TableCell>
<TableCell className="px-3 py-2">
<UserCell size="sm" name={row.actor.name} src={row.actor.avatarUrl} />
</TableCell>
<TableCell className="px-3 py-2">
<StatusBadge size="sm" status={row.action} variant={TONES[row.action as keyof typeof TONES] ?? "neutral"} label={capital(row.action)} />
</TableCell>
<TableCell className="px-3 py-2">
{row.resource} <span className="font-mono text-xs text-muted-foreground">{row.resourceId}</span>
</TableCell>
<TableCell className="px-3 py-2 text-right font-mono text-xs text-muted-foreground">{row.ip}</TableCell>
</TableRow>
))}
</tbody>
))
)}
</Table>
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-rule px-3 py-2.5">
<div className="flex items-center gap-2">
<p role="status" aria-live="polite" className="text-sm text-muted-foreground tabular-nums">
{page.total === 0 ? "No entries" : `Entries ${formatNumber(first)}–${formatNumber(last)} of ${formatNumber(page.total)}`}
{pending ? <span className="sr-only">, updating</span> : null}
</p>
{pending ? <Spinner aria-hidden="true" className="size-3.5 text-muted-foreground" /> : null}
</div>
<div className="flex items-center gap-1.5">
<Button
variant="outline"
size="sm"
disabled={page.page <= 1}
focusableWhenDisabled
onClick={() => apply({ page: latest.current.page - 1 })}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
disabled={page.page >= page.pageCount}
focusableWhenDisabled
onClick={() => apply({ page: latest.current.page + 1 })}
>
Next
</Button>
</div>
</div>
</div>
)
}/**
* What `tables-09` reads: the workspace's audit trail in `db.auditEvents`,
* one page at a time — filtered by what was done, searched by who did it and
* to what, newest or oldest first — with each actor resolved against
* `db.members`.
*
* `activityPage` runs on the server, first for the page the section opens on
* and then behind the section's server action for every page after it. The
* island takes pages as props and asks the action for the next; it never
* imports this module.
*/
import { db, tableQuery, type AuditEvent } from "@/lib/sample-data"
export type ActivityAction = string
export type ActivityQuery = {
page: number
pageSize: number
/** One action, or every one. */
action: ActivityAction | "all"
search: string
newestFirst: boolean
}
export const DEFAULT_ACTIVITY: ActivityQuery = { page: 1, pageSize: 12, action: "all", search: "", newestFirst: true }
export type ActivityRow = {
id: string
at: Date
actor: { name: string; email: string; avatarUrl?: string }
action: ActivityAction
/** What kind of thing it was done to, as a reader says it. */
resource: string
resourceId: string
ip: string
}
export type ActivityPage = { rows: ActivityRow[]; total: number; page: number; pageSize: number; pageCount: number }
/** The kinds of record the trail names, as a reader says them. */
const RESOURCES: Record<string, string> = {
api_key: "API key",
alert: "Alert rule",
billing: "Billing",
customer: "Customer",
deployment: "Deployment",
integration: "Integration",
invitation: "Invitation",
member: "Member",
role: "Role",
service: "Service",
}
const resourceOf = (event: AuditEvent) => RESOURCES[event.resource] ?? event.resource.replaceAll("_", " ")
/** Every action the trail records, with how many times, the most frequent first. */
export function activityActions(): { action: ActivityAction; count: number }[] {
const counts = new Map<string, number>()
for (const event of db.auditEvents.all()) counts.set(event.action, (counts.get(event.action) ?? 0) + 1)
return [...counts.entries()].map(([action, count]) => ({ action, count })).sort((a, b) => b.count - a.count || a.action.localeCompare(b.action))
}
/**
* A count from a query: whole, or the fallback when the action was handed
* something that is not a finite number — the types are erased at its
* boundary, and NaN must never reach the pager.
*/
const counted = (value: unknown, fallback: number) => (typeof value === "number" && Number.isFinite(value) ? Math.floor(value) : fallback)
/**
* One page of the trail for the query, filtered and searched before it is
* sorted and cut. The query is read as the section's own view wherever it is
* not one — a crafted call can send anything, or nothing.
*/
export function activityPage(input: ActivityQuery): ActivityPage {
const query = tableQuery(input, DEFAULT_ACTIVITY)
const members = new Map(db.members.all().map((member) => [member.id, member]))
const needle = typeof query.search === "string" ? query.search.trim().toLowerCase() : ""
const rows = db.auditEvents
.all()
.filter((event) => query.action === "all" || event.action === query.action)
.map((event) => {
const member = members.get(event.actor)
return {
id: event.id,
at: event.at,
actor: { name: member?.name ?? "Former member", email: member?.email ?? "", avatarUrl: member?.avatarUrl },
action: event.action,
resource: resourceOf(event),
resourceId: event.resourceId,
ip: event.ip,
}
})
.filter((row) => !needle || [row.actor.name, row.resource, row.resourceId, row.ip].some((field) => field.toLowerCase().includes(needle)))
.sort((a, b) => (query.newestFirst ? b.at.getTime() - a.at.getTime() : a.at.getTime() - b.at.getTime()))
const pageSize = Math.max(1, counted(query.pageSize, DEFAULT_ACTIVITY.pageSize))
const pageCount = Math.max(1, Math.ceil(rows.length / pageSize))
const page = Math.min(Math.max(1, counted(query.page, 1)), pageCount)
return { rows: rows.slice((page - 1) * pageSize, page * pageSize), total: rows.length, page, pageSize, pageCount }
}npx shadcn@latest add @vibra/tables-09Leaderboard of customers with faces
tables-10The ten customers who spent the most over the last 90 or 30 days, ranked with their faces, their share of the store's revenue and how far each moved against the span before.
import * as React from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Leaderboard } from "./tables-10.client"
import { leaderboards } from "./tables-10.data"
/**
* A leaderboard of customers: the ten who spent the most over the last 90 or
* 30 days, with their faces, their share of the store's revenue and how far
* each moved against the span before.
*
* A server component: both boards come from `./tables-10.data`, so switching
* the window is instant.
*/
export function Tables10() {
const titleId = React.useId()
return (
<section
data-section="tables-10"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>Top customers</h2>
</CardTitle>
<CardDescription>Ranked by paid revenue, against the same span just before.</CardDescription>
</CardHeader>
<CardContent className="p-0">
<Leaderboard boards={leaderboards()} titleId={titleId} />
</CardContent>
</Card>
</div>
</section>
)
}
export default Tables10"use client"
import * as React from "react"
import { ArrowDownIcon, ArrowUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { formatCurrency, formatNumber, formatPercent } from "@/lib/format"
import { SegmentedControl } from "@/components/ui/segmented-control"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { UserCell } from "@/components/ui/user-cell"
import type { Board, Standing } from "./tables-10.data"
const money = (cents: number) => formatCurrency(cents / 100, "USD", { maximumFractionDigits: 0 })
const places = (count: number) => `${count} ${count === 1 ? "place" : "places"}`
const HEAD = "h-10 bg-surface px-3 text-foreground"
const PINNED =
"max-md:sticky max-md:start-0 max-md:z-10 max-md:after:absolute max-md:after:inset-y-0 max-md:after:end-0 max-md:after:w-px max-md:after:bg-border"
/** How a customer moved against the window before, in a glyph and in words. */
function Movement({ standing }: { standing: Standing }) {
if (standing.previousRank === null)
return <span className="rounded-full bg-muted px-1.5 text-xs font-medium text-muted-foreground">New</span>
const moved = standing.previousRank - standing.rank
if (moved === 0)
return (
<span className="text-muted-foreground">
<span aria-hidden="true">—</span>
<span className="sr-only">No change</span>
</span>
)
const Icon = moved > 0 ? ArrowUpIcon : ArrowDownIcon
return (
<span className={cn("inline-flex items-center gap-0.5 font-medium tabular-nums", moved > 0 ? "text-success" : "text-muted-foreground")}>
<Icon aria-hidden="true" className="size-3.5" />
<span aria-hidden="true">{Math.abs(moved)}</span>
<span className="sr-only">
{moved > 0 ? "Up" : "Down"} {places(Math.abs(moved))}, from {standing.previousRank}
</span>
</span>
)
}
/**
* The board: the ten customers who spent the most, ranked, each with their
* face, their orders, their revenue and their share of the store's, and how
* far they moved against the window before. The first three wear their rank
* in ink. A segmented control switches the window; a polite line says which.
*/
export function Leaderboard({ boards, titleId }: { boards: Board[]; titleId: string }) {
const [days, setDays] = React.useState(boards[0].days)
const board = boards.find((entry) => entry.days === days) ?? boards[0]
return (
<div data-slot="leaderboard">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-rule p-3">
<SegmentedControl
aria-label="Window"
size="sm"
options={boards.map((entry) => ({ value: String(entry.days), label: `Last ${entry.days} days` }))}
value={String(days)}
onValueChange={(value) => setDays(Number(value) as Board["days"])}
/>
<p role="status" aria-live="polite" className="text-xs text-muted-foreground tabular-nums">
Top {board.standings.length} of the last {board.days} days · {money(board.revenueCents)} in all
</p>
</div>
<Table aria-labelledby={titleId}>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className={cn(HEAD, PINNED)}>Customer</TableHead>
<TableHead className={cn(HEAD, "text-right")}>Orders</TableHead>
<TableHead aria-sort="descending" className={cn(HEAD, "text-right")}>
Revenue
</TableHead>
<TableHead className={cn(HEAD, "text-right")}>Share</TableHead>
<TableHead className={cn(HEAD, "text-right")}>
<span aria-hidden="true">Change</span>
<span className="sr-only">Change against the {board.days} days before</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{board.standings.map((standing) => (
<TableRow key={standing.id} className="group/row">
<TableCell className={cn(PINNED, "bg-card px-3 py-2 group-hover/row:bg-[color-mix(in_oklab,var(--muted)_50%,var(--card))]")}>
<span className="flex items-center gap-3">
<span
className={cn(
"inline-flex size-6 shrink-0 items-center justify-center rounded-full text-xs font-semibold tabular-nums",
standing.rank <= 3 ? "bg-foreground text-background" : "text-muted-foreground"
)}
>
<span className="sr-only">Rank </span>
{standing.rank}
</span>
<UserCell name={standing.company} description={standing.name} src={standing.avatarUrl} className="max-w-40 sm:max-w-none" />
</span>
</TableCell>
<TableCell className="px-3 py-2 text-right tabular-nums">{formatNumber(standing.orders)}</TableCell>
<TableCell className="px-3 py-2 text-right font-medium tabular-nums">{money(standing.revenueCents)}</TableCell>
<TableCell className="px-3 py-2 text-right">
<span className="inline-flex items-center justify-end gap-2 tabular-nums">
<span aria-hidden="true" className="hidden h-1.5 w-16 overflow-hidden rounded-full bg-muted sm:block">
<span className="block h-full rounded-full bg-chart-1" style={{ width: `${Math.round(standing.share * 100 / board.standings[0].share)}%` }} />
</span>
{formatPercent(standing.share)}
</span>
</TableCell>
<TableCell className="px-3 py-2 text-right">
<Movement standing={standing} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)
}/**
* What `tables-10` reads: the customers who spent the most in paid
* `db.orders` over the last 30 and the last 90 days before `REFERENCE_DATE`,
* each with where they stood over the same span just before, so the board
* can say who climbed and who is new.
*
* Selectors only, reading `db` inside functions and run on the server. The
* island takes the boards as props; it never imports this module.
*/
import { REFERENCE_DATE, db, type Order } from "@/lib/sample-data"
/** The windows the board can show, in days; the first is the one it opens on. */
export const WINDOWS = [90, 30] as const
export type Window = (typeof WINDOWS)[number]
/** How many places the board has. */
export const PLACES = 10
const DAY_MS = 86_400_000
const SOLD: readonly Order["status"][] = ["paid", "fulfilled"]
export type Standing = {
rank: number
id: string
name: string
company: string
avatarUrl?: string
orders: number
revenueCents: number
/** Their share of everything the store took in the window, 0–1. */
share: number
/** Where they stood over the span before; null when they bought nothing then. */
previousRank: number | null
}
export type Board = { days: Window; revenueCents: number; standings: Standing[] }
type Tally = { id: string; orders: number; revenueCents: number }
/** Every customer who bought in [from, to), largest spend first; ties go to more orders, then the name. */
function ranking(from: number, to: number, names: Map<string, string>): Tally[] {
const tallies = new Map<string, Tally>()
for (const order of db.orders.all()) {
const at = order.placedAt.getTime()
if (!order.customerId || !SOLD.includes(order.status) || at < from || at >= to) continue
const tally = tallies.get(order.customerId) ?? { id: order.customerId, orders: 0, revenueCents: 0 }
tally.orders += 1
tally.revenueCents += order.totalCents
tallies.set(order.customerId, tally)
}
return [...tallies.values()].sort(
(a, b) => b.revenueCents - a.revenueCents || b.orders - a.orders || (names.get(a.id) ?? "").localeCompare(names.get(b.id) ?? "")
)
}
/** The top of the board for each window, with each customer's place in the window before. */
export function leaderboards(): Board[] {
const customers = new Map(db.customers.all().map((customer) => [customer.id, customer]))
const names = new Map([...customers.values()].map((customer) => [customer.id, customer.company]))
const now = REFERENCE_DATE.getTime() + 1
return WINDOWS.map((days) => {
const current = ranking(now - days * DAY_MS, now, names)
const before = ranking(now - 2 * days * DAY_MS, now - days * DAY_MS, names)
const revenueCents = current.reduce((total, tally) => total + tally.revenueCents, 0)
const places = new Map(before.map((tally, index) => [tally.id, index + 1]))
return {
days,
revenueCents,
standings: current.slice(0, PLACES).map((tally, index) => {
const customer = customers.get(tally.id)
return {
rank: index + 1,
id: tally.id,
name: customer?.name ?? "Unknown customer",
company: customer?.company ?? "",
avatarUrl: customer?.avatarUrl,
orders: tally.orders,
revenueCents: tally.revenueCents,
share: revenueCents > 0 ? tally.revenueCents / revenueCents : 0,
previousRank: places.get(tally.id) ?? null,
}
}),
}
})
}npx shadcn@latest add @vibra/tables-10