Docs›Exemplos›Dashboard real
Exemplos

Dashboard real

Métricas + gráficos + tabela de atividades recentes.

Exemplo completo de dashboard analítico em MAD: filtro de período, KPIs, gráficos com drill-down, pivot table e uma listagem recente — toda a lógica de query roda no servidor via Mad\Dashboard\MadDashboard, sem JavaScript extra escrito à mão.

Estrutura do dashboard

┌─ Filtros (período + status) — <mad-dash-filters>, props auto-aplicadas
│
├─ Row 1: 3 KPI cards (pedidos, faturamento, ticket médio)
│
├─ Row 2: 2 charts (vendas por dia — linha · por status — donut, com drill-down)
│
├─ Row 3: pivot table (vendedor × status)
│
└─ Row 4: listagem dos 10 pedidos mais recentes do período filtrado
Por que MadDashboard e não MadComponent puro

Mad\Dashboard\MadDashboard (que usa Mad\Filters\MadFiltersTrait por baixo) já resolve filtro de período, persistência em sessão e comparação com o período anterior. Os componentes db-metric-card/db-chart/ mad-grid recebem o Eloquent Builder pronto via $that->baseQuery(Model::class) — o mesmo builder, com o mesmo filtro, em todos os widgets.

1. Model

// app/Models/PedidoVenda.php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class PedidoVenda extends Model
{
    protected $connection = 'business';
    protected $table      = 'pedido_venda';

    protected $fillable = [
        'cliente_id', 'vendedor_id', 'dt_pedido', 'status', 'valor_total',
    ];

    public function cliente(): BelongsTo
    {
        return $this->belongsTo(Cliente::class, 'cliente_id');
    }

    public function vendedor(): BelongsTo
    {
        return $this->belongsTo(Vendedor::class, 'vendedor_id');
    }
}

2. Controller

// app/control/Dashboard/PedidoVendaDashboard.php
namespace App\Control\Dashboard;

use App\Models\PedidoVenda;
use Illuminate\Database\Eloquent\Builder;
use Mad\Dashboard\MadDashboard;

class PedidoVendaDashboard extends MadDashboard
{
    protected static string $title = 'Vendas';

    // Filtro de período por intervalo de datas no campo dt_pedido.
    protected string $periodType = 'date-range';
    protected string $dateField  = 'dt_pedido';
    protected bool $rememberFilters = true;

    // Filtro extra () — prop pública auto-aplicada no baseQuery.
    public string $status = '';

    /** Builder dos cards/charts — período + filtro de status já aplicados. */
    public function queryPedidos(): Builder
    {
        return $this->baseQuery(PedidoVenda::class);
    }

    /** Janela anterior (mesmo tamanho de período) — pro card de comparação. */
    public function queryPedidosAnterior(): Builder
    {
        return $this->comparePeriodQuery(PedidoVenda::class);
    }

    /**
     * mad-pivot-table não aceita Eloquent Builder (:query) — só array-DSL
     * (:filters="[['campo','op','val'], ...]"). Traduz o mesmo filtro de
     * período/status pro formato que o componente entende.
     */
    public function pivotFilters(): array
    {
        $filters = [];
        if ($this->dtIni !== '') $filters[] = ['dt_pedido', '>=', $this->dtIni];
        if ($this->dtFim !== '') $filters[] = ['dt_pedido', '<=', $this->dtFim . ' 23:59:59'];
        if ($this->status !== '') $filters[] = ['status', '=', $this->status];
        return $filters;
    }

    protected function view(): string|array
    {
        return ['dashboard.pedido-venda-dashboard', ['__component' => $this]];
    }
}

3. View


