Skip to contentVibraUI
Part of the Support dashboardinstalls at /support

Support dashboard

A help desk page: open tickets and median reply times, volume opened against resolved day by day, SLA attainment on a gauge, customer satisfaction on a score ring, the outstanding queue, and the agents behind it.

Open the live page

The page is a server component inside AppShell; only the volume chart crosses into the client, because a chart formats its own axis labels. There is no ticket entity, so the queue is built once from the entities that do exist: a ticket takes its subject and its kind from a db.notifications row, its requester from a db.customers row and its agent from a db.members row, and only the timings come from seeded("support-overview"). A ticket is resolved when its resolution time lands before REFERENCE_DATE and open when it does not, so the queue is exactly the work still outstanding. Composes AppShell, PageHeader, StatCardGroup, StatCard, DashboardGrid, ChartCard, BarChart, Widget, Gauge, ScoreRing, SimpleTable, PriorityBadge, StatusBadge and UserCell.

Preview

Install

npx shadcn@latest add @vibra/support-overview

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

Source

app/support/page.tsx
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { DashboardGrid, DashboardGridItem } from "@/components/ui/dashboard-grid"
import { PageHeader } from "@/components/ui/page-header"

import { signOut } from "./actions"
import { AgentsLeaderboard } from "./components/agents-leaderboard"
import { CsatPanel } from "./components/csat-panel"
import { ServiceLevels } from "./components/service-levels"
import { SupportStats } from "./components/support-stats"
import { TicketQueue } from "./components/ticket-queue"
import { TicketVolume } from "./components/ticket-volume"
import { currentUser, shellNotifications, ticketVolume, WINDOW_DAYS } from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/support/nav"

/**
 * The help desk page. It is a server component: the queue is built from db on
 * the server, and only the volume chart crosses into the client, because a
 * chart formats its own axis labels.
 */
export default function SupportPage() {
  return (
    <AppShell
      nav={NAV}
      activeHref={ROUTES.overview}
      user={currentUser()}
      notifications={shellNotifications()}
      now={REFERENCE_DATE}
      onSignOut={signOut}
    >
      <PageHeader
        title="Support"
        description={`What came in over the last ${WINDOW_DAYS} days, what is still open, and how it was answered.`}
      />

      <SupportStats />

      <DashboardGrid>
        <DashboardGridItem colSpan={{ base: 12, lg: 6 }}>
          <TicketVolume days={ticketVolume()} />
        </DashboardGridItem>
        <DashboardGridItem colSpan={{ base: 12, md: 6, lg: 3 }}>
          <ServiceLevels />
        </DashboardGridItem>
        <DashboardGridItem colSpan={{ base: 12, md: 6, lg: 3 }}>
          <CsatPanel />
        </DashboardGridItem>

        <DashboardGridItem colSpan={12}>
          <TicketQueue />
        </DashboardGridItem>

        <DashboardGridItem colSpan={12}>
          <AgentsLeaderboard />
        </DashboardGridItem>
      </DashboardGrid>
    </AppShell>
  )
}
app/support/data.ts
/**
 * What this page reads. There is no ticket entity, so the queue is built once
 * from the two entities that do exist: every ticket takes its subject and its
 * kind from a `db.notifications` row, its requester from a `db.customers` row
 * and its agent from a `db.members` row, and only the timings come from
 * `seeded("dashboard-support")`. Nothing is a literal and nothing reads a
 * clock: a ticket is resolved when its resolution time lands before
 * `REFERENCE_DATE`, still open when it does not, so the queue below is
 * exactly the work that is genuinely outstanding.
 */
import { formatDuration, formatNumber, getInitials } from "@/lib/format"
import {
  db,
  intBetween,
  pickWeighted,
  REFERENCE_DATE,
  rowId,
  seeded,
  type Member,
  type Notification,
} from "@/lib/sample-data"

const MINUTE_MS = 60_000
const DAY_MS = 86_400_000

