Виджеты и графики в Orchid — дашборд

Создаём информативный дашборд: метрики, графики, таблицы. Charts.js интеграция, кастомные виджеты, real-time данные.

Дмитрий Мещеряков
Дмитрий Мещеряков
📅 15 июня 2026 г.📖 7 мин чтения

Дашборд — экран, который открывают чаще всех остальных, и по нему судят о скорости всей админки. При этом он же самый тяжёлый: каждая плитка и каждый график — это агрегирующий запрос, а выполняются они все сразу при каждом открытии.

Разберём построение дашборда и то, без чего он становится самой медленной страницей проекта.

Почему дашборд становится самой медленной страницей

Дашборд — первое, что видит пользователь админки. Сделаем его информативным.

php
// app/Orchid/Screens/DashboardScreen.php
namespace App\Orchid\Screens;
use Orchid\Screen\Screen;
use Orchid\Support\Facades\Layout;
use App\Models\Order;
use App\Models\User;
use App\Models\Post;
class DashboardScreen extends Screen
{
    public ?string $name = 'Дашборд';
    public ?string $description = 'Обзор ключевых метрик';
    public function query(): iterable
    {
        return [
            'metrics' => $this->getMetrics(),
            'salesChart' => $this->getSalesChart(),
            'ordersChart' => $this->getOrdersChart(),
            'recentOrders' => Order::with('user')
                ->latest()
                ->limit(5)
                ->get(),
        ];
    }
    public function layout(): iterable
    {
        return [
            // Метрики
            Layout::metrics([
                'Выручка сегодня' => 'metrics.revenue_today',
                'Заказов сегодня' => 'metrics.orders_today',
                'Новых клиентов' => 'metrics.new_users',
                'Статей' => 'metrics.posts_count',
            ]),
            // Графики в колонках
            Layout::columns([
                Layout::view('admin.widgets.sales-chart', [
                    'data' => $this->query()['salesChart'],
                ]),
                Layout::view('admin.widgets.orders-chart', [
                    'data' => $this->query()['ordersChart'],
                ]),
            ]),
            // Последние заказы
            Layout::table('recentOrders', [
                TD::make('id', '№'),
                TD::make('user.name', 'Клиент'),
                TD::make('total', 'Сумма')
                    ->render(fn($order) => number_format($order->total, 0, ',', ' ') . ' ₽'),
                TD::make('created_at', 'Дата')
                    ->render(fn($order) => $order->created_at->diffForHumans()),
            ]),
        ];
    }
    private function getMetrics(): array
    {
        return [
            'revenue_today' => number_format(
                Order::whereDate('created_at', today())
                    ->where('status', 'completed')
                    ->sum('total'),
                0, ',', ' '
            ) . ' ₽',
            'orders_today' => Order::whereDate('created_at', today())->count(),
            'new_users' => User::whereDate('created_at', today())->count(),
            'posts_count' => Post::where('is_published', true)->count(),
        ];
    }
    private function getSalesChart(): array
    {
        return Order::query()
            ->where('status', 'completed')
            ->where('created_at', '>=', now()->subDays(30))
            ->selectRaw('DATE(created_at) as date, SUM(total) as total')
            ->groupBy('date')
            ->orderBy('date')
            ->get()
            ->map(fn($item) => [
                'date' => $item->date,
                'total' => $item->total,
            ])
            ->toArray();
    }
    private function getOrdersChart(): array
    {
        return Order::query()
            ->where('created_at', '>=', now()->subDays(7))
            ->selectRaw('DATE(created_at) as date, COUNT(*) as count')
            ->groupBy('date')
            ->orderBy('date')
            ->get()
            ->toArray();
    }
}
💡 Совет

Layout::metrics() — встроенный компонент для отображения ключевых показателей в карточках.

⚠️ Важно

Метод query() выполняется при каждом открытии экрана. Восемь плиток с показателями — это восемь запросов вида SELECT COUNT(*) и SELECT SUM(...) по всем заказам за период, и все они уходят в базу синхронно, пока пользователь смотрит на пустой экран.

На тестовых данных незаметно; на боевой базе с сотнями тысяч заказов дашборд открывается секундами. Причём открывают его постоянно — это стартовая страница админки.

Что с этим делать:

Кэшируйте показатели. Выручка за прошлый месяц не меняется вообще, за сегодня — меняется постепенно. Кэш на 5–15 минут снимает почти всю нагрузку, а актуальность цифр на дашборде редко требуется до секунды.

Проверьте индексы на полях, по которым идёт группировка и фильтрация — обычно это дата создания и статус. Без них каждая плитка означает полное сканирование таблицы.

Считайте агрегаты заранее для действительно больших объёмов: ночная задача, складывающая дневные итоги в отдельную таблицу, превращает запрос по миллиону строк в запрос по тремстам.

Встроенные Charts

Line Chart