<mad-page-container>
    <mad-page-header title="Dashboard de Vendas" icon="trending-up" breadcrumb="Vendas > Dashboard" />

    <mad-page-content>
        
        <mad-dash-filters style="toolbar">
            <mad-select-field name="status" label="Status" placeholder="Todos">
                <option value="">Todos</option>
                <option value="aprovado">Aprovado</option>
                <option value="pendente">Pendente</option>
                <option value="cancelado">Cancelado</option>
            </mad-select-field>
        </mad-dash-filters>

        
        <mad-form-grid :cols="3" class="mb-4">
            <mad-db-metric-card model="PedidoVenda" :query="$that->queryPedidos()"
                total="count" label="Pedidos" icon="shopping-cart" format="integer" />

            <mad-db-metric-card model="PedidoVenda" :query="$that->queryPedidos()"
                field="valor_total" total="sum" label="Faturamento"
                icon="circle-dollar-sign" variant="success" format="money:R$ " />

            
            <mad-dashboard-metric-compare model="PedidoVenda" total="avg" field="valor_total"
                :current-query="$that->queryPedidos()" :compare-query="$that->queryPedidosAnterior()"
                label="Ticket médio" compare-label="vs período anterior" icon="receipt" />
        </mad-form-grid>

        
        <mad-form-grid :cols="2" class="mb-4">
            <mad-db-chart type="line" model="PedidoVenda" :query="$that->queryPedidos()"
                group-by="dt_pedido" field="valor_total" total="sum"
                title="Vendas por dia" format="currency:R$" :height="320" area />

            
            <mad-db-chart type="donut" model="PedidoVenda" :query="$that->queryPedidos()"
                group-by="status" total="count"
                title="Por status" :height="320" legend percentage
                filter-prop="status" filter-mode="direct" />
        </mad-form-grid>

        
        
        <mad-pivot-table model="PedidoVenda" :filters="$that->pivotFilters()"
            :joins="['vendedor' => ['pedido_venda.vendedor_id', 'vendedor.id']]"
            title="Detalhamento: vendedor x status" :height="400" subtotals>
            <mad-pivot-row field="vendedor.nome" label="Vendedor" />
            <mad-pivot-col field="status" label="Status" />
            <mad-pivot-value field="valor_total" label="Valor" aggregation="sum" format="currency" />
            <mad-pivot-value field="pedido_venda.id" label="Pedidos" aggregation="count" />
        </mad-pivot-table>

        <mad-separator />

        
                <mad-card :header="$cardHeader">
            <mad-grid model="PedidoVenda" :query="$that->queryPedidos()" per-page="10" no-export>
                <mad-columns>
                    <mad-col field="id" label="N." width="80" sort />
                    <mad-col field="cliente->nome" label="Cliente" />
                    <mad-col field="dt_pedido" label="Data" date="d/m/Y" sort />
                    <mad-col field="valor_total" label="Valor" right money="R$" total="sum" />
                    <mad-col field="status" label="Status"
                        badge="aprovado:success:Aprovado|pendente:warning:Pendente|cancelado:danger:Cancelado" />
                </mad-columns>
                <mad-actions>
                    <mad-nav icon="eye" label="Ver" target="PedidoVendaForm::onEdit({id})" />
                </mad-actions>
            </mad-grid>
        </mad-card>
    </mad-page-content>
</mad-page-container>

Pivot por coluna de relação (:joins)

O pivot acima agrupa por vendedor.nome — uma coluna da tabela relacionada, não da tabela base pedido_venda. Pra isso o mad-pivot-table precisa do JOIN declarado na prop :joins. O formato é um array associativo com a chave = nome da tabela a juntar, e o valor = [coluna_fk, coluna_pk] (operador = implícito) ou [coluna_fk, operador, coluna_pk]:

:joins="['vendedor' => ['pedido_venda.vendedor_id', 'vendedor.id']]"
//         ▲tabela        ▲FK na tabela base        ▲PK na tabela juntada
ParteValor no exemploRegra
chave do array'vendedor'Nome real da tabela a juntar (não o Model, não a relação).
[0] — FKpedido_venda.vendedor_idColuna do lado base. Sem ., é qualificada pra tabela base automaticamente.
[1] — PKvendedor.idQualifique explícito (tabela.coluna): sem o ., o parser prefixaria a tabela base por engano.

Com o join declarado, referencie a coluna juntada com o nome qualificado (field="vendedor.nome") nas child tags mad-pivot-row / mad-pivot-col / mad-pivot-value. Colunas da tabela base (valor_total) seguem valendo sem prefixo; só qualifique quando o nome ficar ambíguo entre as duas tabelas — por isso o exemplo usa pedido_venda.id na contagem (ambas as tabelas têm id). Mesmo formato vale pro :joins de mad-db-chart (group-by numa coluna FK).