/** The window the queue and every rate on this page are measured over. */
export const WINDOW_DAYS = 30

const WINDOW_START = REFERENCE_DATE.getTime() - WINDOW_DAYS * DAY_MS

export type TicketPriority = "urgent" | "high" | "medium" | "low"

/** What a notification's kind means for the ticket it becomes. */
const PRIORITY_BY_KIND: Record<Notification["kind"], TicketPriority> = {
  security: "urgent",
  billing: "high",
  assignment: "medium",
  system: "medium",
  mention: "low",
}

/** How long first contact may take, in minutes, before the promise is missed. */
export const SLA_MINUTES: Record<TicketPriority, number> = {
  urgent: 30,
  high: 60,
  medium: 240,
  low: 480,
}

// How long a ticket of each priority takes to close, in hours, at best and worst.
const RESOLUTION_HOURS: Record<TicketPriority, readonly [number, number]> = {
  urgent: [1, 14],
  high: [2, 30],
  medium: [4, 72],
  low: [8, 140],
}

// How a rating falls when the promise was kept, and when it was missed.
const RATINGS: Record<"met" | "missed", readonly (readonly [number, number])[]> = {
  met: [[5, 0.55], [4, 0.3], [3, 0.1], [2, 0.03], [1, 0.02]],
  missed: [[5, 0.14], [4, 0.24], [3, 0.26], [2, 0.21], [1, 0.15]],
}

export type Ticket = {
  id: string
  subject: string
  priority: TicketPriority
  company: string
  requester: string
  requesterAvatar?: string
  agent: Member
  openedAt: Date
  firstResponseMinutes: number
  slaMet: boolean
  status: "open" | "pending" | "resolved"
  resolutionMinutes: number
  csat?: number
}

const COUNT = 260

/** A ticket as drawn, with the id of the account that raised it beside the names it had then. */
type Drawn = Ticket & { requesterId: string }

let drawn: Drawn[] | undefined

/**
 * The window's tickets, drawn once from the block's seed — when a page first
 * asks, never when the module loads. What an agent or a requester is called is
 * read off the rows per request (`tickets()`), so a teammate renamed since is
 * the name on the leaderboard.
 */
function draws(): Drawn[] {
  if (drawn) return drawn
  const rand = seeded("dashboard-support")
  const subjects = db.notifications.all()
  const requesters = db.customers
    .all()
    .filter((customer) => customer.status === "active" || customer.status === "trial")
  // A viewer cannot answer a ticket, and a deactivated teammate is gone.
  const agents = db.members
    .all()
    .filter((member) => member.status === "active" && member.role !== "viewer")

  drawn = Array.from({ length: COUNT }, (_, index) => {
    const subject = subjects[index % subjects.length]
    const requester = requesters[intBetween(rand, 0, requesters.length - 1)]
    const agent = agents[intBetween(rand, 0, agents.length - 1)]
    const priority = PRIORITY_BY_KIND[subject.kind]

    // Tickets arrive on working days: a weekend arrival mostly slides to the
    // Tuesday after it.
    let day = intBetween(rand, 0, WINDOW_DAYS - 1)
    const at = new Date(WINDOW_START + day * DAY_MS)
    if ((at.getUTCDay() === 0 || at.getUTCDay() === 6) && rand() < 0.55) {
      day = Math.min(WINDOW_DAYS - 1, day + 2)
    }
    // Inside the working day, roughly 08:00 to 20:00 UTC.
    const openedAt = new Date(
      WINDOW_START + day * DAY_MS + (8 + rand() * 12) * 3_600_000
    )

    const sla = SLA_MINUTES[priority]
    // Most first replies beat the promise; the rest miss it by a long way.
    const slaMet = rand() < 0.87
    const firstResponseMinutes = slaMet
      ? Math.max(2, Math.round(sla * (0.15 + rand() * 0.7)))
      : Math.round(sla * (1.1 + rand() * 1.9))

    const [fastest, slowest] = RESOLUTION_HOURS[priority]
    const resolutionMinutes = Math.round((fastest + rand() * (slowest - fastest)) * 60)
    const closesAt = openedAt.getTime() + resolutionMinutes * MINUTE_MS
    const status: Ticket["status"] =
      closesAt <= REFERENCE_DATE.getTime() ? "resolved" : rand() < 0.4 ? "pending" : "open"

    return {
      id: rowId("TCK", index + 1),
      subject: subject.title,
      priority,
      requesterId: requester.id,
      company: requester.company,
      requester: requester.name,
      requesterAvatar: requester.avatarUrl,
      agent,
      openedAt,
      firstResponseMinutes,
      slaMet,
      status,
      resolutionMinutes,
      // Around three in five closed tickets come back with a rating.
      csat:
        status === "resolved" && rand() < 0.62
          ? pickWeighted(rand, RATINGS[slaMet ? "met" : "missed"])
          : undefined,
    }
  })
  return drawn
}

