Урок 22. Примеры: сборка по шагам

📁 Раздел: Итоговый проект ⏱️ Время изучения: ~3–4 часа 🎯 Сложность: Продвинутая

⚡ Кратко

Шесть шагов сборки: каркас, сайдбар, метрики, график, таблица и собранный дашборд целиком.

Шаг 1. Каркас

📄 src/components/app-shell.tsx
// 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
// 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
// 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
// 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>

Переключите тему страницы — дашборд целиком перейдёт в тёмный режим, потому что в нём нет ни одного жёстко заданного цвета.