php
use Orchid\Screen\Layouts\Chart;
class SalesChart extends Chart
{
    /**
     * Ключ данных из query()
     */
    protected $target = 'salesData';
    /**
     * Название графика
     */
    protected $title = 'Продажи за месяц';
    /**
     * Высота
     */
    protected $height = 300;
    /**
     * Доступные типы: line, bar, scatter, pie
     */
    protected $type = self::TYPE_LINE;
    /**
     * Экспорт в CSV/JSON
     */
    protected $export = true;
}
// В Screen
public function layout(): iterable
{
    return [
        SalesChart::class,
    ];
}
public function query(): iterable
{
    return [
        'salesData' => [
            [
                'name' => 'Продажи',
                'values' => [100, 200, 150, 300, 250, 400, 350],
                'labels' => ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'],
            ],
        ],
    ];
}

Bar Chart

php
class OrdersChart extends Chart
{
    protected $target = 'ordersData';
    protected $title = 'Заказы по статусам';
    protected $type = self::TYPE_BAR;
    protected $colors = [
        '#28a745', // completed
        '#ffc107', // pending
        '#dc3545', // cancelled
    ];
}
// Данные
'ordersData' => [
    [
        'name' => 'Выполнены',
        'values' => [45],
    ],
    [
        'name' => 'В ожидании',
        'values' => [12],
    ],
    [
        'name' => 'Отменены',
        'values' => [3],
    ],
],

Pie Chart

php
class CategoriesChart extends Chart
{
    protected $target = 'categoriesData';
    protected $title = 'Товары по категориям';
    protected $type = self::TYPE_PIE;
}

Кастомный виджет с Chart.js

Blade-шаблон

blade
{{-- resources/views/admin/widgets/sales-chart.blade.php --}}
<div class="bg-white rounded shadow-sm p-4 mb-3">
    <h5 class="mb-3">Продажи за 30 дней</h5>
    <canvas id="salesChart" height="200"></canvas>
</div>
@push('scripts')
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
    const data = @json($data);
    const ctx = document.getElementById('salesChart').getContext('2d');
    new Chart(ctx, {
        type: 'line',
        data: {
            labels: data.map(item => item.date),
            datasets: [{
                label: 'Выручка',
                data: data.map(item => item.total),
                borderColor: '#3490dc',
                backgroundColor: 'rgba(52, 144, 220, 0.1)',
                fill: true,
                tension: 0.4,
            }]
        },
        options: {
            responsive: true,
            plugins: {
                legend: {
                    display: false,
                },
                tooltip: {
                    callbacks: {
                        label: function(context) {
                            return new Intl.NumberFormat('ru-RU', {
                                style: 'currency',
                                currency: 'RUB',
                                maximumFractionDigits: 0,
                            }).format(context.raw);
                        }
                    }
                }
            },
            scales: {
                y: {
                    beginAtZero: true,
                    ticks: {
                        callback: function(value) {
                            return value.toLocaleString('ru-RU') + ' ₽';
                        }
                    }
                }
            }
        }
    });
});
</script>
@endpush

Использование

php
public function layout(): iterable
{
    return [
        Layout::view('admin.widgets.sales-chart', [
            'data' => $this->query()['salesChart'],
        ]),
    ];
}

Кастомный Layout-виджет

php
// app/Orchid/Layouts/StatsWidget.php
namespace App\Orchid\Layouts;
use Orchid\Screen\Layouts\Rows;
use Orchid\Screen\Fields\Label;
class StatsWidget extends Rows
{
    private string $title;
    private string $valueKey;
    private string $icon;
    private string $color;
    public function __construct(string $title, string $valueKey, string $icon = 'graph', string $color = 'primary')
    {
        $this->title = $title;
        $this->valueKey = $valueKey;
        $this->icon = $icon;
        $this->color = $color;
    }
    protected function fields(): iterable
    {
        $value = $this->query->get($this->valueKey);
        return [
            Label::make('widget')
                ->value(view('admin.widgets.stat-card', [
                    'title' => $this->title,
                    'value' => $value,
                    'icon' => $this->icon,
                    'color' => $this->color,
                ])),
        ];
    }
}
blade
{{-- resources/views/admin/widgets/stat-card.blade.php --}}
<div class="card border-{{ $color }} mb-3">
    <div class="card-body">
        <div class="d-flex align-items-center">
            <div class="flex-shrink-0">
                <x-orchid-icon path="{{ $icon }}" class="h1 text-{{ $color }}"/>
            </div>
            <div class="flex-grow-1 ms-3">
                <h6 class="text-muted mb-1">{{ $title }}</h6>
                <h3 class="mb-0">{{ $value }}</h3>
            </div>
        </div>
    </div>
</div>
php
// Использование
public function layout(): iterable
{
    return [
        Layout::columns([
            new StatsWidget('Выручка', 'revenue', 'wallet', 'success'),
            new StatsWidget('Заказы', 'orders_count', 'bag', 'primary'),
            new StatsWidget('Клиенты', 'users_count', 'people', 'info'),
            new StatsWidget('Товары', 'products_count', 'grid', 'warning'),
        ]),
    ];
}