Alternativa sem join: se você só quer o label do vendedor no eixo (e não agrupar/filtrar por outra coluna dele), o filter-mode="lookup" filter-model="Vendedor" do gráfico resolve o id → nome sem JOIN (ver tabela de drill-down abaixo). Use :joins quando precisar de fato pivotar/agregar por colunas da tabela relacionada.

Drill-down — duas formas de fazer uma fatia do gráfico filtrar a página

O mad-db-chart tem clique-pra-filtrar embutido, em duas formas. A declarativa (usada acima) é a preferida: nenhum método PHP — clicar numa fatia/barra seta a prop do filtro direto e recarrega o dashboard via auto-bind.

FormaComoQuando usar
Declarativa (preferida) filter-prop="status" filter-mode="direct" Filtro 1:1 com uma prop pública existente — sem PHP extra.
Declarativa com lookup filter-prop="vendedor_id" filter-mode="lookup" filter-model="Vendedor" O label exibido no eixo não é o valor cru salvo na coluna (ex.: nome em vez de id).
Handler custom click-action="onCategoriaClick" Lógica além de "setar uma prop e recarregar" — recebe ($name, $value, $dataIndex).
// Forma com handler custom (só quando a declarativa não cobre o caso):
public function onCategoriaClick(string $status, mixed $value, int $dataIndex): void
{
    $this->status = $status;
    // void — auto-bind recarrega KPIs, pivot e listagem com o novo filtro.
}

Cache do dashboard

O MadDashboard tem cache nativo do payload da view, por combinação de filtros — não precisa escrever Cache::remember à mão. Ligue com $cacheTtl (segundos; 0 = desligado, o default) e implemente cacheableViewData(); o view() consome via viewData():

class PedidoVendaDashboard extends MadDashboard
{
    /** TTL em segundos do payload da view. 0 = off. */
    protected int $cacheTtl = 600;

    /**
     * SÓ dados serializáveis (arrays/escalares). Builder e Closure NÃO entram
     * aqui — não serializam; ficam no view().
     */
    protected function cacheableViewData(): array
    {
        return [
            'faturamento' => (float) (clone $this->queryPedidos())->sum('valor_total'),
            'ranking'     => (clone $this->queryPedidos())
                ->selectRaw('vendedor_id, SUM(valor_total) AS total')
                ->groupBy('vendedor_id')->orderByDesc('total')->limit(10)
                ->get()->toArray(),
        ];
    }

    protected function view(): string|array
    {
        // viewData() = cacheableViewData() já cacheado por filtros; o Builder
        // vai por fora, cru.
        return ['dashboard.pedido-venda-dashboard',
            $this->viewData() + ['__component' => $this]];
    }
}
MembroPapel
protected int $cacheTtlTTL em segundos. 0 (default) desliga tudo, inclusive o fallback de chart-cache abaixo.
cacheableViewData(): arrayO que é cacheado. Nunca retorne Builder/Closure.
viewData(): arrayConsome o cache no view(). Falha do store não derruba a tela — cai no cômputo direto.
cacheTtlFor(): intOverride do TTL efetivo (ex.: janela de período já fechada = TTL maior).
cacheKeyExtra(): stringComponente extra da chave. Obrigatório em multi-tenant.
Multi-tenant: sem cacheKeyExtra() o cache vaza dados

A chave default é maddash:<Classe>:<extra>:<md5 do snapshot de filtros> — o snapshot não conhece tenant. Duas empresas com o mesmo filtro leem a mesma entrada. Sempre inclua o escopo:

protected function cacheKeyExtra(): string
{
    return 'tenant:' . (string) session('tenant_id');
}

Bônus — fallback por página do cache dos charts: se o global mad.chart.cache_ttl está desligado (<= 0) e $cacheTtl > 0, o mesmo TTL é aplicado ao Mad\Database\QueryCache dos mad-db-chart/ mad-db-metric-card :query desta página durante o render. Global ligado sempre vence — a página nunca rebaixa nem sobe o TTL do projeto.

Próximos