Dashboard shell
The whole frame of an app: the sidebar, the header, the search and the page inside.
Classic shell: sidebar, bar and page
dashboard-shell-01The SaaS dashboard's own frame — a sidebar of its pages, a bar with the trail, the bell and the account — around a month of the business.
import * as React from "react"
import Link from "next/link"
import { CreditCardIcon, TriangleAlertIcon, UserPlusIcon, UsersIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { formatCurrency, formatDate, formatNumber, formatRelative, getInitials } from "@/lib/format"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { buttonVariants } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { DashboardGrid, DashboardGridItem } from "@/components/ui/dashboard-grid"
import { PageHeader } from "@/components/ui/page-header"
import { RankList } from "@/components/ui/rank-list"
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"
import { ClassicShell } from "./dashboard-shell-01.client"
import { ACTIVE, WINDOW_DAYS, asOf, overview, shell } from "./dashboard-shell-01.data"
const DAY_MS = 86_400_000
const dollars = (cents: number) => formatCurrency(cents / 100, "USD", { maximumFractionDigits: 0 })
const change = (now: number, before: number) => (before === 0 ? undefined : (now - before) / before)
const PLAN_NAMES: Record<string, string> = { free: "Free", starter: "Starter", team: "Team", enterprise: "Enterprise" }
/**
* The classic dashboard shell: a sidebar, a bar across the top and the page
* under it — here the SaaS dashboard's own frame around its overview. The
* sidebar collapses to its icons, the bar carries the trail, the bell and the
* account, and the page is a month of the business: recurring revenue, paying
* accounts, sign-ups and what is past due, revenue by plan and the latest
* accounts to join.
*
* A server component: the shell's nav, person and bell and the page's figures
* come from `./dashboard-shell-01.data`; the page is drawn here and handed to
* the shell island, which never sees the store.
*/
export function DashboardShell01() {
const titleId = React.useId()
const frame = shell()
const page = overview()
const now = asOf()
const since = (date: Date) =>
now.getTime() - date.getTime() < 7 * DAY_MS ? formatRelative(date, now) : formatDate(date, "medium", { timeZone: "UTC" })
return (
<section
data-section="dashboard-shell-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}>Northwind Analytics</h2>
</CardTitle>
<CardDescription>Sidebar, bar and page: the classic frame around the overview</CardDescription>
</CardHeader>
<CardContent className="p-0">
<ClassicShell nav={frame.nav} activeHref={ACTIVE} user={frame.user} bell={frame.bell} now={now}>
<PageHeader
as="h3"
title="Overview"
description="Recurring revenue, accounts and sign-ups."
meta={
<span className="tabular-nums">
Last {WINDOW_DAYS} days to {formatDate(now, "medium", { timeZone: "UTC" })}
</span>
}
actions={
<Link href="/saas/revenue" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
Revenue report
</Link>
}
/>
<StatCardGroup columns={4} divided>
<StatCard
label="Recurring revenue"
value={dollars(page.mrrCents)}
description={`a month, from ${formatNumber(page.paying)} paying accounts`}
icon={<CreditCardIcon aria-hidden="true" />}
/>
<StatCard
label="Paying accounts"
value={formatNumber(page.paying)}
description={`${formatNumber(page.trialing)} more on a trial`}
icon={<UsersIcon aria-hidden="true" />}
/>
<StatCard
label="New customers"
value={formatNumber(page.newCustomers)}
delta={change(page.newCustomers, page.newCustomersBefore)}
deltaFormat="percent"
description={`vs ${formatNumber(page.newCustomersBefore)} the month before`}
icon={<UserPlusIcon aria-hidden="true" />}
/>
<StatCard
label="Past due"
value={formatNumber(page.pastDue)}
description={`${dollars(page.pastDueCents)} a month at risk`}
icon={<TriangleAlertIcon aria-hidden="true" />}
/>
</StatCardGroup>
<DashboardGrid>
<DashboardGridItem colSpan={{ base: 12, lg: 7 }}>
<Card className="h-full">
<CardHeader>
<CardTitle>Revenue by plan</CardTitle>
<CardDescription>Monthly recurring, paying accounts only</CardDescription>
</CardHeader>
<CardContent>
<RankList
showRank={false}
items={page.byPlan.map((row) => ({
label: `${row.plan} · ${formatNumber(row.accounts)} ${row.accounts === 1 ? "account" : "accounts"}`,
value: row.mrrCents,
}))}
format={dollars}
/>
</CardContent>
</Card>
</DashboardGridItem>
<DashboardGridItem colSpan={{ base: 12, lg: 5 }}>
<Card className="h-full">
<CardHeader>
<CardTitle>Latest to join</CardTitle>
<CardDescription>Newest accounts first</CardDescription>
</CardHeader>
<CardContent className="p-0">
<ul className="divide-y">
{page.signups.map((account) => (
<li key={account.id} className="flex items-center gap-3 px-(--card-spacing) py-3">
<Avatar size="sm" aria-hidden="true">
<AvatarImage src={account.avatarUrl} alt="" />
<AvatarFallback>{getInitials(account.name)}</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-medium">{account.company}</span>
<span className="truncate text-xs text-muted-foreground">
{account.name} · {PLAN_NAMES[account.plan] ?? account.plan}
</span>
</div>
<time dateTime={account.createdAt.toISOString()} className="shrink-0 text-xs text-muted-foreground tabular-nums">
{since(account.createdAt)}
</time>
</li>
))}
</ul>
</CardContent>
</Card>
</DashboardGridItem>
</DashboardGrid>
</ClassicShell>
</CardContent>
</Card>
</div>
</section>
)
}
export default DashboardShell01"use client"
import * as React from "react"
import Link from "next/link"
import { LogOutIcon, SettingsIcon, UserRoundIcon } from "lucide-react"
import { breadcrumbsFor, findNavItem, type NavConfig, type NavItem } from "@/lib/nav-config"
import { AppSidebar, type NavItem as SidebarItem } from "@/components/ui/app-sidebar"
import { navIcon } from "@/components/ui/app-shell/icons"
import { DashboardShell } from "@/components/ui/dashboard-shell"
import { HeaderBar } from "@/components/ui/header-bar"
import { NotificationCenter } from "@/components/ui/notification-center"
import { UserMenu } from "@/components/ui/user-menu"
import type { BellRow, ShellPerson } from "./dashboard-shell-01.data"
function toItem(item: NavItem): SidebarItem {
return { title: item.title, href: item.href, icon: navIcon(item.icon), badge: item.badge }
}
const link = (href: string, props: React.ComponentProps<"a">) => <Link href={href} {...props} />
export type ClassicShellProps = {
nav: NavConfig
activeHref: string
user: ShellPerson
bell: BellRow[]
/** What the bell dates its rows against. */
now: Date
/** The page, drawn on the server. */
children: React.ReactNode
}
/**
* The classic frame: a sidebar of the dashboard's pages on the left, a bar
* across the top of the page with the toggle, the trail, the bell and the
* account, and the page under it. Contained — it keeps to its band: the rail
* is placed in the band, the page is no second main, ⌘B collapses the rail to
* its icons only while focus is inside, and the landmarks are named for the
* product, so a page holding two shells can tell them apart. Marking a
* notification read is the reader's own, for the session.
*/
export function ClassicShell({ nav, activeHref, user, bell, now, children }: ClassicShellProps) {
const product = [nav.brand.name, nav.brand.caption].filter(Boolean).join(" ")
const current = findNavItem(nav, activeHref)
// The page names itself in its header, so the trail stops at its parent —
// the way the kit's AppShell cuts it — and never runs empty.
const full = breadcrumbsFor(nav, activeHref)
const trail = full.length < 2 ? full : full.slice(0, -1)
const [readIds, setReadIds] = React.useState<string[]>([])
return (
<DashboardShell
contained
className="md:min-h-[44rem]"
sidebar={
<AppSidebar
collapsible="icon"
variant="sidebar"
navLabel={product}
brand={{
name: nav.brand.name,
description: nav.brand.caption,
// The page you are on is not a link: the brand points at the Overview.
href: nav.brand.href === current?.href ? undefined : nav.brand.href,
logo: <span aria-hidden="true" className="text-sm font-semibold">{nav.brand.initial}</span>,
}}
groups={nav.groups.map((group) => ({ label: group.label, items: group.items.map(toItem) }))}
secondary={nav.footer?.map(toItem)}
activePath={current?.href}
renderLink={link}
/>
}
header={
<HeaderBar
breadcrumbLabel={`${product} breadcrumb`}
breadcrumbs={trail.map((crumb) => ({ label: crumb.title, href: crumb.href }))}
lastCrumbIsPage={full.length < 2}
renderLink={link}
actions={
<>
<NotificationCenter
now={now}
notifications={bell.map((row) => ({
id: row.id,
title: row.title,
description: row.description,
time: row.at,
read: row.read || readIds.includes(row.id),
}))}
onMarkRead={(id) => setReadIds((ids) => (ids.includes(id) ? ids : [...ids, id]))}
onMarkAllRead={() => setReadIds(bell.map((row) => row.id))}
/>
<UserMenu
user={{ name: user.name, email: user.email, src: user.avatarUrl }}
items={[
{ label: "Your profile", href: "/profile", icon: <UserRoundIcon aria-hidden="true" /> },
{ label: "Settings", href: "/settings", icon: <SettingsIcon aria-hidden="true" /> },
{ label: "Log out", href: "/sign-in", icon: <LogOutIcon aria-hidden="true" />, separatorBefore: true },
]}
/>
</>
}
/>
}
>
{children}
</DashboardShell>
)
}/**
* What `dashboard-shell-01` reads: the SaaS dashboard's own nav, the
* signed-in member (the store's first `db.members` row) and their latest
* `db.notifications` for the shell; and for the page it frames, the overview,
* a month of the business counted from `db.subscriptions`, `db.plans` and
* `db.customers`. A month is the 30 days up to `REFERENCE_DATE`, measured
* against the 30 before.
*
* Selectors only, reading `db` inside functions and run on the server. The
* island takes what they return as props; it never imports this module.
*/
import { NAV, ROUTES } from "@/lib/dashboards/saas/nav"
import { type NavConfig } from "@/lib/nav-config"
import { REFERENCE_DATE, db, type Customer, type Subscription } from "@/lib/sample-data"
const DAY_MS = 86_400_000
export const WINDOW_DAYS = 30
/** The page the shell frames. */
export const ACTIVE = ROUTES.overview
export type ShellPerson = { name: string; email: string; avatarUrl?: string }
export type BellRow = { id: string; title: string; description?: string; at: Date; read: boolean }
export function shell(): { nav: NavConfig; user: ShellPerson; bell: BellRow[] } {
const person = db.members.all()[0]
return {
nav: NAV,
user: { name: person.name, email: person.email, avatarUrl: person.avatarUrl },
bell: [...db.notifications.all()]
.sort((a, b) => b.at.getTime() - a.at.getTime())
.slice(0, 6)
.map(({ id, title, description, at, read }) => ({ id, title, description, at, read })),
}
}
/**
* A subscription pays on a day if it had started and not been cancelled, is
* past its trial and charges something: a free plan is an account, not a
* paying one. Past due still counts — it is owed.
*/
function payingOn(subscription: Subscription, at: number): boolean {
if (subscription.startedAt.getTime() > at) return false
if (subscription.cancelledAt && subscription.cancelledAt.getTime() <= at) return false
return subscription.status !== "trialing" && subscription.mrrCents > 0
}
export type Overview = {
mrrCents: number
paying: number
trialing: number
pastDue: number
pastDueCents: number
newCustomers: number
newCustomersBefore: number
byPlan: { plan: string; mrrCents: number; accounts: number }[]
signups: Pick<Customer, "id" | "name" | "company" | "plan" | "avatarUrl" | "createdAt">[]
}
export function overview(): Overview {
const now = REFERENCE_DATE.getTime()
const monthAgo = now - WINDOW_DAYS * DAY_MS
const subscriptions = db.subscriptions.all()
const plans = new Map(db.plans.all().map((plan) => [plan.id, plan.name]))
const live = subscriptions.filter((subscription) => payingOn(subscription, now))
const customers = db.customers.all()
const joined = (from: number, to: number) =>
customers.filter((customer) => customer.createdAt.getTime() >= from && customer.createdAt.getTime() < to).length
const byPlan = new Map<string, { mrrCents: number; accounts: number }>()
for (const subscription of live) {
const name = plans.get(subscription.planId) ?? "Other"
const entry = byPlan.get(name) ?? { mrrCents: 0, accounts: 0 }
byPlan.set(name, { mrrCents: entry.mrrCents + subscription.mrrCents, accounts: entry.accounts + 1 })
}
const pastDue = live.filter((subscription) => subscription.status === "past_due")
return {
mrrCents: live.reduce((total, subscription) => total + subscription.mrrCents, 0),
paying: live.length,
trialing: subscriptions.filter((subscription) => subscription.status === "trialing").length,
pastDue: pastDue.length,
pastDueCents: pastDue.reduce((total, subscription) => total + subscription.mrrCents, 0),
newCustomers: joined(monthAgo, now),
newCustomersBefore: joined(monthAgo - WINDOW_DAYS * DAY_MS, monthAgo),
byPlan: [...byPlan.entries()]
.map(([plan, entry]) => ({ plan, ...entry }))
.sort((a, b) => b.mrrCents - a.mrrCents),
signups: [...customers]
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
.slice(0, 5)
.map(({ id, name, company, plan, avatarUrl, createdAt }) => ({ id, name, company, plan, avatarUrl, createdAt })),
}
}
/** "Now" for the shell: the store's reference date, never the clock. */
export function asOf(): Date {
return REFERENCE_DATE
}npx shadcn@latest add @vibra/dashboard-shell-01Inset shell: the page on a sheet of its own
dashboard-shell-02The E-commerce dashboard's sidebar as the ground and the order book on a ringed sheet beside it; the sidebar slides away and the sheet takes the band.
import * as React from "react"
import Link from "next/link"
import { BanknoteIcon, PackageCheckIcon, RotateCcwIcon, ShoppingBagIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { ORDER_STATUS_MAP } from "@/lib/dashboards/ecommerce/vocabulary"
import { formatCurrency, formatDate, formatNumber, formatRelative, getInitials } from "@/lib/format"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { buttonVariants } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { PageHeader } from "@/components/ui/page-header"
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"
import { StatusBadge } from "@/components/ui/status-badge"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { InsetShell } from "./dashboard-shell-02.client"
import { ACTIVE, asOf, orderBook, shell, type OrderRow } from "./dashboard-shell-02.data"
const DAY_MS = 86_400_000
const dollars = (cents: number) => formatCurrency(cents / 100, "USD", { maximumFractionDigits: 0 })
/** Where an order stands, in the order book's words; the tones are the dashboard's own. */
const STATUS_LABELS: Record<OrderRow["status"], string> = {
pending: "Awaiting payment",
paid: "To ship",
fulfilled: "Shipped",
refunded: "Refunded",
cancelled: "Cancelled",
}
/**
* The inset shell: the E-commerce dashboard's own sidebar as the ground, the
* page on a sheet of its own beside it — the order book, with Orders open in
* the sidebar and the order book marked current under it. The sidebar slides
* away and the sheet takes the band; the account sits at the sidebar's foot,
* the bell in the bar.
*
* A server component: the shell's nav, person and bell and the page's orders
* come from `./dashboard-shell-02.data`; the page is drawn here and handed to
* the shell island.
*/
export function DashboardShell02() {
const titleId = React.useId()
const frame = shell()
const book = orderBook()
const now = asOf()
const placed = (date: Date) =>
now.getTime() - date.getTime() < 7 * DAY_MS ? formatRelative(date, now) : formatDate(date, "medium", { timeZone: "UTC" })
return (
<section
data-section="dashboard-shell-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">
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>Northwind Store</h2>
</CardTitle>
<CardDescription>The page on a sheet of its own, inset in the sidebar’s ground</CardDescription>
</CardHeader>
<CardContent className="p-0">
<InsetShell nav={frame.nav} activeHref={ACTIVE} user={frame.user} bell={frame.bell} now={now}>
<PageHeader
as="h3"
title="Orders"
description="Every order, the newest first."
actions={
<Link href="/ecommerce/orders/overview" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
Order insights
</Link>
}
/>
<StatCardGroup columns={4} divided>
<StatCard
label="Awaiting payment"
value={formatNumber(book.byStatus.pending.count)}
description={`${dollars(book.byStatus.pending.cents)} not yet taken`}
icon={<BanknoteIcon aria-hidden="true" />}
/>
<StatCard
label="To ship"
value={formatNumber(book.byStatus.paid.count)}
description={`${dollars(book.byStatus.paid.cents)} paid for`}
icon={<PackageCheckIcon aria-hidden="true" />}
/>
<StatCard
label="Placed this week"
value={formatNumber(book.placedThisWeek)}
description="the last seven days"
icon={<ShoppingBagIcon aria-hidden="true" />}
/>
<StatCard
label="Refunded"
value={formatNumber(book.byStatus.refunded.count)}
description={`${dollars(book.byStatus.refunded.cents)} handed back`}
icon={<RotateCcwIcon aria-hidden="true" />}
/>
</StatCardGroup>
<Card>
<CardHeader>
<CardTitle>Latest orders</CardTitle>
<CardDescription>The newest {book.latest.length}</CardDescription>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="bg-surface">Order</TableHead>
<TableHead className="bg-surface">Customer</TableHead>
<TableHead className="hidden bg-surface sm:table-cell">Placed</TableHead>
<TableHead className="bg-surface text-right">Total</TableHead>
<TableHead className="hidden bg-surface md:table-cell">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{book.latest.map((order) => (
<TableRow key={order.id}>
<TableCell className="font-mono text-xs">{order.number}</TableCell>
<TableCell>
<span className="flex min-w-0 items-center gap-2">
<Avatar size="sm" aria-hidden="true">
<AvatarImage src={order.customer?.avatarUrl} alt="" />
<AvatarFallback>{getInitials(order.customer?.name ?? "Walk in")}</AvatarFallback>
</Avatar>
<span className="flex min-w-0 flex-col">
<span className="truncate">{order.customer?.name ?? "Walk-in"}</span>
<span className="truncate text-xs text-muted-foreground">
{order.items} {order.items === 1 ? "item" : "items"} · {order.channel === "pos" ? "in the shop" : "online"}
</span>
</span>
</span>
</TableCell>
<TableCell className="hidden text-muted-foreground sm:table-cell">
<time dateTime={order.placedAt.toISOString()}>{placed(order.placedAt)}</time>
</TableCell>
<TableCell className="text-right font-medium">{formatCurrency(order.totalCents / 100)}</TableCell>
<TableCell className="hidden md:table-cell">
<StatusBadge status={order.status} label={STATUS_LABELS[order.status]} map={ORDER_STATUS_MAP} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</InsetShell>
</CardContent>
</Card>
</div>
</section>
)
}
export default DashboardShell02"use client"
import * as React from "react"
import Link from "next/link"
import { LogOutIcon, SettingsIcon, UserRoundIcon } from "lucide-react"
import { breadcrumbsFor, findNavItem, type NavConfig, type NavItem } from "@/lib/nav-config"
import { AppSidebar, type NavItem as SidebarItem } from "@/components/ui/app-sidebar"
import { navIcon } from "@/components/ui/app-shell/icons"
import { DashboardShell } from "@/components/ui/dashboard-shell"
import { HeaderBar } from "@/components/ui/header-bar"
import { NotificationCenter } from "@/components/ui/notification-center"
import { UserMenu } from "@/components/ui/user-menu"
import type { BellRow, ShellPerson } from "./dashboard-shell-02.data"
function toItem(item: NavItem): SidebarItem {
return {
title: item.title,
href: item.href,
icon: navIcon(item.icon),
badge: item.badge,
items: item.items?.map((child) => ({ title: child.title, href: child.href })),
}
}
const link = (href: string, props: React.ComponentProps<"a">) => <Link href={href} {...props} />
export type InsetShellProps = {
nav: NavConfig
activeHref: string
user: ShellPerson
bell: BellRow[]
now: Date
children: React.ReactNode
}
/**
* The inset frame: the sidebar is the ground, and the page sits on it as a
* sheet of its own — ringed, a step off the ground — with the bar along its
* top. The account lives at the foot of the sidebar, the bell in the bar.
* The toggle, or ⌘B while focus is in the band, slides the sidebar away and
* the sheet takes the band's width; on a phone the sidebar is a sheet over the
* page. Contained: it keeps to its band and names its landmarks for the store.
*/
export function InsetShell({ nav, activeHref, user, bell, now, children }: InsetShellProps) {
const store = nav.brand.name
const current = findNavItem(nav, activeHref)
const full = breadcrumbsFor(nav, activeHref)
const trail = full.length < 2 ? full : full.slice(0, -1)
const [readIds, setReadIds] = React.useState<string[]>([])
return (
<DashboardShell
contained
className="md:min-h-[46rem]"
sidebar={
<AppSidebar
collapsible="offcanvas"
variant="inset"
navLabel={store}
brand={{
name: store,
description: nav.brand.caption,
href: nav.brand.href === current?.href ? undefined : nav.brand.href,
logo: <span aria-hidden="true" className="text-sm font-semibold">{nav.brand.initial}</span>,
}}
groups={nav.groups.map((group) => ({ label: group.label, items: group.items.map(toItem) }))}
secondary={nav.footer?.map(toItem)}
activePath={current?.href}
renderLink={link}
footer={
<UserMenu
trigger="full"
align="start"
user={{ name: user.name, email: user.email, src: user.avatarUrl }}
items={[
{ label: "Your profile", href: "/profile", icon: <UserRoundIcon aria-hidden="true" /> },
{ label: "Settings", href: "/settings", icon: <SettingsIcon aria-hidden="true" /> },
{ label: "Log out", href: "/sign-in", icon: <LogOutIcon aria-hidden="true" />, separatorBefore: true },
]}
/>
}
/>
}
header={
<HeaderBar
breadcrumbLabel={`${store} breadcrumb`}
breadcrumbs={trail.map((crumb) => ({ label: crumb.title, href: crumb.href }))}
lastCrumbIsPage={full.length < 2}
renderLink={link}
actions={
<NotificationCenter
now={now}
notifications={bell.map((row) => ({
id: row.id,
title: row.title,
description: row.description,
time: row.at,
read: row.read || readIds.includes(row.id),
}))}
onMarkRead={(id) => setReadIds((ids) => (ids.includes(id) ? ids : [...ids, id]))}
onMarkAllRead={() => setReadIds(bell.map((row) => row.id))}
/>
}
/>
}
>
{children}
</DashboardShell>
)
}/**
* What `dashboard-shell-02` reads: the E-commerce dashboard's own nav, the
* signed-in member and their latest notifications for the shell; and for the
* page it frames, the order book — every `db.orders` row counted by where it
* stands, and the newest of them with the `db.customers` who placed them.
* "This week" is the seven days up to `REFERENCE_DATE`.
*
* Selectors only, reading `db` inside functions and run on the server. The
* island takes what they return as props; it never imports this module.
*/
import { NAV, ROUTES } from "@/lib/dashboards/ecommerce/nav"
import { type NavConfig } from "@/lib/nav-config"
import { REFERENCE_DATE, db, type Order } from "@/lib/sample-data"
const DAY_MS = 86_400_000
/** The page the shell frames: the order book, under Orders. */
export const ACTIVE = ROUTES.orders
export type ShellPerson = { name: string; email: string; avatarUrl?: string }
export type BellRow = { id: string; title: string; description?: string; at: Date; read: boolean }
export function shell(): { nav: NavConfig; user: ShellPerson; bell: BellRow[] } {
const person = db.members.all()[0]
return {
nav: NAV,
user: { name: person.name, email: person.email, avatarUrl: person.avatarUrl },
bell: [...db.notifications.all()]
.sort((a, b) => b.at.getTime() - a.at.getTime())
.slice(0, 6)
.map(({ id, title, description, at, read }) => ({ id, title, description, at, read })),
}
}
export type OrderRow = Pick<Order, "id" | "number" | "status" | "channel" | "totalCents" | "placedAt"> & {
items: number
customer: { name: string; avatarUrl?: string } | undefined
}
export type OrderBook = {
/** How many orders stand at each status, and what they come to. */
byStatus: Record<Order["status"], { count: number; cents: number }>
placedThisWeek: number
latest: OrderRow[]
}
export function orderBook(limit = 8): OrderBook {
const orders = [...db.orders.all()].sort((a, b) => b.placedAt.getTime() - a.placedAt.getTime())
const customers = new Map(db.customers.all().map((customer) => [customer.id, customer]))
const byStatus = { pending: { count: 0, cents: 0 }, paid: { count: 0, cents: 0 }, fulfilled: { count: 0, cents: 0 }, refunded: { count: 0, cents: 0 }, cancelled: { count: 0, cents: 0 } }
for (const order of orders) {
byStatus[order.status].count += 1
byStatus[order.status].cents += order.totalCents
}
const weekAgo = REFERENCE_DATE.getTime() - 7 * DAY_MS
return {
byStatus,
placedThisWeek: orders.filter((order) => order.placedAt.getTime() >= weekAgo).length,
latest: orders.slice(0, limit).map((order) => {
const customer = customers.get(order.customerId)
return {
id: order.id,
number: order.number,
status: order.status,
channel: order.channel,
totalCents: order.totalCents,
placedAt: order.placedAt,
items: order.items.reduce((total, item) => total + item.qty, 0),
customer: customer ? { name: customer.name, avatarUrl: customer.avatarUrl } : undefined,
}
}),
}
}
/** "Now" for the shell: the store's reference date, never the clock. */
export function asOf(): Date {
return REFERENCE_DATE
}npx shadcn@latest add @vibra/dashboard-shell-02Top navigation, no sidebar
dashboard-shell-03The CRM dashboard's pages as tabs under a bar with the brand, the bell and the account, around its pipeline; a phone opens the pages in a sheet.
import * as React from "react"
import { formatCurrency, formatNumber, formatPercent, getInitials } from "@/lib/format"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { PageHeader } from "@/components/ui/page-header"
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"
import { StatusBadge, type StatusVariant } from "@/components/ui/status-badge"
import { TopNavShell } from "./dashboard-shell-03.client"
import { ACTIVE, CLOSING_DAYS, asOf, pipeline, shell, type StageTotal } from "./dashboard-shell-03.data"
const dollars = (cents: number) => formatCurrency(cents / 100, "USD", { maximumFractionDigits: 0 })
/** "Sep 12", in UTC where the data lives. */
const DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" })
/** A stage as the pipeline names it, and the tone a deal in it wears. */
const STAGES: Record<StageTotal["stage"], { label: string; tone: StatusVariant }> = {
lead: { label: "Lead", tone: "neutral" },
qualified: { label: "Qualified", tone: "info" },
proposal: { label: "Proposal", tone: "primary" },
negotiation: { label: "Negotiation", tone: "warning" },
won: { label: "Won", tone: "success" },
lost: { label: "Lost", tone: "danger" },
}
/**
* A shell with the navigation on top and no sidebar: the CRM dashboard's own
* pages as tabs under the bar, Pipeline the one marked current, and the page
* under them — what each open stage holds and is worth, the win rate of the
* deals already decided, and the deals due to close this month. On a phone the
* tabs fold into a menu that opens as a sheet.
*
* A server component: the nav, person and bell and the page's deals come from
* `./dashboard-shell-03.data`; the page is drawn here and handed to the shell
* island.
*/
export function DashboardShell03() {
const titleId = React.useId()
const frame = shell()
const page = pipeline()
const decided = page.won + page.lost
return (
<section
data-section="dashboard-shell-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}>Northwind CRM</h2>
</CardTitle>
<CardDescription>Navigation along the top, and no sidebar</CardDescription>
</CardHeader>
<CardContent className="p-0">
<TopNavShell nav={frame.nav} activeHref={ACTIVE} user={frame.user} bell={frame.bell} now={asOf()}>
<PageHeader
as="h3"
title="Pipeline"
description="Every open deal, by the stage it is at."
meta={
<span className="tabular-nums">
Won {formatNumber(page.won)} of the {formatNumber(decided)} decided ·{" "}
{formatPercent(decided ? page.won / decided : 0, { maximumFractionDigits: 0 })} win rate
</span>
}
/>
<StatCardGroup columns={4} divided>
{page.stages.map((stage) => (
<StatCard
key={stage.stage}
label={STAGES[stage.stage].label}
value={dollars(stage.cents)}
description={`${formatNumber(stage.deals)} ${stage.deals === 1 ? "deal" : "deals"} · ${dollars(stage.weightedCents)} weighted`}
/>
))}
</StatCardGroup>
<Card>
<CardHeader>
<CardTitle>Closing this month</CardTitle>
<CardDescription>
{formatNumber(page.closingCount)} open deals due to close in the next {CLOSING_DAYS} days
</CardDescription>
</CardHeader>
<CardContent className="p-0">
<ul aria-label="Deals due to close, soonest first" className="divide-y">
{page.closing.map((deal) => (
// On a phone every row takes two lines the same way: who
// on the first, and the stage, the date and the sum on
// the second, set to the right as they are on one line
// from sm, so the dates and the sums stay in columns.
<li key={deal.id} className="flex flex-wrap items-center gap-x-4 gap-y-2 px-(--card-spacing) py-3">
<div className="flex min-w-0 flex-1 basis-full items-center gap-3 sm:basis-56">
<Avatar size="sm" aria-hidden="true">
<AvatarImage src={deal.owner?.avatarUrl} alt="" />
<AvatarFallback>{getInitials(deal.owner?.name ?? deal.company)}</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium">{deal.company}</span>
<span className="truncate text-xs text-muted-foreground">
{deal.owner ? `${deal.owner.name} · ` : ""}
{deal.probability}% likely
</span>
</div>
</div>
<div className="flex flex-1 items-center justify-end gap-4 sm:flex-none">
<StatusBadge status={deal.stage} label={STAGES[deal.stage].label} variant={STAGES[deal.stage].tone} />
<span className="w-14 text-xs text-muted-foreground tabular-nums">{DAY.format(deal.expectedCloseAt)}</span>
<span className="w-20 text-right text-sm font-medium tabular-nums">{dollars(deal.amountCents)}</span>
</div>
</li>
))}
</ul>
</CardContent>
</Card>
</TopNavShell>
</CardContent>
</Card>
</div>
</section>
)
}
export default DashboardShell03"use client"
import * as React from "react"
import Link from "next/link"
import { LogOutIcon, MenuIcon, SettingsIcon, UserRoundIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { findNavItem, type NavConfig } from "@/lib/nav-config"
import { Button } from "@/components/ui/button"
import { NavTabs } from "@/components/ui/nav-tabs"
import { NotificationCenter } from "@/components/ui/notification-center"
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet"
import { UserMenu } from "@/components/ui/user-menu"
import type { BellRow, ShellPerson } from "./dashboard-shell-03.data"
export type TopNavShellProps = {
nav: NavConfig
activeHref: string
user: ShellPerson
bell: BellRow[]
now: Date
children: React.ReactNode
}
/**
* A shell with no sidebar: the product's pages as a row of tabs under the bar
* that carries the brand, the bell and the account, and the page under both.
* The page it is on is the tab marked current. Below md the tabs give way to
* a menu button, and the pages open in the kit's sheet — a modal dialog, so
* focus stays in it, Escape closes it and focus goes back to the button; a
* page chosen there closes it too. Contained: nothing of it reaches past its
* band, and its landmark is named for the product.
*/
export function TopNavShell({ nav, activeHref, user, bell, now, children }: TopNavShellProps) {
const [open, setOpen] = React.useState(false)
const [readIds, setReadIds] = React.useState<string[]>([])
const current = findNavItem(nav, activeHref)?.href
const pages = [...nav.groups.flatMap((group) => group.items), ...(nav.footer ?? [])]
const tabs = nav.groups.flatMap((group) => group.items)
const product = nav.brand.name
return (
<div data-slot="top-nav-shell" className="flex flex-col bg-background">
{/* On a phone the bar draws the rule under itself; from md the tabs bring it. */}
<div className="flex h-14 items-center gap-3 border-b border-rule px-4 md:border-b-0 md:px-6">
<Link
href={nav.brand.href}
className="focus-ring -mx-1 flex items-center gap-2 rounded-md px-1 py-1"
aria-label={`${product}, home`}
>
<span aria-hidden="true" className="flex size-7 items-center justify-center rounded-md bg-sidebar-primary text-sm font-semibold text-sidebar-primary-foreground">
{nav.brand.initial}
</span>
<span className="text-sm font-semibold">{product}</span>
</Link>
<div className="ml-auto flex items-center gap-1">
<NotificationCenter
now={now}
notifications={bell.map((row) => ({
id: row.id,
title: row.title,
description: row.description,
time: row.at,
read: row.read || readIds.includes(row.id),
}))}
onMarkRead={(id) => setReadIds((ids) => (ids.includes(id) ? ids : [...ids, id]))}
onMarkAllRead={() => setReadIds(bell.map((row) => row.id))}
/>
<UserMenu
user={{ name: user.name, email: user.email, src: user.avatarUrl }}
items={[
{ label: "Your profile", href: "/profile", icon: <UserRoundIcon aria-hidden="true" /> },
{ label: "Settings", href: "/settings", icon: <SettingsIcon aria-hidden="true" /> },
{ label: "Log out", href: "/sign-in", icon: <LogOutIcon aria-hidden="true" />, separatorBefore: true },
]}
/>
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger
render={
<Button variant="ghost" size="icon" aria-label="Open the menu" data-slot="mobile-nav-trigger" className="md:hidden">
<MenuIcon />
</Button>
}
/>
<SheetContent side="left" className="w-72 gap-0 p-0">
<SheetHeader className="h-14 flex-row items-center border-b px-4 py-0">
<SheetTitle className="text-sm">{product}</SheetTitle>
<SheetDescription className="sr-only">Every page of {product}.</SheetDescription>
</SheetHeader>
<nav aria-label={product} className="flex flex-col gap-0.5 p-2">
{pages.map((page) => (
<Link
key={page.href}
href={page.href}
onClick={() => setOpen(false)}
aria-current={page.href === current ? "page" : undefined}
className="focus-ring flex h-10 items-center rounded-md px-3 text-sm transition-colors duration-(--duration-fast) ease-(--ease-standard) hover:bg-muted aria-[current=page]:bg-brand-muted aria-[current=page]:font-medium"
>
{page.title}
</Link>
))}
</nav>
</SheetContent>
</Sheet>
</div>
</div>
<NavTabs
aria-label={product}
items={tabs.map((tab) => ({ label: tab.title, href: tab.href }))}
activeHref={current}
className="hidden px-4 md:flex md:px-6"
renderLink={(item, content, className) => (
<Link
href={item.href}
data-slot="nav-tabs-item"
data-active={item.href === current || undefined}
aria-current={item.href === current ? "page" : undefined}
className={cn(className, "focus-ring-inset")}
>
{content}
</Link>
)}
/>
<div className="flex flex-col gap-6 p-4 md:p-6">{children}</div>
</div>
)
}/**
* What `dashboard-shell-03` reads: the CRM dashboard's own nav, the signed-in
* member and their latest notifications for the bar; and for the page it
* frames, the pipeline — every `db.deals` row by stage, what each stage is
* worth, the win rate of the deals already decided, and the open deals due to
* close in the next 30 days with the `db.members` who own them. "Now" is
* `REFERENCE_DATE`.
*
* Selectors only, reading `db` inside functions and run on the server. The
* island takes what they return as props; it never imports this module.
*/
import { NAV, ROUTES } from "@/lib/dashboards/crm/nav"
import { type NavConfig } from "@/lib/nav-config"
import { REFERENCE_DATE, db, type Deal } from "@/lib/sample-data"
const DAY_MS = 86_400_000
export const CLOSING_DAYS = 30
/** The page the shell frames. */
export const ACTIVE = ROUTES.pipeline
export type ShellPerson = { name: string; email: string; avatarUrl?: string }
export type BellRow = { id: string; title: string; description?: string; at: Date; read: boolean }
export function shell(): { nav: NavConfig; user: ShellPerson; bell: BellRow[] } {
const person = db.members.all()[0]
return {
nav: NAV,
user: { name: person.name, email: person.email, avatarUrl: person.avatarUrl },
bell: [...db.notifications.all()]
.sort((a, b) => b.at.getTime() - a.at.getTime())
.slice(0, 6)
.map(({ id, title, description, at, read }) => ({ id, title, description, at, read })),
}
}
/** The stages a deal moves through while it is open, in order. */
export const OPEN_STAGES: Deal["stage"][] = ["lead", "qualified", "proposal", "negotiation"]
export type StageTotal = { stage: Deal["stage"]; deals: number; cents: number; weightedCents: number }
export type ClosingDeal = Pick<Deal, "id" | "company" | "name" | "stage" | "amountCents" | "probability" | "expectedCloseAt"> & {
owner: { name: string; avatarUrl?: string } | undefined
}
export function pipeline(limit = 6): { stages: StageTotal[]; won: number; lost: number; closing: ClosingDeal[]; closingCount: number } {
const deals = db.deals.all()
const members = new Map(db.members.all().map((member) => [member.id, member]))
const now = REFERENCE_DATE.getTime()
const closing = deals
.filter((deal) => OPEN_STAGES.includes(deal.stage))
.filter((deal) => deal.expectedCloseAt.getTime() >= now && deal.expectedCloseAt.getTime() < now + CLOSING_DAYS * DAY_MS)
.sort((a, b) => a.expectedCloseAt.getTime() - b.expectedCloseAt.getTime() || b.amountCents - a.amountCents)
return {
stages: OPEN_STAGES.map((stage) => {
const here = deals.filter((deal) => deal.stage === stage)
return {
stage,
deals: here.length,
cents: here.reduce((total, deal) => total + deal.amountCents, 0),
weightedCents: Math.round(here.reduce((total, deal) => total + (deal.amountCents * deal.probability) / 100, 0)),
}
}),
won: deals.filter((deal) => deal.stage === "won").length,
lost: deals.filter((deal) => deal.stage === "lost").length,
closingCount: closing.length,
closing: closing.slice(0, limit).map((deal) => {
const owner = members.get(deal.owner)
return {
id: deal.id,
company: deal.company,
name: deal.name,
stage: deal.stage,
amountCents: deal.amountCents,
probability: deal.probability,
expectedCloseAt: deal.expectedCloseAt,
owner: owner ? { name: owner.name, avatarUrl: owner.avatarUrl } : undefined,
}
}),
}
}
/** "Now" for the shell: the store's reference date, never the clock. */
export function asOf(): Date {
return REFERENCE_DATE
}npx shadcn@latest add @vibra/dashboard-shell-03Dual sidebar: a rail of pages and a panel of context
dashboard-shell-04The Engineering dashboard's pages as a rail of named icons beside a filterable panel of every service, worst first, and the picked service's page beside both.
import * as React from "react"
import { ActivityIcon, SirenIcon, TimerIcon } from "lucide-react"
import { formatDuration, formatNumber } from "@/lib/format"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { PageHeader } from "@/components/ui/page-header"
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"
import { StatusBadge, type StatusVariant } from "@/components/ui/status-badge"
import { UptimeBar, type UptimeDayStatus } from "@/components/ui/uptime-bar"
import { DualSidebarShell } from "./dashboard-shell-04.client"
import { ACTIVE, INCIDENT_DAYS, UPTIME_DAYS, services, shell, type ServiceIncident, type ServiceRecord } from "./dashboard-shell-04.data"
/** How a service stands now, in a status page's words and tones. */
const SERVICE_STATUS: Record<ServiceRecord["status"], { label: string; tone: StatusVariant }> = {
operational: { label: "Operational", tone: "success" },
degraded: { label: "Degraded", tone: "warning" },
partial_outage: { label: "Partial outage", tone: "warning" },
major_outage: { label: "Major outage", tone: "danger" },
maintenance: { label: "Maintenance", tone: "info" },
}
const INCIDENT_STATUS: Record<ServiceIncident["status"], { label: string; tone: StatusVariant }> = {
investigating: { label: "Investigating", tone: "danger" },
identified: { label: "Identified", tone: "warning" },
monitoring: { label: "Monitoring", tone: "info" },
resolved: { label: "Resolved", tone: "success" },
}
/** "Aug 29", in UTC where the data lives. */
const DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" })
/** A day's bar, by the Engineering dashboard's rule: up from 99.5%, degraded down to 97%, down below it. */
const dayStatus = (uptime: number): UptimeDayStatus => (uptime >= 99.5 ? "up" : uptime >= 97 ? "degraded" : "down")
/** One service's page: where it stands, its month, and what went wrong with it lately. */
function ServicePage({ service }: { service: ServiceRecord }) {
const status = SERVICE_STATUS[service.status]
const open = service.incidents.filter((incident) => incident.status !== "resolved").length
return (
<>
<PageHeader
as="h3"
title={service.name}
badge={<StatusBadge status={service.status} label={status.label} variant={status.tone} />}
description={
<>
<span className="font-mono">{service.region}</span> · watched every minute
</>
}
/>
<StatCardGroup columns={3} divided>
<StatCard
label="Uptime"
value={`${service.uptime}%`}
description={`the last ${UPTIME_DAYS} days`}
icon={<ActivityIcon aria-hidden="true" />}
/>
<StatCard
label="Open incidents"
value={formatNumber(open)}
description={`${formatNumber(service.incidents.length)} in ${INCIDENT_DAYS} days`}
icon={<SirenIcon aria-hidden="true" />}
/>
<StatCard
label="Time to resolve"
value={service.meanRepairMs === null ? "None yet" : formatDuration(service.meanRepairMs)}
description="on average, resolved ones"
icon={<TimerIcon aria-hidden="true" />}
/>
</StatCardGroup>
<Card>
<CardHeader>
<CardTitle>Last {UPTIME_DAYS} days</CardTitle>
<CardDescription>Each day up, degraded or down, by the minutes the probes reached it</CardDescription>
</CardHeader>
<CardContent>
<UptimeBar
uptime={service.uptime}
labelText={`${service.name}, last ${UPTIME_DAYS} days`}
days={service.days.map((day) => ({ date: day.date, status: dayStatus(day.uptime), label: `${day.uptime.toFixed(2)}% up` }))}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Incidents</CardTitle>
<CardDescription>The last {INCIDENT_DAYS} days, newest first</CardDescription>
</CardHeader>
<CardContent className={service.incidents.length ? "p-0" : undefined}>
{service.incidents.length ? (
<ul className="divide-y">
{service.incidents.map((incident) => {
const state = INCIDENT_STATUS[incident.status]
return (
<li key={incident.id} className="flex flex-wrap items-center gap-x-3 gap-y-1 px-(--card-spacing) py-3">
<span className="min-w-0 flex-1 basis-48 truncate text-sm font-medium">{incident.title}</span>
<span className="font-mono text-xs text-muted-foreground">{incident.severity.toUpperCase()}</span>
<span className="text-xs text-muted-foreground tabular-nums">{DAY.format(incident.startedAt)}</span>
<StatusBadge status={incident.status} label={state.label} variant={state.tone} />
</li>
)
})}
</ul>
) : (
<p className="text-sm text-muted-foreground">Nothing in the last {INCIDENT_DAYS} days.</p>
)}
</CardContent>
</Card>
</>
)
}
/**
* A dual sidebar: the Engineering dashboard's pages as a rail of named icons,
* and beside it a panel of what Monitoring is about — every service, worst
* first, filterable — with the picked service's page in the sheet beside
* both. The toggle folds the panel away and keeps the rail; on a phone both
* open in one sheet.
*
* A server component: the nav, the person and every service's page come from
* `./dashboard-shell-04.data` and are drawn here, one page a service, so the
* shell island only has to choose which one shows.
*/
export function DashboardShell04() {
const titleId = React.useId()
const frame = shell()
const records = services()
return (
<section
data-section="dashboard-shell-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}>Northwind Platform</h2>
</CardTitle>
<CardDescription>A rail of pages, and a panel of what the page is about</CardDescription>
</CardHeader>
<CardContent className="p-0">
<DualSidebarShell
nav={frame.nav}
activeHref={ACTIVE}
user={frame.user}
services={records.map((service) => ({
id: service.id,
name: service.name,
region: service.region,
status: service.status,
statusLabel: SERVICE_STATUS[service.status].label,
tone: SERVICE_STATUS[service.status].tone,
uptime: service.uptime,
}))}
pages={Object.fromEntries(records.map((service) => [service.id, <ServicePage key={service.id} service={service} />]))}
/>
</CardContent>
</Card>
</div>
</section>
)
}
export default DashboardShell04"use client"
import * as React from "react"
import Link from "next/link"
import { cn } from "@/lib/utils"
import { findNavItem, type NavConfig, type NavItem } from "@/lib/nav-config"
import { navIcon } from "@/components/ui/app-shell/icons"
import { Separator } from "@/components/ui/separator"
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
SidebarTrigger,
useSidebar,
} from "@/components/ui/sidebar"
import { StatusBadge, type StatusVariant } from "@/components/ui/status-badge"
import { UserMenu } from "@/components/ui/user-menu"
/** A service as the panel lists it; its page is drawn on the server and handed in beside it. */
export type ServiceEntry = { id: string; name: string; region: string; status: string; statusLabel: string; tone: StatusVariant; uptime: number }
export type DualSidebarShellProps = {
nav: NavConfig
activeHref: string
user: { name: string; email: string; avatarUrl?: string }
services: ServiceEntry[]
/** Each service's page, by id, drawn on the server. */
pages: Record<string, React.ReactNode>
}
/**
* Two sidebars side by side: a rail of the dashboard's pages — icon-only,
* each named and tipped — and beside it a panel of what the current page is
* about, here every service, worst first, with a filter. Picking a service
* shows its page; the chosen one wears the brand fill, a rail and the weight.
* The toggle (or ⌘B while focus is in the band) folds the panel away —
* out of reach as well as out of sight — and leaves the rail; on a phone both
* open in the one sheet, the pages named in full, and picking a service
* closes it.
*/
export function DualSidebarShell({ nav, activeHref, user, services, pages }: DualSidebarShellProps) {
const [selectedId, setSelectedId] = React.useState(services[0]?.id)
const selected = services.find((service) => service.id === selectedId) ?? services[0]
const current = findNavItem(nav, activeHref)
return (
<SidebarProvider contained className="md:min-h-[44rem]" style={{ "--sidebar-width": "22rem" } as React.CSSProperties}>
<Sidebar collapsible="icon" className="overflow-hidden *:data-[sidebar=sidebar]:flex-row">
<PageRail nav={nav} current={current?.href} user={user} />
<ServicePanel services={services} selectedId={selected?.id} onSelect={setSelectedId} />
</Sidebar>
<SidebarInset>
<div className="flex h-14 shrink-0 items-center gap-2 border-b px-4">
<SidebarTrigger className="-ml-1.5" />
<Separator orientation="vertical" className="mr-1 data-vertical:h-4 data-vertical:self-center" />
<span className="text-sm text-muted-foreground">{current?.title}</span>
<span aria-hidden="true" className="text-sm text-muted-foreground">/</span>
<span className="truncate text-sm font-medium">{selected?.name}</span>
</div>
<div className="flex flex-1 flex-col gap-6 p-4 md:p-6">{selected ? pages[selected.id] : null}</div>
</SidebarInset>
</SidebarProvider>
)
}
/** The dashboard's pages: a narrow rail of named icons, or on a phone a list with their names. */
function PageRail({ nav, current, user }: { nav: NavConfig; current?: string; user: DualSidebarShellProps["user"] }) {
const { isMobile } = useSidebar()
const pages: NavItem[] = [...nav.groups.flatMap((group) => group.items), ...(nav.footer ?? [])]
return (
<Sidebar collapsible="none" className={cn(isMobile ? "h-auto w-full" : "w-[calc(var(--sidebar-width-icon)+1px)]! border-r border-sidebar-border")}>
<SidebarHeader className={cn(isMobile && "px-4")}>
<Link
href={nav.brand.href}
aria-label={`${nav.brand.name}, home`}
className="focus-ring flex size-8 items-center justify-center rounded-md bg-sidebar-primary text-sm font-semibold text-sidebar-primary-foreground"
>
<span aria-hidden="true">{nav.brand.initial}</span>
</Link>
</SidebarHeader>
<SidebarContent>
<nav aria-label={nav.brand.name}>
<SidebarGroup>
<SidebarGroupContent className={cn(!isMobile && "px-0")}>
<SidebarMenu>
{pages.map((page) => {
const Icon = navIcon(page.icon)
return (
<SidebarMenuItem key={page.href}>
<SidebarMenuButton
isActive={page.href === current}
// A rail is icons whether or not it is open, so its tips always show.
tooltip={isMobile ? undefined : { children: page.title, hidden: false }}
className={cn(!isMobile && "px-2.5")}
render={<Link href={page.href} aria-current={page.href === current ? "page" : undefined} />}
>
{Icon ? <Icon /> : null}
<span>{page.title}</span>
</SidebarMenuButton>
</SidebarMenuItem>
)
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</nav>
</SidebarContent>
{isMobile ? null : (
<SidebarFooter className="items-center">
<UserMenu align="start" user={{ name: user.name, email: user.email, src: user.avatarUrl }} />
</SidebarFooter>
)}
</Sidebar>
)
}
/** The page's own context: every service, worst first, filtered by name, one of them shown. */
function ServicePanel({
services,
selectedId,
onSelect,
}: {
services: ServiceEntry[]
selectedId?: string
onSelect: (id: string) => void
}) {
const { isMobile, state, setOpenMobile } = useSidebar()
const [query, setQuery] = React.useState("")
const shown = services.filter((service) => service.name.toLowerCase().includes(query.trim().toLowerCase()))
// Folded, the panel sits squeezed out of sight behind the rail: inert and
// hidden from assistive technology, so none of it takes focus or is read
// out. Focus caught in it as it folds moves on first, to the toggle that
// brings it back — a browser drops focus from an inert element at once, to
// the top of the page — so the attributes are set here, after that move.
const folded = !isMobile && state === "collapsed"
const ref = React.useRef<HTMLDivElement>(null)
React.useLayoutEffect(() => {
const panel = ref.current
if (!panel) return
if (folded && panel.contains(document.activeElement))
panel.closest("[data-sidebar=wrapper]")?.querySelector<HTMLElement>("[data-sidebar=trigger]")?.focus()
panel.toggleAttribute("inert", folded)
if (folded) panel.setAttribute("aria-hidden", "true")
else panel.removeAttribute("aria-hidden")
}, [folded])
return (
<Sidebar ref={ref} collapsible="none" className="min-h-0 flex-1 border-sidebar-border max-md:border-t">
<SidebarHeader className="gap-3 border-b border-sidebar-border p-4">
<div className="flex items-baseline justify-between gap-2">
<span className="text-sm font-semibold text-foreground">Services</span>
<span className="text-xs text-muted-foreground tabular-nums">{services.length} watched</span>
</div>
<SidebarInput
type="search"
aria-label="Filter services"
placeholder="Filter services"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</SidebarHeader>
<SidebarContent>
{shown.length ? (
<ul aria-label="Services, worst first" className="flex flex-col">
{shown.map((service) => {
const chosen = service.id === selectedId
return (
<li key={service.id} className="border-b border-sidebar-border last:border-b-0">
<button
type="button"
aria-current={chosen ? "true" : undefined}
// Its parts are drawn as two lines; read out, they would run together.
aria-label={`${service.name}, ${service.statusLabel}, ${service.uptime}% up over 30 days`}
onClick={() => {
onSelect(service.id)
if (isMobile) setOpenMobile(false)
}}
className={cn(
"relative flex w-full flex-col gap-1 px-4 py-3 text-left transition-colors duration-(--duration-fast) ease-(--ease-standard) hover:bg-sidebar-accent focus-ring-inset",
chosen && "bg-brand-muted font-medium before:absolute before:inset-y-0 before:start-0 before:w-0.5 before:bg-brand hover:bg-brand-muted"
)}
>
<span className="flex items-center justify-between gap-2 text-sm">
<span className="truncate">{service.name}</span>
<StatusBadge status={service.status} label={service.statusLabel} variant={service.tone} size="sm" />
</span>
<span className="flex items-center justify-between gap-2 text-xs font-normal text-muted-foreground">
<span className="font-mono">{service.region}</span>
<span className="tabular-nums">{service.uptime}% up</span>
</span>
</button>
</li>
)
})}
</ul>
) : (
<p className="p-4 text-sm text-muted-foreground">No service matches “{query.trim()}”.</p>
)}
</SidebarContent>
</Sidebar>
)
}/**
* What `dashboard-shell-04` reads: the Engineering dashboard's own nav and the
* signed-in member for the rail; every `db.services` row for the context
* panel beside it; and for the page, each service's last 30 days and its
* `db.incidents` of the last 90, so the page can show whichever service the
* reader picks without asking again. "Now" is `REFERENCE_DATE`.
*
* Selectors only, reading `db` inside functions and run on the server. The
* island takes what they return as props; it never imports this module.
*/
import { NAV, ROUTES } from "@/lib/dashboards/engineering/nav"
import { type NavConfig } from "@/lib/nav-config"
import { REFERENCE_DATE, db, type Incident, type Service } from "@/lib/sample-data"
const DAY_MS = 86_400_000
export const UPTIME_DAYS = 30
export const INCIDENT_DAYS = 90
/** The page the shell frames. */
export const ACTIVE = ROUTES.monitoring
export type ServiceIncident = Pick<Incident, "id" | "title" | "severity" | "status" | "startedAt" | "resolvedAt">
export type ServiceRecord = Pick<Service, "id" | "name" | "region" | "status"> & {
/** One a day, oldest first, ending on `REFERENCE_DATE`: the share of the day it was up, 0–100. */
days: { date: Date; uptime: number }[]
/** What the probes measured over the 30 days, to two places. */
uptime: number
/** Its incidents of the last 90 days, newest first. */
incidents: ServiceIncident[]
/** The mean time from start to resolution of those that are resolved, or null with none. */
meanRepairMs: number | null
}
/** Worst first: the order a platform page reads its services in when something is wrong. */
const SEVERITY: Service["status"][] = ["major_outage", "partial_outage", "degraded", "maintenance", "operational"]
export function services(): ServiceRecord[] {
const today = Date.UTC(REFERENCE_DATE.getUTCFullYear(), REFERENCE_DATE.getUTCMonth(), REFERENCE_DATE.getUTCDate())
const since = REFERENCE_DATE.getTime() - INCIDENT_DAYS * DAY_MS
const incidents = db.incidents.all()
return db.services
.all()
.map((service) => {
const days = service.uptime90d.slice(-UPTIME_DAYS).map((uptime, index, all) => ({
date: new Date(today - (all.length - 1 - index) * DAY_MS),
uptime,
}))
const recent = incidents
.filter((incident) => incident.serviceIds.includes(service.id) && incident.startedAt.getTime() >= since)
.sort((a, b) => b.startedAt.getTime() - a.startedAt.getTime())
const repaired = recent.filter((incident) => incident.resolvedAt)
return {
id: service.id,
name: service.name,
region: service.region,
status: service.status,
days,
uptime: Math.round((days.reduce((total, day) => total + day.uptime, 0) / days.length) * 100) / 100,
incidents: recent.map(({ id, title, severity, status, startedAt, resolvedAt }) => ({ id, title, severity, status, startedAt, resolvedAt })),
meanRepairMs: repaired.length
? repaired.reduce((total, incident) => total + (incident.resolvedAt!.getTime() - incident.startedAt.getTime()), 0) / repaired.length
: null,
}
})
.sort((a, b) => SEVERITY.indexOf(a.status) - SEVERITY.indexOf(b.status) || a.uptime - b.uptime)
}
export function shell(): { nav: NavConfig; user: { name: string; email: string; avatarUrl?: string } } {
const person = db.members.all()[0]
return { nav: NAV, user: { name: person.name, email: person.email, avatarUrl: person.avatarUrl } }
}npx shadcn@latest add @vibra/dashboard-shell-04Shell with a command palette, ⌘K bound to the band
dashboard-shell-05The Finance dashboard's frame around its invoices, run from the kit's command palette: find an unpaid invoice, narrow the ledger, fold the sidebar — ⌘K only while focus is inside.
import * as React from "react"
import { formatCurrency } from "@/lib/format"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { PaletteShell } from "./dashboard-shell-05.client"
import { ACTIVE, ledger, shell } from "./dashboard-shell-05.data"
/** "Sep 23", in UTC where the data lives. */
const DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" })
export type DashboardShell05Props = {
/** Opens the command palette on the first render — for a preview; false when installed. */
defaultOpen?: boolean
}
/**
* A shell run from a command palette: the Finance dashboard's own frame
* around its invoices, with ⌘K bound to the band — open it with focus
* anywhere inside, and the page keeps the chord everywhere else. The palette
* finds an unpaid invoice by number, customer or amount and opens it on the
* ledger, narrows the ledger by standing, and folds the sidebar; the search
* field under the brand and the bar's search button open it too.
*
* A server component: the nav, the person and the ledger come from
* `./dashboard-shell-05.data`, every amount and date written out here, so the
* island gets plain strings.
*/
export function DashboardShell05({ defaultOpen = false }: DashboardShell05Props) {
const titleId = React.useId()
const frame = shell()
return (
<section
data-section="dashboard-shell-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">
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>Northwind Finance</h2>
</CardTitle>
<CardDescription>A shell you can run from the keyboard: ⌘K with focus in the band</CardDescription>
</CardHeader>
<CardContent className="p-0">
<PaletteShell
nav={frame.nav}
activeHref={ACTIVE}
user={frame.user}
defaultOpen={defaultOpen}
rows={ledger().map((row) => ({
id: row.id,
number: row.number,
customer: row.customer,
amount: formatCurrency(row.amountCents / 100),
due: DAY.format(row.dueAt),
standing: row.standing,
}))}
/>
</CardContent>
</Card>
</div>
</section>
)
}
export default DashboardShell05"use client"
import * as React from "react"
import Link from "next/link"
import { FileTextIcon, FilterIcon, LogOutIcon, PanelLeftIcon, SearchIcon, SettingsIcon, UserRoundIcon } from "lucide-react"
import { firstDelivery, matchesHotkey } from "@/lib/hotkeys"
import { breadcrumbsFor, findNavItem, type NavConfig, type NavItem } from "@/lib/nav-config"
import { AppSidebar, type NavItem as SidebarItem } from "@/components/ui/app-sidebar"
import { navIcon } from "@/components/ui/app-shell/icons"
import { Button } from "@/components/ui/button"
import { CommandPalette } from "@/components/ui/command-palette"
import { DashboardShell } from "@/components/ui/dashboard-shell"
import { HeaderBar } from "@/components/ui/header-bar"
import { KbdShortcut } from "@/components/ui/kbd-shortcut"
import { PageHeader } from "@/components/ui/page-header"
import { SegmentedControl } from "@/components/ui/segmented-control"
import { useSidebar } from "@/components/ui/sidebar"
import { StatusBadge, type StatusVariant } from "@/components/ui/status-badge"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { UserMenu } from "@/components/ui/user-menu"
import type { ShellPerson, Standing } from "./dashboard-shell-05.data"
/** An invoice as the ledger draws it: the amount and the dates already written out. */
export type LedgerEntry = { id: string; number: string; customer: string; amount: string; due: string; standing: Standing }
type Filter = Standing | "all"
/** How many invoices the ledger shows at once. */
const PAGE = 8
const STANDING_LABELS: Record<Standing, string> = { open: "Open", overdue: "Overdue", paid: "Paid" }
const STANDING_TONES: Record<Standing, StatusVariant> = { open: "info", overdue: "danger", paid: "success" }
/** The chord that opens the palette — while focus is in the band, and only then. */
const HOTKEY = "mod+k"
function toItem(item: NavItem): SidebarItem {
return { title: item.title, href: item.href, icon: navIcon(item.icon), badge: item.badge }
}
const link = (href: string, props: React.ComponentProps<"a">) => <Link href={href} {...props} />
export type PaletteShellProps = {
nav: NavConfig
activeHref: string
user: ShellPerson
rows: LedgerEntry[]
/** Opens the palette on the first render — for a preview; false when installed. */
defaultOpen?: boolean
}
/**
* A shell run from a command palette. ⌘K / Ctrl+K opens it while focus is
* anywhere in the band — its sidebar, its bar, its page or the palette itself
* — and is left to the page everywhere else; the search field under the brand
* and the bar's search button open the same one. It finds an invoice by
* number, customer or amount and opens it on the page, narrows the ledger, and
* folds the sidebar. What a pick did is said in the page's status line.
*/
export function PaletteShell({ nav, activeHref, user, rows, defaultOpen = false }: PaletteShellProps) {
const product = [nav.brand.name, nav.brand.caption].filter(Boolean).join(" ")
const current = findNavItem(nav, activeHref)
const full = breadcrumbsFor(nav, activeHref)
const trail = full.length < 2 ? full : full.slice(0, -1)
const [open, setOpen] = React.useState(defaultOpen)
const [filter, setFilter] = React.useState<Filter>("all")
const [selectedId, setSelectedId] = React.useState<string>()
const [notice, setNotice] = React.useState("")
// A key pressed in the palette reaches this box twice — React delivers a
// portalled event once per portal container it passes, and the palette's
// dialog is two deep — so each press counts once.
const [once] = React.useState(firstDelivery)
function openInvoice(row: LedgerEntry) {
setFilter(row.standing)
setSelectedId(row.id)
setNotice(`${row.number}, ${row.customer}: ${row.amount}, due ${row.due}.`)
}
function show(next: Filter) {
setFilter(next)
const count = next === "all" ? rows.length : rows.filter((row) => row.standing === next).length
setNotice(`Showing ${count} ${next === "all" ? "" : `${STANDING_LABELS[next].toLowerCase()} `}invoices.`)
}
const search = (
<Button
type="button"
variant="outline"
size="sm"
aria-haspopup="dialog"
onClick={() => setOpen(true)}
className="w-full justify-start gap-2 text-muted-foreground"
>
<SearchIcon aria-hidden="true" />
Search or jump to
<KbdShortcut keys={HOTKEY} size="sm" className="ml-auto" />
</Button>
)
return (
// The one listener for the chord, on the band's own box: React brings a
// key press here from the palette and the phone's sheet too, portals and
// all — caught on the way down, before the palette's field can keep it.
<div
data-slot="palette-shell"
onKeyDownCapture={(event) => {
if (!matchesHotkey(event.nativeEvent, HOTKEY) || !once(event.nativeEvent)) return
event.preventDefault()
setOpen((value) => !value)
}}
>
<DashboardShell
contained
className="md:min-h-[44rem]"
sidebar={
<AppSidebar
collapsible="icon"
variant="sidebar"
navLabel={product}
brand={{
name: nav.brand.name,
description: nav.brand.caption ?? "Money in and out",
href: nav.brand.href === current?.href ? undefined : nav.brand.href,
logo: <span aria-hidden="true" className="text-sm font-semibold">{nav.brand.initial}</span>,
}}
search={search}
groups={nav.groups.map((group) => ({ label: group.label, items: group.items.map(toItem) }))}
secondary={nav.footer?.map(toItem)}
activePath={current?.href}
renderLink={link}
/>
}
header={
<>
<HeaderBar
breadcrumbLabel={`${product} breadcrumb`}
breadcrumbs={trail.map((crumb) => ({ label: crumb.title, href: crumb.href }))}
lastCrumbIsPage={full.length < 2}
renderLink={link}
actions={
<>
<Button type="button" variant="ghost" size="icon-sm" aria-label="Search" aria-haspopup="dialog" onClick={() => setOpen(true)}>
<SearchIcon />
</Button>
<UserMenu
user={{ name: user.name, email: user.email, src: user.avatarUrl }}
items={[
{ label: "Your profile", href: "/profile", icon: <UserRoundIcon aria-hidden="true" /> },
{ label: "Settings", href: "/settings", icon: <SettingsIcon aria-hidden="true" /> },
{ label: "Log out", href: "/sign-in", icon: <LogOutIcon aria-hidden="true" />, separatorBefore: true },
]}
/>
</>
}
/>
<Palette open={open} onOpenChange={setOpen} product={product} rows={rows} onInvoice={openInvoice} onShow={show} />
</>
}
>
<InvoiceLedger rows={rows} filter={filter} onFilter={show} selectedId={selectedId} notice={notice} />
</DashboardShell>
</div>
)
}
/** The palette itself: inside the shell, where the sidebar it can fold is. */
type PaletteProps = { open: boolean; onOpenChange: (open: boolean) => void; product: string; rows: LedgerEntry[] } & {
onInvoice: (row: LedgerEntry) => void
onShow: (filter: Filter) => void
}
function Palette({ open, onOpenChange, product, rows, onInvoice, onShow }: PaletteProps) {
const sidebar = useSidebar()
const unpaid = rows.filter((row) => row.standing !== "paid")
const fold = sidebar.isMobile ? "Open the menu" : sidebar.open ? "Collapse the sidebar" : "Expand the sidebar"
return (
<CommandPalette
open={open}
onOpenChange={onOpenChange}
// Bound to the band above, never to the window.
hotkey={null}
title={`Search ${product}`}
description="Find an invoice, narrow the ledger or fold the sidebar."
placeholder="Invoice number, customer or amount"
emptyMessage="Nothing in the ledger matches that."
groups={[
{
heading: "Unpaid invoices",
items: unpaid.map((row) => ({
id: row.id,
label: `${row.number} · ${row.customer}`,
description: `${row.amount} · ${row.standing === "overdue" ? "overdue since" : "due"} ${row.due}`,
keywords: [row.customer, row.amount],
icon: <FileTextIcon aria-hidden="true" />,
onSelect: () => onInvoice(row),
})),
},
{
heading: "Show",
items: (["all", "open", "overdue", "paid"] as Filter[]).map((option) => ({
id: `show-${option}`,
label: option === "all" ? "All invoices" : `${STANDING_LABELS[option as Standing]} invoices`,
icon: <FilterIcon aria-hidden="true" />,
onSelect: () => onShow(option),
})),
},
{
heading: "Layout",
items: [
{ id: "fold", label: fold, shortcut: "mod+b", icon: <PanelLeftIcon aria-hidden="true" />, onSelect: sidebar.toggleSidebar },
],
},
]}
/>
)
}
/**
* The page: the ledger, narrowed by standing, the invoice a palette pick opens
* leading it and marked with the brand fill. The line under it says what the
* last pick did, and how much of the ledger is showing.
*/
type LedgerProps = { rows: LedgerEntry[]; filter: Filter; onFilter: (filter: Filter) => void; selectedId?: string; notice: string }
function InvoiceLedger({ rows, filter, onFilter, selectedId, notice }: LedgerProps) {
const matching = filter === "all" ? rows : rows.filter((row) => row.standing === filter)
// The invoice a pick opened leads the list, so it is in view however far down it was.
const picked = matching.find((row) => row.id === selectedId)
const shown = (picked ? [picked, ...matching.filter((row) => row !== picked)] : matching).slice(0, PAGE)
const count = (option: Filter) => (option === "all" ? rows.length : rows.filter((row) => row.standing === option).length)
return (
<>
<PageHeader
as="h3"
title="Invoices"
description="Everything issued in the last 90 days."
actions={
<SegmentedControl
aria-label="Show"
size="sm"
value={filter}
onValueChange={(value) => onFilter(value as Filter)}
options={(["all", "open", "overdue", "paid"] as Filter[]).map((option) => ({
value: option,
label: `${option === "all" ? "All" : STANDING_LABELS[option as Standing]} ${count(option)}`,
}))}
/>
}
/>
<div className="panel overflow-hidden">
<Table>
<TableHeader className="bg-surface">
<TableRow className="hover:bg-transparent">
<TableHead className="ps-4">Invoice</TableHead>
<TableHead>Customer</TableHead>
<TableHead className="hidden sm:table-cell">Due</TableHead>
<TableHead className="text-right">Amount</TableHead>
<TableHead className="hidden pe-4 md:table-cell">Standing</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{shown.map((row) => (
<TableRow
key={row.id}
data-state={row.id === selectedId ? "selected" : undefined}
aria-current={row.id === selectedId ? "true" : undefined}
>
<TableCell className="ps-4 font-mono text-xs">{row.number}</TableCell>
<TableCell className="max-w-48 truncate">{row.customer}</TableCell>
<TableCell className="hidden text-muted-foreground tabular-nums sm:table-cell">{row.due}</TableCell>
<TableCell className="text-right font-medium tabular-nums">{row.amount}</TableCell>
<TableCell className="hidden pe-4 md:table-cell">
<StatusBadge status={row.standing} label={STANDING_LABELS[row.standing]} variant={STANDING_TONES[row.standing]} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="flex items-center justify-between gap-3 border-t px-4 py-2.5 text-xs text-muted-foreground">
<p role="status" aria-live="polite" className="min-w-0 truncate">
{notice}
</p>
<p className="shrink-0 tabular-nums">
{shown.length} of {matching.length}
</p>
</div>
</div>
</>
)
}/**
* What `dashboard-shell-05` reads: the Finance dashboard's own nav and the
* signed-in member for the shell; and for the
* page it frames, the ledger of `db.invoices` issued in the last 90 days —
* less drafts, never sent, and void ones, owed by nobody — with the
* `db.customers` they are billed to. An invoice past its due date and unpaid
* is overdue, whatever its row says. "Now" is `REFERENCE_DATE`.
*
* Selectors only, reading `db` inside functions and run on the server. The
* island takes what they return as props; it never imports this module.
*/
import { NAV, ROUTES } from "@/lib/dashboards/finance/nav"
import { type NavConfig } from "@/lib/nav-config"
import { REFERENCE_DATE, db, type Invoice } from "@/lib/sample-data"
const DAY_MS = 86_400_000
export const WINDOW_DAYS = 90
/** The page the shell frames. */
export const ACTIVE = ROUTES.invoices
export type ShellPerson = { name: string; email: string; avatarUrl?: string }
export function shell(): { nav: NavConfig; user: ShellPerson } {
const person = db.members.all()[0]
return { nav: NAV, user: { name: person.name, email: person.email, avatarUrl: person.avatarUrl } }
}
/** Where an invoice stands for the reader: still to be paid, late, or paid. */
export type Standing = "open" | "overdue" | "paid"
export type LedgerRow = Pick<Invoice, "id" | "number" | "amountCents" | "issuedAt" | "dueAt"> & {
customer: string
standing: Standing
}
export function ledger(): LedgerRow[] {
const now = REFERENCE_DATE.getTime()
const since = now - WINDOW_DAYS * DAY_MS
const customers = new Map(db.customers.all().map((customer) => [customer.id, customer.company]))
return db.invoices
.all()
.filter((invoice) => invoice.issuedAt.getTime() >= since && invoice.status !== "draft" && invoice.status !== "void")
.sort((a, b) => b.issuedAt.getTime() - a.issuedAt.getTime())
.map((invoice) => ({
id: invoice.id,
number: invoice.number,
amountCents: invoice.amountCents,
issuedAt: invoice.issuedAt,
dueAt: invoice.dueAt,
customer: customers.get(invoice.customerId) ?? "Unknown customer",
standing:
invoice.status === "paid" ? "paid" : invoice.status === "overdue" || invoice.dueAt.getTime() < now ? "overdue" : "open",
}))
}
/** "Now" for the shell: the store's reference date, never the clock. */
export function asOf(): Date {
return REFERENCE_DATE
}npx shadcn@latest add @vibra/dashboard-shell-05Mobile-first shell: tabs on a phone, a sidebar from md
dashboard-shell-06The Hotel dashboard's pages as tabs along the foot of a phone — More opening the rest in a sheet — and as a sidebar from md, around the front desk's day with a check-in for every guest due.
import * as React from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { MobileFirstShell } from "./dashboard-shell-06.client"
import { ACTIVE, frontDesk, shell } from "./dashboard-shell-06.data"
/** "Fri, Sep 4": the desk's day, in UTC where the data lives. */
const DAY = new Intl.DateTimeFormat("en-US", { weekday: "short", month: "short", day: "numeric", timeZone: "UTC" })
/**
* A shell drawn for the phone first: the Hotel dashboard's pages as tabs along
* the foot of a phone — More opening the rest and the account in a sheet —
* and as a sidebar from md, around the front desk's day: who is due in, each
* with a check-in, and the day's figures following.
*
* A server component: the nav, the person and the desk's day come from
* `./dashboard-shell-06.data`; the island takes plain rows.
*/
export function DashboardShell06() {
const titleId = React.useId()
const frame = shell()
const desk = frontDesk()
return (
<section
data-section="dashboard-shell-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}>Harbour House</h2>
</CardTitle>
<CardDescription>Tabs along the foot of a phone, a sidebar from a tablet up</CardDescription>
</CardHeader>
<CardContent className="p-0">
<MobileFirstShell
nav={frame.nav}
activeHref={ACTIVE}
user={frame.user}
day={DAY.format(desk.today)}
rooms={desk.rooms}
inHouse={desk.inHouse}
leaving={desk.leaving}
arrivals={desk.arrivals}
/>
</CardContent>
</Card>
</div>
</section>
)
}
export default DashboardShell06"use client"
import * as React from "react"
import Link from "next/link"
import { BedDoubleIcon, DoorOpenIcon, EllipsisIcon, LogInIcon, PercentIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { findNavItem, type NavConfig, type NavItem } from "@/lib/nav-config"
import { AppSidebar, type NavItem as SidebarItem } from "@/components/ui/app-sidebar"
import { navIcon } from "@/components/ui/app-shell/icons"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { DashboardShell } from "@/components/ui/dashboard-shell"
import { HeaderBar } from "@/components/ui/header-bar"
import { PageHeader } from "@/components/ui/page-header"
import { useSidebar } from "@/components/ui/sidebar"
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"
import { StatusBadge } from "@/components/ui/status-badge"
import { UserMenu } from "@/components/ui/user-menu"
import type { Arrival } from "./dashboard-shell-06.data"
function toItem(item: NavItem): SidebarItem {
return { title: item.title, href: item.href, icon: navIcon(item.icon), badge: item.badge }
}
const link = (href: string, props: React.ComponentProps<"a">) => <Link href={href} {...props} />
export type MobileFirstShellProps = {
nav: NavConfig
activeHref: string
user: { name: string; email: string; avatarUrl?: string }
/** "Fri, Sep 4", written by the server. */
day: string
rooms: number
inHouse: number
leaving: number
arrivals: Arrival[]
}
/**
* A shell drawn for the phone first. On a phone the pages are tabs along the
* foot of the band — the page it is on marked current — and More opens the
* rest, the settings and the account, in the kit's sheet; from md the same
* pages are a sidebar, and the tabs go. The page is the front desk's day:
* who is due in, and a button to check each one in, the day's figures
* following and a status line saying who went where.
*/
export function MobileFirstShell(props: MobileFirstShellProps) {
const { nav, activeHref, user } = props
const current = findNavItem(nav, activeHref)
return (
<DashboardShell
contained
className="md:min-h-[42rem]"
contentClassName="p-0 space-y-0 flex flex-col"
sidebar={
<AppSidebar
collapsible="icon"
variant="sidebar"
navLabel={nav.brand.name}
brand={{
name: nav.brand.name,
description: nav.brand.caption,
href: nav.brand.href === current?.href ? undefined : nav.brand.href,
logo: <span aria-hidden="true" className="text-sm font-semibold">{nav.brand.initial}</span>,
}}
groups={nav.groups.map((group) => ({ label: group.label, items: group.items.map(toItem) }))}
secondary={nav.footer?.map(toItem)}
activePath={current?.href}
renderLink={link}
footer={
<UserMenu
trigger="full"
align="start"
// The rail folds to an icon's width but the account row does
// not, so folded it drops its padding and clips itself down to
// the avatar — in line with the icons above it — rather than
// spilling the name and the chevron across the page beside it.
className="overflow-hidden group-data-[collapsible=icon]:p-0"
user={{ name: user.name, email: user.email, src: user.avatarUrl }}
/>
}
/>
}
header={<DeskBar title={nav.brand.name} />}
>
<FrontDesk {...props} />
<BottomTabs nav={nav} current={current?.href} />
</DashboardShell>
)
}
/**
* The bar: the toggle only where there is a sidebar to fold — from md; a
* phone has its tabs. The stylesheet hides it, not a script: the server's
* HTML cannot know the width, and a phone's first paint shows no toggle.
*/
function DeskBar({ title }: { title: string }) {
return (
<HeaderBar
title={title}
className="max-md:*:data-[slot=separator]:hidden max-md:*:data-[slot=sidebar-trigger]:hidden"
/>
)
}
/** The pages along the foot of a phone, and More for everything else; gone from md. */
function BottomTabs({ nav, current }: { nav: NavConfig; current?: string }) {
const { openMobile, setOpenMobile } = useSidebar()
const tabs = nav.groups.flatMap((group) => group.items).slice(0, 4)
const tab = "flex flex-col items-center justify-center gap-1 rounded-md py-1.5 text-2xs font-medium text-muted-foreground focus-ring-inset"
return (
<nav
aria-label={`${nav.brand.name} tabs`}
className="sticky bottom-0 z-20 mt-auto grid grid-cols-5 gap-1 border-t border-rule bg-background px-2 pt-1.5 pb-2 md:hidden"
>
{tabs.map((item) => {
const Icon = navIcon(item.icon)
const here = item.href === current
return (
<Link key={item.href} href={item.href} aria-current={here ? "page" : undefined} className={cn(tab, here && "text-foreground")}>
<span className={cn("flex h-6 w-12 items-center justify-center rounded-full", here && "bg-brand-muted")}>
{Icon ? <Icon aria-hidden="true" className="size-4" /> : null}
</span>
{item.title}
</Link>
)
})}
<button
type="button"
data-slot="more-trigger"
aria-haspopup="dialog"
aria-expanded={openMobile}
onClick={() => setOpenMobile(true)}
className={tab}
>
<span className="flex h-6 w-12 items-center justify-center rounded-full">
<EllipsisIcon aria-hidden="true" className="size-4" />
</span>
More
</button>
</nav>
)
}
/** The front desk's day, with a check-in for every guest due. */
function FrontDesk({ day, rooms, inHouse, leaving, arrivals }: MobileFirstShellProps) {
const [arrived, setArrived] = React.useState<string[]>([])
const [notice, setNotice] = React.useState("")
const buttons = React.useRef(new Map<string, HTMLButtonElement>())
const list = React.useRef<HTMLUListElement>(null)
const focusNext = React.useRef<string | null | undefined>(undefined)
const due = arrivals.length - arrived.length
const occupied = inHouse + arrived.length
// A checked-in guest's button gives way to a badge, so focus moves on to the
// next guest still due — or, with none left, to the list itself.
React.useEffect(() => {
if (focusNext.current === undefined) return
const next = focusNext.current
focusNext.current = undefined
;(next ? buttons.current.get(next) : list.current)?.focus()
}, [arrived])
function checkIn(stay: Arrival) {
const rest = arrivals.filter((other) => other.id !== stay.id && !arrived.includes(other.id))
const after = rest.find((other) => arrivals.indexOf(other) > arrivals.indexOf(stay)) ?? rest[0]
focusNext.current = after?.id ?? null
setArrived((ids) => [...ids, stay.id])
setNotice(`${stay.guestName} is checked in to room ${stay.room}.`)
}
return (
<div className="flex flex-1 flex-col gap-6 p-4 md:p-6">
<PageHeader as="h3" title="Today" description={day} />
<StatCardGroup columns={4} divided>
<StatCard label="Due in" value={due} description="still to arrive" icon={<LogInIcon aria-hidden="true" />} />
<StatCard label="Leaving" value={leaving} description="checking out today" icon={<DoorOpenIcon aria-hidden="true" />} />
<StatCard label="In house" value={occupied} description={`of ${rooms} rooms`} icon={<BedDoubleIcon aria-hidden="true" />} />
<StatCard label="Occupancy" value={`${Math.round((occupied / rooms) * 100)}%`} description="tonight" icon={<PercentIcon aria-hidden="true" />} />
</StatCardGroup>
<Card>
<CardHeader>
<CardTitle>Arriving today</CardTitle>
<CardDescription>A to Z, with the room each guest is given</CardDescription>
</CardHeader>
<CardContent className="p-0">
<ul ref={list} tabIndex={-1} aria-label="Arriving today" className="divide-y outline-none">
{arrivals.map((stay) => {
const inside = arrived.includes(stay.id)
const party = stay.adults + stay.children
return (
<li key={stay.id} className="flex items-center gap-3 px-(--card-spacing) py-3">
<Avatar size="sm" aria-hidden="true">
<AvatarImage src={stay.guestAvatar} alt="" />
<AvatarFallback>{stay.guestName.slice(0, 1)}</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-medium">{stay.guestName}</span>
<span className="truncate text-xs text-muted-foreground tabular-nums">
Room {stay.room} · {stay.nights} {stay.nights === 1 ? "night" : "nights"} · {party} {party === 1 ? "guest" : "guests"}
</span>
</div>
{inside ? (
<StatusBadge status="checked_in" label="In house" variant="success" />
) : (
<Button
ref={(node) => {
if (node) buttons.current.set(stay.id, node)
else buttons.current.delete(stay.id)
}}
type="button"
size="sm"
variant="outline"
aria-label={`Check in ${stay.guestName}`}
onClick={() => checkIn(stay)}
>
Check in
</Button>
)}
</li>
)
})}
</ul>
</CardContent>
</Card>
<p role="status" aria-live="polite" className="-mt-3 text-sm text-muted-foreground">
{notice}
</p>
</div>
)
}/**
* What `dashboard-shell-06` reads: the Hotel dashboard's own nav and the
* signed-in member for the shell; and for the page it frames, the front desk's
* day — every `db.bookings` stay arriving or leaving on the UTC day of
* `REFERENCE_DATE`, the guests in house and the house's rooms, with the room
* each arriving guest is given.
*
* Selectors only, reading `db` inside functions and run on the server. The
* island takes what they return as props; it never imports this module.
*/
import { NAV, ROUTES } from "@/lib/dashboards/hotel/nav"
import { type NavConfig } from "@/lib/nav-config"
import { REFERENCE_DATE, ROOMS, db, type Booking } from "@/lib/sample-data"
/** The page the shell frames. */
export const ACTIVE = ROUTES.overview
export function shell(): { nav: NavConfig; user: { name: string; email: string; avatarUrl?: string } } {
const person = db.members.all()[0]
return { nav: NAV, user: { name: person.name, email: person.email, avatarUrl: person.avatarUrl } }
}
export type Arrival = Pick<Booking, "id" | "reference" | "guestName" | "guestAvatar" | "nights" | "adults" | "children"> & { room: string }
const dayOf = (date: Date) => date.toISOString().slice(0, 10)
export function frontDesk(): { today: Date; rooms: number; inHouse: number; leaving: number; arrivals: Arrival[] } {
const today = dayOf(REFERENCE_DATE)
const stays = db.bookings.all()
const rooms = new Map(ROOMS.map((room) => [room.id, room.number]))
return {
today: REFERENCE_DATE,
rooms: ROOMS.length,
inHouse: stays.filter((stay) => stay.status === "checked_in").length,
leaving: stays.filter((stay) => stay.status === "checked_in" && dayOf(stay.checkOut) === today).length,
// Due in today and not called off: a cancelled stay is not coming.
arrivals: stays
.filter((stay) => stay.status === "confirmed" && dayOf(stay.checkIn) === today)
.sort((a, b) => a.guestName.localeCompare(b.guestName))
.map(({ id, reference, guestName, guestAvatar, nights, adults, children, roomId }) => ({
id,
reference,
guestName,
guestAvatar,
nights,
adults,
children,
room: rooms.get(roomId) ?? roomId,
})),
}
}npx shadcn@latest add @vibra/dashboard-shell-06