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
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
| Parte | Valor no exemplo | Regra |
|---|---|---|
| chave do array | 'vendedor' | Nome real da tabela a juntar (não o Model, não a relação). |
[0] — FK | pedido_venda.vendedor_id | Coluna do lado base. Sem ., é qualificada pra tabela base automaticamente. |
[1] — PK | vendedor.id | Qualifique 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).
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.
| Forma | Como | Quando 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]];
}
}
| Membro | Papel |
|---|---|
protected int $cacheTtl | TTL em segundos. 0 (default) desliga tudo, inclusive o fallback de chart-cache abaixo. |
cacheableViewData(): array | O que é cacheado. Nunca retorne Builder/Closure. |
viewData(): array | Consome o cache no view(). Falha do store não derruba a tela — cai no cômputo direto. |
cacheTtlFor(): int | Override do TTL efetivo (ex.: janela de período já fechada = TTL maior). |
cacheKeyExtra(): string | Componente extra da chave. Obrigatório em multi-tenant. |
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.