/** Every ticket in the window, its requester and agent as the rows say they are now. */
function tickets(): Ticket[] {
  const customers = new Map(db.customers.all().map((customer) => [customer.id, customer]))
  const members = new Map(db.members.all().map((member) => [member.id, member]))
  return draws().map(({ requesterId, ...ticket }) => {
    const requester = customers.get(requesterId)
    return {
      ...ticket,
      company: requester?.company ?? ticket.company,
      requester: requester?.name ?? ticket.requester,
      requesterAvatar: requester ? requester.avatarUrl : ticket.requesterAvatar,
      agent: members.get(ticket.agent.id) ?? ticket.agent,
    }
  })
}

const resolved = (all: Ticket[]) => all.filter((ticket) => ticket.status === "resolved")
const outstanding = (all: Ticket[]) => all.filter((ticket) => ticket.status !== "resolved")

const median = (values: number[]): number => {
  if (values.length === 0) return 0
  const sorted = [...values].sort((a, b) => a - b)
  return sorted[Math.floor(sorted.length / 2)]
}

export type VolumePoint = { date: string; opened: number; resolved: number }

/** Tickets opened and closed on each day of the window, oldest first. */
export function ticketVolume(): VolumePoint[] {
  const days = Array.from({ length: WINDOW_DAYS }, (_, index) => ({
    date: new Date(WINDOW_START + index * DAY_MS).toISOString().slice(0, 10),
    opened: 0,
    resolved: 0,
  }))
  const indexOf = (time: number) => Math.floor((time - WINDOW_START) / DAY_MS)

  for (const ticket of tickets()) {
    const opened = indexOf(ticket.openedAt.getTime())
    if (opened >= 0 && opened < days.length) days[opened].opened += 1
    if (ticket.status !== "resolved") continue
    const closed = indexOf(ticket.openedAt.getTime() + ticket.resolutionMinutes * MINUTE_MS)
    if (closed >= 0 && closed < days.length) days[closed].resolved += 1
  }
  return days
}

/** The share of tickets answered inside their priority's promise, as a percentage. */
export function slaAttainment(): number {
  const all = tickets()
  return Math.round((all.filter((ticket) => ticket.slaMet).length / all.length) * 1000) / 10
}

/** The average rating out of five, and how many people left one. */
export function csat(): { score: number; responses: number } {
  const rated = resolved(tickets()).filter((ticket) => ticket.csat !== undefined)
  const total = rated.reduce((sum, ticket) => sum + (ticket.csat ?? 0), 0)
  return {
    score: rated.length === 0 ? 0 : Math.round((total / rated.length) * 10) / 10,
    responses: rated.length,
  }
}

/** What is still outstanding, longest waiting first. */
export function queue(limit = 8): Ticket[] {
  return outstanding(tickets())
    .sort((a, b) => a.openedAt.getTime() - b.openedAt.getTime())
    .slice(0, limit)
}