Нужно ли обновлять дашборд в реальном времени

⚠️ Важно

Периодический опрос — самая дорогая функция дашборда. Обновление раз в 10 секунд у пяти открытых вкладок в отделе продаж означает тридцать полных выполнений query() в минуту — то есть постоянная фоновая нагрузка от страницы, на которую никто не смотрит.

Прежде чем включать, ответьте на вопрос, нужна ли эта актуальность на самом деле. Для дашборда руководителя, который открывают утром, — нет. Для монитора на стене в отделе — да, но тогда интервал имеет смысл сделать минутным, а данные — кэшированными.

php
// Polling через Turbo
public function layout(): iterable
{
    return [
        Layout::view('admin.widgets.live-stats')
            ->async('asyncLoadStats'),
    ];
}
public function asyncLoadStats(): iterable
{
    return [
        'stats' => [
            'online_users' => cache()->remember('online_users', 10, fn() => 
                User::where('last_activity_at', '>=', now()->subMinutes(5))->count()
            ),
            'pending_orders' => Order::where('status', 'pending')->count(),
        ],
    ];
}
blade
{{-- resources/views/admin/widgets/live-stats.blade.php --}}
<div data-turbo-poll="10000">  {{-- Обновление каждые 10 сек --}}
    <div class="row">
        <div class="col">
            <div class="alert alert-info">
                <strong>{{ $stats['online_users'] }}</strong> пользователей онлайн
            </div>
        </div>
        <div class="col">
            <div class="alert alert-warning">
                <strong>{{ $stats['pending_orders'] }}</strong> заказов ожидают обработки
            </div>
        </div>
    </div>
</div>
⚠️ Важно

Производительность: Real-time виджеты создают нагрузку. Используйте кэширование и разумные интервалы обновления (не чаще 10 сек).

Итоги

КомпонентНазначение
Layout::metrics()Карточки с метриками
Chart классВстроенные графики
Layout::view()Кастомные виджеты
Turbo pollingReal-time данные

Структура дашборда:

text
┌─────────────┬─────────────┬─────────────┬─────────────┐
│   Выручка   │   Заказы    │   Клиенты   │   Товары    │
└─────────────┴─────────────┴─────────────┴─────────────┘
┌────────────────────────────┬────────────────────────────┐
│      График продаж         │      График заказов        │
└────────────────────────────┴────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│                   Последние заказы                      │
└─────────────────────────────────────────────────────────┘

Частые вопросы

Почему дашборд в Orchid открывается медленно?

Потому что метод query() выполняется при каждом открытии экрана, а каждая плитка с показателем — это отдельный агрегирующий запрос. Восемь плиток означают восемь запросов вида COUNT и SUM по всем заказам за период, и все они уходят в базу синхронно, пока пользователь смотрит на пустой экран. На тестовых данных это незаметно, на боевой базе с сотнями тысяч заказов — секунды на каждое открытие, а открывают дашборд постоянно: это стартовая страница админки.

Как ускорить дашборд?

Тремя способами. Кэшировать показатели: выручка за прошлый месяц не меняется вообще, за сегодня меняется постепенно, и кэш на 5–15 минут снимает почти всю нагрузку — актуальность цифр на дашборде редко требуется до секунды. Проверить индексы на полях, по которым идёт группировка и фильтрация, обычно это дата создания и статус: без них каждая плитка означает полное сканирование таблицы. И считать агрегаты заранее ночной задачей, складывающей дневные итоги в отдельную таблицу.

Нужно ли обновлять дашборд в реальном времени?

Обычно нет. Периодический опрос — самая дорогая функция дашборда: обновление раз в 10 секунд у пяти открытых вкладок в отделе продаж означает тридцать полных выполнений query() в минуту, то есть постоянную фоновую нагрузку от страницы, на которую никто не смотрит. Для дашборда руководителя, который открывают утром, автообновление не нужно. Для монитора на стене в отделе — да, но тогда интервал имеет смысл сделать минутным, а данные кэшированными.

Чем выводить метрики и графики в Orchid?

Для карточек с ключевыми показателями есть встроенный компонент Layout::metrics. Для графиков — встроенные классы Chart с линейным, столбчатым и круговым типами. Если встроенных возможностей не хватает, подключают Chart.js в своём Blade-шаблоне через кастомный Layout-виджет.

Как сделать свой виджет в Orchid?

Через кастомный Layout-класс со своим Blade-шаблоном: класс готовит данные, шаблон их отрисовывает. Для графиков в шаблон подключается Chart.js, а данные передаются из класса в виде массива. Это тот же механизм, на котором построены встроенные компоненты, поэтому виджет ведёт себя как родной элемент экрана.

Что учесть при добавлении новых показателей на дашборд?

Что каждый показатель стоит запроса, и цена умножается на частоту открытий. Прежде чем добавлять девятую плитку, спросите, будет ли на неё кто-то смотреть: дашборд из четырёх осмысленных цифр, который открывается мгновенно, полезнее двенадцати, которые грузятся три секунды.