/supportSupport 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.
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
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>
)
}Install
npx shadcn@latest add @vibra/support-overviewNeeds the @vibra registry in your components.json — set it up once.
Source
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>
)
}/**
* 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())
}"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 } }
}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>
)
}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>
)
}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>
)
}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>
)
}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>
)
}"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>
)
}