export type AgentRow = {
  id: string
  name: string
  email: string
  avatarUrl?: string
  resolved: number
  firstResponseMinutes: number
  csat: number | null
}

/** Who closed the most, with the reply time and rating behind it. */
export function agentLeaderboard(limit = 6): AgentRow[] {
  const rows = new Map<string, { member: Member; resolved: Ticket[] }>()
  for (const ticket of resolved(tickets())) {
    const row = rows.get(ticket.agent.id) ?? { member: ticket.agent, resolved: [] }
    row.resolved.push(ticket)
    rows.set(ticket.agent.id, row)
  }

  return [...rows.values()]
    .map(({ member, resolved }) => {
      const rated = resolved.filter((ticket) => ticket.csat !== undefined)
      return {
        id: member.id,
        name: member.name,
        email: member.email,
        avatarUrl: member.avatarUrl,
        resolved: resolved.length,
        firstResponseMinutes: median(resolved.map((ticket) => ticket.firstResponseMinutes)),
        csat:
          rated.length === 0
            ? null
            : Math.round(
                (rated.reduce((sum, ticket) => sum + (ticket.csat ?? 0), 0) / rated.length) * 10
              ) / 10,
      }
    })
    .sort((a, b) => b.resolved - a.resolved)
    .slice(0, limit)
}

export type SupportStat = { key: string; label: string; value: string; description: string }

/** The four headline numbers over the window. */
export function supportStats(): SupportStat[] {
  const all = tickets()
  const open = outstanding(all)
  const closed = resolved(all)
  const urgent = open.filter((ticket) => ticket.priority === "urgent").length
  return [
    {
      key: "open",
      label: "Open now",
      value: formatNumber(open.length, { maximumFractionDigits: 0 }),
      description: urgent > 0 ? `${urgent} of them urgent` : "none of them urgent",
    },
    {
      key: "first-response",
      label: "Median first response",
      value: formatDuration(
        median(all.map((ticket) => ticket.firstResponseMinutes)) * MINUTE_MS
      ),
      description: "across every priority",
    },
    {
      key: "resolution",
      label: "Median resolution",
      value: formatDuration(median(closed.map((ticket) => ticket.resolutionMinutes)) * MINUTE_MS),
      description: "from opened to closed",
    },
    {
      key: "resolved",
      label: `Resolved in ${WINDOW_DAYS} days`,
      value: formatNumber(closed.length, { maximumFractionDigits: 0 }),
      description: `out of ${formatNumber(all.length, { maximumFractionDigits: 0 })} raised`,
    },
  ]
}

/** 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 }))
}

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 moment every "waiting for" on this page is measured against. */
export const NOW = REFERENCE_DATE

/**
 * How long a ticket has been waiting, measured against `NOW` rather than the
 * reader's clock — so the queue reads the same on a server render, in a test,
 * and on a machine whose clock is wrong.
 */
export function waitedFor(at: Date): string {
  return formatDuration(NOW.getTime() - at.getTime())
}
app/support/actions.ts
"use server"

import { mockAuthAdapter } from "@/lib/auth-adapter"
import { type Result } from "@/lib/sample-data"

/**
 * The one thing this page changes. A server action so the page can stay a
 * server component and still hand the shell something to call, and a `Result`
 * so the caller reads the same success-or-error shape every mutation returns.
 */
export async function signOut(): Promise<Result<{ signedOut: true }>> {
  await mockAuthAdapter.signOut()
  return { ok: true, data: { signedOut: true } }
}
app/support/components/agents-leaderboard.tsx
import { formatDuration, formatNumber } from "@/lib/format"
import { SimpleTable, type SimpleTableColumn } from "@/components/ui/simple-table"
import { UserCell } from "@/components/ui/user-cell"
import { Widget } from "@/components/ui/widget"

