Урок 22. Примеры: сборка по шагам
⚡ Кратко
Шесть шагов сборки: каркас, сайдбар, метрики, график, таблица и собранный дашборд целиком.
Шаг 1. Каркас
// src/components/app-shell.tsx
export function AppShell({ children }: { children: React.ReactNode }) {
return (
<div className="grid min-h-svh grid-rows-[auto_1fr] bg-background text-foreground
lg:grid-cols-[16rem_1fr] lg:grid-rows-[auto_1fr]">
<Sidebar className="hidden lg:col-start-1 lg:row-span-2 lg:block" />
<AppHeader className="lg:col-start-2" />
<main className="overflow-y-auto p-4 lg:col-start-2 lg:p-6">{children}</main>
</div>
)
}
Сетка задаёт две колонки на десктопе и одну на мобильном; сайдбар
занимает обе строки; прокручивается только main.
Шаг 2. Сайдбар
// src/components/app-sidebar.tsx
import { cn } from "@/lib/utils"
const NAV = [
{ href: "/", label: "Обзор", icon: "▦" },
{ href: "/orders", label: "Заказы", icon: "▤" },
{ href: "/customers", label: "Клиенты", icon: "☺" },
{ href: "/settings", label: "Настройки", icon: "⚙" },
]
export function AppSidebar({ className, current }: { className?: string; current: string }) {
return (
<aside className={cn("flex flex-col gap-4 bg-sidebar p-4 text-sidebar-foreground", className)}>
<div className="flex items-center gap-2">
<div className="size-7 rounded-lg bg-sidebar-primary" />
<span className="text-sm font-semibold">Панель</span>
</div>
<nav className="flex flex-col gap-1">
{NAV.map((item) => (
<a
key={item.href}
href={item.href}
aria-current={current === item.href ? "page" : undefined}
className="flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm hover:bg-sidebar-accent
aria-[current=page]:bg-sidebar-accent aria-[current=page]:font-medium
aria-[current=page]:text-sidebar-accent-foreground"
>
<span aria-hidden="true">{item.icon}</span>
{item.label}
</a>
))}
</nav>
</aside>
)
}
<aside class="flex w-56 flex-col gap-4 rounded-xl bg-sidebar p-4 text-sidebar-foreground">
<div class="flex items-center gap-2">
<div class="size-7 rounded-lg bg-sidebar-primary"></div>
<span class="text-sm font-semibold">Панель</span>
</div>
<nav class="flex flex-col gap-1 text-sm">
<a href="#" aria-current="page" class="flex items-center gap-2 rounded-lg bg-sidebar-accent px-2 py-1.5 font-medium text-sidebar-accent-foreground"><span aria-hidden="true">▦</span> Обзор</a>
<a href="#" class="flex items-center gap-2 rounded-lg px-2 py-1.5 hover:bg-sidebar-accent"><span aria-hidden="true">▤</span> Заказы</a>
<a href="#" class="flex items-center gap-2 rounded-lg px-2 py-1.5 hover:bg-sidebar-accent"><span aria-hidden="true">☺</span> Клиенты</a>
<a href="#" class="flex items-center gap-2 rounded-lg px-2 py-1.5 hover:bg-sidebar-accent"><span aria-hidden="true">⚙</span> Настройки</a>
</nav>
</aside>
Активный пункт помечен aria-current="page" — это и стиль,
и сообщение для скринридера. Иконки скрыты от него
через aria-hidden, потому что дублируют подпись.
Шаг 3. Карточки метрик
// src/components/metric-card.tsx
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
type MetricProps = {
label: string
value: string
delta: string
trend: "up" | "down"
}
export function MetricCard({ label, value, delta, trend }: MetricProps) {
return (
<Card>
<CardHeader>
<CardDescription>{label}</CardDescription>
<CardTitle className="text-2xl tabular-nums">{value}</CardTitle>
</CardHeader>
<CardContent>
<Badge variant={trend === "up" ? "secondary" : "destructive"}>
{trend === "up" ? "▲" : "▼"} {delta}
</Badge>
</CardContent>
</Card>
)
}
Выручка
1,24 млн
▲ 12%Заказы
348
▲ 4%Средний чек
3 560 ₽
▼ 2%Возвраты
12
▲ 3<div class="grid grid-cols-2 gap-3 lg:grid-cols-4">
<div class="rounded-xl bg-card p-4 text-card-foreground ring-1 ring-border">
<p class="text-xs text-muted-foreground">Выручка</p>
<p class="mt-1 text-2xl font-semibold tabular-nums">1,24 млн</p>
<span class="mt-2 inline-flex h-5 items-center rounded-4xl bg-secondary px-2 text-xs font-medium text-secondary-foreground">▲ 12%</span>
</div>
<div class="rounded-xl bg-card p-4 text-card-foreground ring-1 ring-border">
<p class="text-xs text-muted-foreground">Заказы</p>
<p class="mt-1 text-2xl font-semibold tabular-nums">348</p>
<span class="mt-2 inline-flex h-5 items-center rounded-4xl bg-secondary px-2 text-xs font-medium text-secondary-foreground">▲ 4%</span>
</div>
<div class="rounded-xl bg-card p-4 text-card-foreground ring-1 ring-border">
<p class="text-xs text-muted-foreground">Средний чек</p>
<p class="mt-1 text-2xl font-semibold tabular-nums">3 560 ₽</p>
<span class="mt-2 inline-flex h-5 items-center rounded-4xl bg-destructive/10 px-2 text-xs font-medium text-destructive">▼ 2%</span>
</div>
<div class="rounded-xl bg-card p-4 text-card-foreground ring-1 ring-border">
<p class="text-xs text-muted-foreground">Возвраты</p>
<p class="mt-1 text-2xl font-semibold tabular-nums">12</p>
<span class="mt-2 inline-flex h-5 items-center rounded-4xl bg-destructive/10 px-2 text-xs font-medium text-destructive">▲ 3</span>
</div>
</div>
Шаг 4. График
Полноценный график рисует библиотека, но каркас блока и цвета —
ваши. Токены --chart-1…5 уже есть в теме (урок 19).
Выручка по неделям
Последние 8 недель
<div class="rounded-xl bg-card p-4 text-card-foreground ring-1 ring-border">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium">Выручка по неделям</p>
<p class="text-xs text-muted-foreground">Последние 8 недель</p>
</div>
<div class="inline-flex h-7 items-center rounded-lg bg-muted p-[3px] text-xs text-muted-foreground">
<button class="h-full rounded-md bg-background px-2 font-medium text-foreground shadow-sm">8 недель</button>
<button class="h-full rounded-md px-2">Квартал</button>
</div>
</div>
<div class="mt-4 flex h-28 items-end gap-2">
<div class="h-1/3 flex-1 rounded-t bg-chart-1"></div>
<div class="h-1/2 flex-1 rounded-t bg-chart-1"></div>
<div class="h-2/5 flex-1 rounded-t bg-chart-1"></div>
<div class="h-3/5 flex-1 rounded-t bg-chart-1"></div>
<div class="h-3/4 flex-1 rounded-t bg-chart-1"></div>
<div class="h-2/3 flex-1 rounded-t bg-chart-1"></div>
<div class="h-full flex-1 rounded-t bg-chart-2"></div>
<div class="h-4/5 flex-1 rounded-t bg-chart-1"></div>
</div>
</div>
Шаг 5. Таблица заказов
// src/components/orders-table.tsx
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Badge } from "@/components/ui/badge"
export function OrdersTable({ orders }: { orders: Order[] }) {
return (
<div className="overflow-x-auto rounded-xl ring-1 ring-border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Заказ</TableHead>
<TableHead>Клиент</TableHead>
<TableHead>Статус</TableHead>
<TableHead className="text-right">Сумма</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{orders.map((order) => (
<TableRow key={order.id}>
<TableCell className="font-medium">{order.number}</TableCell>
<TableCell className="text-muted-foreground">{order.customer}</TableCell>
<TableCell><Badge variant="secondary">{order.status}</Badge></TableCell>
<TableCell className="text-right tabular-nums">{order.total}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)
}
| Заказ | Клиент | Статус | Сумма | |
|---|---|---|---|---|
| № 1042 | Смирнова А. | Оплачен | 12 400 | |
| № 1043 | Ковалёв И. | Ожидает | 8 900 | |
| № 1044 | Титова Е. | Ошибка оплаты | 3 150 |
<div class="overflow-x-auto rounded-xl ring-1 ring-border">
<table class="w-full min-w-md text-sm">
<thead class="bg-muted/50 text-xs text-muted-foreground">
<tr>
<th class="px-4 py-2 text-left font-medium">Заказ</th>
<th class="px-4 py-2 text-left font-medium">Клиент</th>
<th class="px-4 py-2 text-left font-medium">Статус</th>
<th class="px-4 py-2 text-right font-medium">Сумма</th>
<th class="px-4 py-2"></th>
</tr>
</thead>
<tbody class="divide-y divide-border bg-card text-card-foreground">
<tr class="hover:bg-muted/50">
<td class="px-4 py-2 font-medium">№ 1042</td>
<td class="px-4 py-2 text-muted-foreground">Смирнова А.</td>
<td class="px-4 py-2"><span class="inline-flex h-5 items-center rounded-4xl bg-secondary px-2 text-xs font-medium text-secondary-foreground">Оплачен</span></td>
<td class="px-4 py-2 text-right tabular-nums">12 400</td>
<td class="px-4 py-2 text-right"><button aria-label="Действия" class="inline-flex size-7 items-center justify-center rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground">⋯</button></td>
</tr>
<tr class="hover:bg-muted/50">
<td class="px-4 py-2 font-medium">№ 1043</td>
<td class="px-4 py-2 text-muted-foreground">Ковалёв И.</td>
<td class="px-4 py-2"><span class="inline-flex h-5 items-center rounded-4xl border border-border px-2 text-xs font-medium">Ожидает</span></td>
<td class="px-4 py-2 text-right tabular-nums">8 900</td>
<td class="px-4 py-2 text-right"><button aria-label="Действия" class="inline-flex size-7 items-center justify-center rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground">⋯</button></td>
</tr>
<tr class="hover:bg-muted/50">
<td class="px-4 py-2 font-medium">№ 1044</td>
<td class="px-4 py-2 text-muted-foreground">Титова Е.</td>
<td class="px-4 py-2"><span class="inline-flex h-5 items-center rounded-4xl bg-destructive/10 px-2 text-xs font-medium text-destructive">Ошибка оплаты</span></td>
<td class="px-4 py-2 text-right tabular-nums">3 150</td>
<td class="px-4 py-2 text-right"><button aria-label="Действия" class="inline-flex size-7 items-center justify-center rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground">⋯</button></td>
</tr>
</tbody>
</table>
</div>
Шаг 6. Дашборд целиком
Выручка
1,24 млн
Заказы
348
Средний чек
3 560 ₽
Возвраты
12
Выручка по неделям
| Заказ | Статус | Сумма |
|---|---|---|
| № 1042 | Оплачен | 12 400 |
| № 1043 | Ожидает | 8 900 |
<div class="grid h-96 grid-rows-[auto_1fr] overflow-hidden rounded-xl bg-background ring-1 ring-border lg:grid-cols-[13rem_1fr]">
<aside class="hidden bg-sidebar p-3 text-sidebar-foreground lg:col-start-1 lg:row-span-2 lg:flex lg:flex-col lg:gap-3">
<div class="flex items-center gap-2">
<div class="size-6 rounded-md bg-sidebar-primary"></div>
<span class="text-sm font-semibold">Панель</span>
</div>
<nav class="flex flex-col gap-1 text-sm">
<a href="#" aria-current="page" class="rounded-md bg-sidebar-accent px-2 py-1.5 font-medium text-sidebar-accent-foreground">Обзор</a>
<a href="#" class="rounded-md px-2 py-1.5 hover:bg-sidebar-accent">Заказы</a>
<a href="#" class="rounded-md px-2 py-1.5 hover:bg-sidebar-accent">Клиенты</a>
</nav>
</aside>
<header class="flex items-center gap-2 border-b border-border px-3 py-2 lg:col-start-2">
<button class="inline-flex size-7 items-center justify-center rounded-lg hover:bg-muted lg:hidden" aria-label="Меню">☰</button>
<input class="h-7 min-w-0 flex-1 rounded-lg border border-input bg-transparent px-2 text-sm outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50" placeholder="Поиск заказов">
<button class="inline-flex h-7 shrink-0 items-center justify-center rounded-lg bg-primary px-2.5 text-xs font-medium text-primary-foreground">Новый заказ</button>
<div class="size-7 shrink-0 rounded-full bg-muted"></div>
</header>
<main class="space-y-3 overflow-y-auto p-3 lg:col-start-2">
<div class="grid grid-cols-2 gap-3 xl:grid-cols-4">
<div class="rounded-lg bg-card p-3 ring-1 ring-border"><p class="text-xs text-muted-foreground">Выручка</p><p class="mt-1 text-lg font-semibold tabular-nums text-card-foreground">1,24 млн</p></div>
<div class="rounded-lg bg-card p-3 ring-1 ring-border"><p class="text-xs text-muted-foreground">Заказы</p><p class="mt-1 text-lg font-semibold tabular-nums text-card-foreground">348</p></div>
<div class="rounded-lg bg-card p-3 ring-1 ring-border"><p class="text-xs text-muted-foreground">Средний чек</p><p class="mt-1 text-lg font-semibold tabular-nums text-card-foreground">3 560 ₽</p></div>
<div class="rounded-lg bg-card p-3 ring-1 ring-border"><p class="text-xs text-muted-foreground">Возвраты</p><p class="mt-1 text-lg font-semibold tabular-nums text-card-foreground">12</p></div>
</div>
<div class="rounded-lg bg-card p-3 ring-1 ring-border">
<p class="text-sm font-medium text-card-foreground">Выручка по неделям</p>
<div class="mt-3 flex h-20 items-end gap-1.5">
<div class="h-1/3 flex-1 rounded-t bg-chart-1"></div>
<div class="h-1/2 flex-1 rounded-t bg-chart-1"></div>
<div class="h-2/5 flex-1 rounded-t bg-chart-1"></div>
<div class="h-3/5 flex-1 rounded-t bg-chart-1"></div>
<div class="h-4/5 flex-1 rounded-t bg-chart-1"></div>
<div class="h-full flex-1 rounded-t bg-chart-2"></div>
</div>
</div>
<div class="overflow-x-auto rounded-lg ring-1 ring-border">
<table class="w-full text-sm">
<thead class="bg-muted/50 text-xs text-muted-foreground">
<tr><th class="px-3 py-2 text-left font-medium">Заказ</th><th class="px-3 py-2 text-left font-medium">Статус</th><th class="px-3 py-2 text-right font-medium">Сумма</th></tr>
</thead>
<tbody class="divide-y divide-border bg-card text-card-foreground">
<tr><td class="px-3 py-2 font-medium">№ 1042</td><td class="px-3 py-2"><span class="inline-flex h-5 items-center rounded-4xl bg-secondary px-2 text-xs">Оплачен</span></td><td class="px-3 py-2 text-right tabular-nums">12 400</td></tr>
<tr><td class="px-3 py-2 font-medium">№ 1043</td><td class="px-3 py-2"><span class="inline-flex h-5 items-center rounded-4xl border border-border px-2 text-xs">Ожидает</span></td><td class="px-3 py-2 text-right tabular-nums">8 900</td></tr>
</tbody>
</table>
</div>
</main>
</div>
Переключите тему страницы — дашборд целиком перейдёт в тёмный режим, потому что в нём нет ни одного жёстко заданного цвета.