Team figures
The people in the workspace and how many of them are active, the seats offered and not yet taken, and the seats the plan includes; reads summary().
Preview
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"
import type { TeamSummary as Summary } from "../data"
/** Nothing on this row holds state, so it stays a plain function component. */
export function TeamSummary({ summary }: { summary: Summary }) {
return (
<StatCardGroup data-widget="widget-people-directory-team-summary" columns={3} divided>
<StatCard
label="People"
value={String(summary.people)}
description={`${summary.active} of them active`}
/>
<StatCard
label="Awaiting acceptance"
value={String(summary.awaiting)}
description="Seats offered and not yet taken"
/>
<StatCard
label="Seats on the plan"
value={String(summary.seatsIncluded)}
description={`Included with ${summary.planName}`}
/>
</StatCardGroup>
)
}Install
$
npx shadcn@latest add @vibra/widget-people-directory-team-summaryNeeds the @vibra registry in your components.json — set it up once.
Source
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"
import type { TeamSummary as Summary } from "../data"
/** Nothing on this row holds state, so it stays a plain function component. */
export function TeamSummary({ summary }: { summary: Summary }) {
return (
<StatCardGroup data-widget="widget-people-directory-team-summary" columns={3} divided>
<StatCard
label="People"
value={String(summary.people)}
description={`${summary.active} of them active`}
/>
<StatCard
label="Awaiting acceptance"
value={String(summary.awaiting)}
description="Seats offered and not yet taken"
/>
<StatCard
label="Seats on the plan"
value={String(summary.seatsIncluded)}
description={`Included with ${summary.planName}`}
/>
</StatCardGroup>
)
}/**
* What this page reads, and the two things it changes.
*
* The directory is `db.members` — the workspace's own people, one row each,
* with the role and status they already carry. Nothing about a person is
* invented here: the counts under the title are that table grouped, and the
* tier is read off the same `db.subscriptions` row `/billing` reads, so the two
* pages cannot name different plans. Seats are the organisation's, counted
* across the workspaces it still runs and stated in the same words `/billing`
* uses — a person in this workspace and a seat on the bill are different
* things, and saying both in one sentence is what keeps them from being read
* as the same number.
*
* `invite` and `removeMember` are the mutations, server actions returning
* `Result`. An invitation is a real `db.invitations` row dated from
* `REFERENCE_DATE`, which is what the invitations page then lists.
*/
import { getInitials } from "@/lib/format"
import {
db,
type Invitation,
type Member,
} from "@/lib/sample-data"
export type MemberRow = {
id: string
name: string
email: string
avatarUrl?: string
role: Member["role"]
status: Member["status"]
twoFactor: boolean
joinedAt: Date
lastActiveAt: Date
}
/** Everyone in the workspace, in the order the table first shows them. */
export function members(): MemberRow[] {
return db.members.all().map((member) => ({
id: member.id,
name: member.name,
email: member.email,
avatarUrl: member.avatarUrl,
role: member.role,
status: member.status,
twoFactor: member.twoFactor,
joinedAt: member.joinedAt,
lastActiveAt: member.lastActiveAt,
}))
}
export type TeamSummary = {
people: number
active: number
awaiting: number
seatsIncluded: number
planName: string
}
// Every read is per call, never held at module scope: a workspace closed or a
// plan raised since the server started is what the next render shows.
/** The workspaces the organisation still runs: a closed account fills no seats. */
const liveWorkspaces = () =>
db.customers
.all()
.filter((customer) => customer.company.startsWith("Northwind ") && customer.status !== "churned")
function accountRow() {
const live = liveWorkspaces()
return live.find((customer) => customer.company === "Northwind Analytics") ?? live[0]
}
/** The organisation, as both `/billing` and `/people/directory` state it, counted per call. */
export function organisation(): { name: string; workspaces: number; seatsFilled: number } {
const live = liveWorkspaces()
return {
name: "Northwind",
workspaces: live.length,
seatsFilled: live.reduce((total, customer) => total + customer.seats, 0),
}
}
/**
* The seats sentence, written once. `/billing` and `/people/directory` both render it, so
* neither can drift from the other about how many seats are filled.
*/
export function seatsSummary(): string {
const { seatsFilled, workspaces } = organisation()
return `${seatsFilled} seats filled across the organisation's ${workspaces} workspaces`
}
/** The other half of it: the people this workspace holds, not the organisation. */
export function peopleSummary(): string {
return `${db.members.all().length} people in this workspace`
}
// The tier is read off the subscription, the same row `/billing` reads, rather
// than guessed from headcount — so the two pages cannot name different plans.
// Resolved per call: the repository replaces a row rather than editing it.
function planRow() {
const subscription =
db.subscriptions.all().find((row) => row.customerId === accountRow().id) ?? db.subscriptions.all()[0]
return db.plans.all().find((plan) => plan.id === subscription.planId) ?? db.plans.all()[0]
}
/** The line under the title: how many people, how many seats the tier covers. */
export function summary(): TeamSummary {
const rows = db.members.all()
const plan = planRow()
return {
people: rows.length,
active: rows.filter((row) => row.status === "active").length,
// A seat that has been offered and not taken: the members table records
// one, and db.invitations records the other.
awaiting:
rows.filter((row) => row.status === "invited").length +
db.invitations.all().filter((row) => row.status === "pending").length,
seatsIncluded: plan.seatsIncluded,
planName: plan.name,
}
}
/** The roles an invitation may grant. There is only ever one owner. */
export const INVITE_ROLES: Invitation["role"][] = ["admin", "member", "viewer"]
function ownerRow(): Member {
return db.members.all().find((member) => member.role === "owner") ?? db.members.all()[0]
}
/** The person looking at the page: whoever owns this workspace. */
export function currentUser() {
const owner = ownerRow()
return { name: owner.name, email: owner.email, initials: getInitials(owner.name), avatarUrl: owner.avatarUrl }
}
/** The bell's contents: the newest notifications, unread first in the panel. */
export function shellNotifications() {
return db.notifications
.all()
.sort((a, b) => b.at.getTime() - a.at.getTime())
.slice(0, 6)
.map(({ id, title, description, at, read, href }) => ({ id, title, description, at, read, href }))
}Its page
On its page the card sits among the rest of the dashboard and shares its range and its data with them.
From the Team directory page