import { agentLeaderboard, WINDOW_DAYS, type AgentRow } from "../data"

const COLUMNS: SimpleTableColumn<AgentRow>[] = [
  {
    key: "name",
    header: "Agent",
    cell: (row) => <UserCell name={row.name} email={row.email} src={row.avatarUrl} size="sm" />,
  },
  {
    key: "resolved",
    header: "Resolved",
    align: "right",
    cell: (row) => formatNumber(row.resolved, { maximumFractionDigits: 0 }),
  },
  {
    key: "first",
    header: "First reply",
    align: "right",
    cell: (row) => formatDuration(row.firstResponseMinutes * 60_000),
  },
  {
    key: "csat",
    header: "CSAT",
    align: "right",
    cell: (row) =>
      row.csat === null ? <span className="text-muted-foreground">—</span> : row.csat.toFixed(1),
  },
]

export function AgentsLeaderboard() {
  return (
    <Widget
      data-widget="widget-support-overview-agents-leaderboard"
      title="Agents"
      description={`Who closed the most over ${WINDOW_DAYS} days`}
      className="h-full"
      contentClassName="px-0"
      footer="First reply is the median across the tickets each agent closed."
    >
      <SimpleTable
        columns={COLUMNS}
        rows={agentLeaderboard()}
        rowKey="id"
        size="sm"
        caption="Agents by tickets resolved"
      />
    </Widget>
  )
}
app/support/components/csat-panel.tsx
import { formatNumber } from "@/lib/format"
import { ScoreRing } from "@/components/ui/score-ring"
import { Widget } from "@/components/ui/widget"

import { csat } from "../data"

export function CsatPanel() {
  const { score, responses } = csat()

  return (
    <Widget
      data-widget="widget-support-overview-csat-panel"
      title="Customer satisfaction"
      description="How a closed ticket was rated, out of five"
      className="h-full"
      footer={`${formatNumber(responses, { maximumFractionDigits: 0 })} people rated the answer they got.`}
    >
      <div className="flex justify-center py-2">
        <ScoreRing
          value={score}
          max={5}
          size={190}
          label="Average rating"
          tone="auto"
          format={(value) => value.toFixed(1)}
        />
      </div>
    </Widget>
  )
}
app/support/components/service-levels.tsx
import { Gauge } from "@/components/ui/gauge"
import { Widget } from "@/components/ui/widget"

import { slaAttainment, SLA_MINUTES, WINDOW_DAYS } from "../data"

export function ServiceLevels() {
  const attainment = slaAttainment()

  return (
    <Widget
      data-widget="widget-support-overview-service-levels"
      title="Service levels"
      description={`First replies inside their promise, over ${WINDOW_DAYS} days`}
      className="h-full"
      footer={`The target is 95%. Urgent is answered in ${SLA_MINUTES.urgent} minutes, low in ${SLA_MINUTES.low / 60} hours.`}
    >
      <div className="flex justify-center py-2">
        <Gauge
          value={attainment}
          label="SLA attainment"
          size={190}
          arc={270}
          // High is the good end of this dial, so the thresholds are floors:
          // the target is 95%, within ten points of it the desk is slipping,
          // and below that it is failing.
          direction="higher-is-better"
          thresholds={{ warning: 95, danger: 85 }}
          format={(value) => `${value.toFixed(1)}%`}
        />
      </div>
    </Widget>
  )
}
app/support/components/support-stats.tsx
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"

import { supportStats } from "../data"

export function SupportStats() {
  return (
    <StatCardGroup data-widget="widget-support-overview-support-stats" columns={4}>
      {supportStats().map((stat) => (
        <StatCard
          key={stat.key}
          label={stat.label}
          value={stat.value}
          description={stat.description}
        />
      ))}
    </StatCardGroup>
  )
}
app/support/components/ticket-queue.tsx
import { formatDuration } from "@/lib/format"
import { PriorityBadge } from "@/components/ui/priority-badge"
import { SimpleTable, type SimpleTableColumn } from "@/components/ui/simple-table"
import { StatusBadge } from "@/components/ui/status-badge"
import { Widget } from "@/components/ui/widget"
import { UserCell } from "@/components/ui/user-cell"

