Skip to contentVibraUI

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

Install

npx shadcn@latest add @vibra/widget-people-directory-team-summary

Needs the @vibra registry in your components.json — set it up once.

Source

app/people/directory/components/team-summary.tsx
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>
  )
}
app/people/directory/data.ts
/**
 * 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