Главная/Статьи/Виджеты и графики в Orchid — дашборд

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

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

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

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

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

Главный экран — Dashboard

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

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'),
        ]),
    ];
}

Real-time обновление

⚠️ Важно

Периодический опрос — самая дорогая функция дашборда. Обновление раз в 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
┌─────────────┬─────────────┬─────────────┬─────────────┐
│   Выручка   │   Заказы    │   Клиенты   │   Товары    │
└─────────────┴─────────────┴─────────────┴─────────────┘
┌────────────────────────────┬────────────────────────────┐
│      График продаж         │      График заказов        │
└────────────────────────────┴────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│                   Последние заказы                       │
└─────────────────────────────────────────────────────────┘
🚀

Хотите такое же решение?

Настрою окружение под ваш проект, учту специфику инфраструктуры и обучу команду.

Обсудить проект →
Бесплатная консультация · Ответ в течение дня

Комментарии

Система комментариев скоро будет подключена. А пока вы можете написать мне в Telegram или на email.