import { queue, waitedFor, type Ticket } from "../data"

const COLUMNS: SimpleTableColumn<Ticket>[] = [
  {
    key: "subject",
    header: "Ticket",
    cell: (row) => (
      <div className="flex min-w-0 flex-col">
        <span className="truncate font-medium">{row.subject}</span>
        <span className="font-mono text-xs text-muted-foreground">{row.id}</span>
      </div>
    ),
  },
  {
    key: "company",
    header: "Requester",
    cell: (row) => <UserCell size="sm" name={row.requester} description={row.company} src={row.requesterAvatar} />,
  },
  { key: "priority", header: "Priority", cell: (row) => <PriorityBadge priority={row.priority} /> },
  {
    key: "status",
    header: "Status",
    cell: (row) => <StatusBadge status={row.status} map={{ open: "info", pending: "warning" }} />,
  },
  { key: "agent", header: "Agent", cell: (row) => row.agent.name },
  {
    key: "first",
    header: "First reply",
    align: "right",
    cell: (row) => formatDuration(row.firstResponseMinutes * 60_000),
  },
  {
    key: "waiting",
    header: "Waiting",
    align: "right",
    // Measured against the block's own "now", not the reader's clock; the
    // instant itself stays machine-readable on the element.
    cell: (row) => <time dateTime={row.openedAt.toISOString()}>{waitedFor(row.openedAt)}</time>,
  },
]

export function TicketQueue() {
  return (
    <Widget
      data-widget="widget-support-overview-ticket-queue"
      title="Ticket queue"
      description="What is still outstanding, longest waiting first"
      className="h-full"
      contentClassName="px-0"
      footer="A pending ticket is waiting on the customer; an open one is waiting on us."
    >
      <SimpleTable
        columns={COLUMNS}
        rows={queue()}
        rowKey="id"
        size="sm"
        caption="Open and pending tickets"
      />
    </Widget>
  )
}
app/support/components/ticket-volume.tsx
"use client"

import { formatNumber } from "@/lib/format"
import { BarChart } from "@/components/ui/bar-chart"
import { ChartCard } from "@/components/ui/chart-card"

import { type VolumePoint } from "../data"

// What came in against what went out: the pair is the whole story, so they sit
// on one scale and keep their colours across the page.
const SERIES = [
  { key: "opened", label: "Opened", color: "chart-1" as const },
  { key: "resolved", label: "Resolved", color: "chart-2" as const },
]

const DAY_LABEL = new Intl.DateTimeFormat("en-US", {
  month: "short",
  day: "numeric",
  timeZone: "UTC",
})

/** What came in against what went out, a day at a time; the days arrive from the server. */
export function TicketVolume({ days }: { days: VolumePoint[] }) {
  const opened = days.reduce((total, day) => total + day.opened, 0)
  const resolved = days.reduce((total, day) => total + day.resolved, 0)

  return (
    <ChartCard
      data-widget="widget-support-overview-ticket-volume"
      title="Ticket volume"
      description={`Opened against resolved, day by day, over ${days.length} days`}
      height={260}
      className="h-full"
      footer={`${formatNumber(opened, { maximumFractionDigits: 0 })} raised, ${formatNumber(resolved, { maximumFractionDigits: 0 })} closed inside the window.`}
    >
      <BarChart
        data={days}
        index="date"
        series={SERIES}
        height={260}
        showYAxis
        indexFormatter={(value) => DAY_LABEL.format(new Date(`${value}T00:00:00Z`))}
        valueFormatter={(value) => formatNumber(value, { maximumFractionDigits: 0 })}
      />
    </ChartCard>
  )
}