MadDashboard (base class)
Base class para dashboards reativos com filtros + auto-refresh.
Classe base (Mad\Dashboard\MadDashboard) para dashboards com filtros declarativos, comparação automática
com período anterior, drill-down via chart label, widget toggle e
auto-refresh. Funciona via composição com Mad\Filters\MadFiltersTrait — o
mesmo trait usado por MadDataGrid para a parte de filtros/período.
Quando usar: dashboard com KPIs + gráficos + tabela de atividade onde usuário troca período/filtros e tudo recarrega.
Quando NÃO usar: página estática com 1 métrica → use
<mad-db-metric-card>direto.
Quick start
Controller mínimo
<?php
use Mad\Dashboard\MadDashboard;
use Illuminate\Database\Eloquent\Builder;
class DashboardNegociacao extends MadDashboard
{
protected bool $defaultToCurrentPeriod = true;
public function queryEmNegociacao(): Builder
{
return $this->baseQuery('Negociacao')->whereIn('estado_id', [1, 2, 3]);
}
protected function view(): string|array
{
return 'crm.dashboard-negociacao';
}
}
View
<mad-page-container>
<mad-page-header title="Dashboard CRM" icon="layout-dashboard" />
<mad-page-content>
<mad-dash-filters style="toolbar">
<mad-period-monthyear />
<mad-dbcombo-field name="vendedor_id" label="Vendedor" model="Pessoa" display="nome" />
</mad-dash-filters>
<mad-form-grid :cols="4">
<mad-db-metric-card :query="$that->queryEmNegociacao()" total="count"
label="Em negociação" icon="briefcase" />
<mad-dashboard-metric-compare
:current-query="$that->queryEmNegociacao()"
:compare-query="$that->comparePeriodQuery('Negociacao')->whereIn('estado_id', [1, 2, 3])"
field="valor" total="sum"
label="Pipeline" icon="circle-dollar-sign" format="money:R$" />
<mad-db-metric-card :query="$that->baseQuery('Cliente')" total="count"
label="Clientes" icon="users" />
<mad-db-metric-card :query="$that->queryEmNegociacao()" field="valor" total="avg"
label="Ticket médio" icon="trending-up" format="money:R$" />
</mad-form-grid>
<mad-form-grid :cols="2" class="mt-4">
<mad-db-chart :query="$that->queryEmNegociacao()" type="bar"
group-by="estado.nome" total="count" title="Por estado" />
<mad-db-chart :query="$that->baseQuery('Negociacao')" type="line"
group-by="mes" field="valor" total="sum" title="Histórico" />
</mad-form-grid>
</mad-page-content>
</mad-page-container>
Configuração — props do controller
Período
| Prop | Default | Descrição |
|---|---|---|
$periodType |
month-year |
none | month-year | date-range | preset |
$dateField |
'' |
Coluna usada em date-range/preset |
$periodFields |
['mes' => 'mes', 'ano' => 'ano'] |
Colunas em month-year (pode variar por model — ver periodColumnFor()) |
$defaultToCurrentPeriod |
false | Seed automático com mês/ano atuais no mount() |
$usePresets |
false | Habilita dropdown de presets dentro do period-filter |
$rememberFilters |
false | Persiste filtros em sessão entre requests |
Multi-tenant (opt-in)
| Prop | Default | Descrição |
|---|---|---|
$applyUnitFilter |
false | Adiciona filtro de unit (multi-tenant) via session('userunitid') |
$unitField |
unit_id |
Coluna FK na tabela alvo |
$unitFields |
[] |
Override por model: ['Negociacao' => 'filial_id'] |
Auto-refresh
| Prop | Default | Descrição |
|---|---|---|
$autoRefreshSec |
0 | Intervalo em segundos (0 = desabilitado, mínimo efetivo 5) |
Filtros auto-discovery
Qualquer prop pública string/array declarada na subclasse (exceto form,
mes, ano, dtIni, dtFim, preset, que são reservadas pelo trait) vira
automaticamente um filtro: aparece em <mad-dash-filters>, é hidratada de
request/sessão e é aplicada em baseQuery()/applyAutoFiltersToQuery().
| Prop | Default | Descrição |
|---|---|---|
$skipAutoFilter |
[] |
Nomes de props que NÃO entram no auto-merge — controle manual via onSearch() |
A hidratação ($_GET/$_POST → props, depois $params) coage o valor pro
tipo declarado da prop antes de atribuir: ?prop[]=x contra uma prop
string é descartado (retorna null) em vez de fatalar com TypeError; um
escalar contra prop array vira [$valor]; arrays mantêm só elementos
escalares (nested array estouraria no whereIn). Valor vazio nunca sobrescreve
— um campo em branco no request não apaga o que veio da sessão.
Cache do payload da view (opt-in)
| Prop | Default | Descrição |
|---|---|---|
$cacheTtl |
0 |
TTL em segundos do cache do payload da view por combinação de filtros. 0 = desligado |
Helpers da base
baseQuery(string $model, array $opts = []): Builder
Eloquent Builder pronto com período + unit + auto-filters já aplicados. Ponto de partida padrão pra qualquer query do dashboard:
public function queryProspeccao(): \Illuminate\Database\Eloquent\Builder
{
return $this->baseQuery('Negociacao') // já tem período + filtros
->where('estado_id', '=', EstadoNegociacao::PROSPECCAO);
}
$opts aceita 'period' => false e 'unit' => false pra pular essas etapas,
e 'only'/'exclude' (array de nomes de prop) pra controlar quais
auto-filters entram.
comparePeriodQuery(string $model, array $opts = []): Builder
Mesma assinatura de baseQuery(), mas monta a janela do período anterior
(deslocada conforme $periodType) em vez do período atual. Combine com a
query atual + computeDelta() (ou use <mad-dashboard-metric-compare>, que
já faz isso por baixo dos panos):
public function queryAnterior(): \Illuminate\Database\Eloquent\Builder
{
return $this->comparePeriodQuery('Negociacao')
->where('estado_id', '=', EstadoNegociacao::PROSPECCAO);
}
MadDashboard::computeDelta(float $current, float $previous): array
Estático. Calcula variação % e tendência entre dois valores agregados — labels pt-BR com vírgula decimal:
$d = MadDashboard::computeDelta(1500, 1200);
// ['delta_pct' => 25.0, 'trend' => 'up', 'label' => '+25,0%', 'sign' => '+']
trend é up | down | flat. Use $d['label'] direto como prop trend
de <mad-db-metric-card> quando computar o delta manualmente.
applyPeriodoToQuery($q, ?string $model) / applyUnitToQuery($q, ?string $model) / applyAutoFiltersToQuery($q, ?string $model, array $opts)
Peças que baseQuery() compõe — use direto quando precisar montar uma query
já com join/select custom e só "encaixar" os filtros do dashboard nela:
$q = Negociacao::query()->join('pessoa', 'pessoa.id', '=', 'negociacao.pessoa_id');
$this->applyPeriodoToQuery($q, 'Negociacao');
$this->applyAutoFiltersToQuery($q, 'Negociacao');
Filtros declarativos — <mad-dash-filters>
Mesma API de <mad-grid-filters>. Ver filtros declarativos.
<mad-dash-filters style="toolbar">
<mad-period-monthyear />
<mad-dbcombo-field name="vendedor_id" label="Vendedor" model="Pessoa" display="nome" />
<mad-dbcombo-field name="cliente_id" label="Cliente" model="Cliente" display="nome" />
</mad-dash-filters>
Props auto-discovered:
public string $vendedor_id = ''; // nome = coluna → auto-filter
public string $cliente_id = '';
style="sidebar-left"/"sidebar-right" rendeririza a barra como coluna fixa;
nesse modo envolva o conteúdo principal em <mad-dashboard-main> pra manter o
layout flex em 2 colunas (ver grid-filters.md).
Comparação com período anterior
<mad-db-metric-card> não tem prop compare/criteria — a comparação é
sempre 2 queries (atual + anterior) reduzidas a um delta. Duas formas:
1. Declarativa — <mad-dashboard-metric-compare> (preferir)
Wrapper de <mad-db-metric-card> que recebe as duas queries já prontas,
agrega ambas e injeta o trend formatado automaticamente:
<mad-dashboard-metric-compare
:current-query="$that->baseQuery('Pedido')"
:compare-query="$that->comparePeriodQuery('Pedido')"
field="valor" total="sum"
label="Faturamento" icon="circle-dollar-sign" format="money:R$" />
2. Manual — quando precisa do valor do delta pra outra coisa
public function metricas(): array
{
$atual = $this->baseQuery('Pedido')->sum('valor');
$anterior = $this->comparePeriodQuery('Pedido')->sum('valor');
return MadDashboard::computeDelta((float) $atual, (float) $anterior);
}
@php $d = $that->metricas(); @endphp
<mad-db-metric-card :query="$that->baseQuery('Pedido')" field="valor" total="sum"
label="Faturamento" format="money:R$"
:trend="$d['label']" trend-label="vs período anterior" />
Clique-para-filtrar (drill-down dentro do dashboard)
<mad-db-chart> já sabe aplicar filtro no host ao clicar numa
categoria/fatia via filter-prop/filter-mode — não precisa de JS manual nem
de override de método (applyFilterFromLabel/applyFilterDirect já existem
na base e são chamados automaticamente). Ver a seção "Clique-para-filtrar
(drill-down)" em db-chart.md para a API completa
(filter-mode="direct|lookup|period", filter-toggle, click-action para
handler 100% customizado).
<mad-db-chart type="bar" model="Cliente"
group-by="estado" total="count" title="Por estado"
filter-prop="estado_nome" filter-mode="direct" />
Widget toggle (show/hide via sessão)
@if ($that->isWidgetVisible('metricas_extra'))
<mad-db-metric-card ... />
@endif
<mad-btn mad:click="onToggleWidget('metricas_extra')">
{{ $that->isWidgetVisible('metricas_extra') ? 'Esconder' : 'Mostrar' }} extras
</mad-btn>
Métodos:
isWidgetVisible(string $key): boolonToggleWidget(string $key): void— único método público de toggle; chame direto viamad:click="onToggleWidget('chave')"hiddenWidgets(): array— lista de keys escondidos (pra UI de configuração)
State persiste em mad_dashboard_<class>_hidden_widgets na sessão.
Auto-refresh
class DashboardOperacional extends MadDashboard
{
protected int $autoRefreshSec = 60; // recarrega a cada 60s
}
O timer não é automático na view — inclua o partial no template do
dashboard, passando getAutoRefreshSec():
@include('components.dashboard-auto-refresh', ['seconds' => $that->getAutoRefreshSec()])
O partial é wrapper-bound: o timer é ancorado no [mad-component] que
contém o marcador e chama MadWire.call(wrapper, 'onAtualizar') — re-executa as
queries com o estado JÁ confirmado no server, sem re-submeter inputs meio
editados e sem window.location.reload().
O tick é pulado quando:
- o wrapper saiu do DOM (
isConnected === false) → o timer se encerra sozinho (navegação SPA / morph); - o marcador sumiu num re-render parcial (feature desligada) → encerra;
- a aba está em background (
document.hidden); - há request em voo (
data-mad-busyno wrapper); - o usuário está digitando num
input/select/textareadentro do wrapper.
Abaixo de 5s o include não faz nada; $seconds é normalizado com max(5, …).
Histórico (corrigido no 5.x): a versão antiga usava um timer GLOBAL (
window.__madDashboardRefreshTimer) +window.location.reload()de fallback — o timer sobrevivia à navegação SPA e recarregava a tela ERRADA em loop. O partial atual ainda mata esse timer global legado ao carregar.
Cache (opt-in, OFF por default)
São duas camadas independentes — dá pra ligar uma, outra ou as duas:
| Camada | O que cacheia | Knob | Default |
|---|---|---|---|
A — payload da view (MadDashboard) |
Arrays computados no controller (KPIs, rankings, séries :data, sparklines) |
protected int $cacheTtl na subclasse |
0 (off) |
B — agregações dos charts (Mad\Database\QueryCache) |
O SQL que <mad-db-chart :query/model> e <mad-db-metric-card> executam no render |
config('mad.chart.cache_ttl') → env MAD_CHART_CACHE_TTL |
0 (off) |
Camada A — $cacheTtl + cacheableViewData()
Cache write-through por combinação de filtros: cada combinação de mês/ano/selects/drill-through vira uma entrada própria.
class DashboardVendas extends MadDashboard
{
protected int $cacheTtl = 600; // segundos; 0 = off (default)
/** SÓ arrays/escalares — NUNCA Builders ou closures (não serializam). */
protected function cacheableViewData(): array
{
return [
'cards' => $this->montarCards(),
'porRegiao' => $this->porRegiao(), // ['Sul' => 12, ...]
];
}
protected function view(): string|array
{
// viewData() = cacheableViewData() com cache; builders ficam FORA
// (os componentes :query executam no render — camada B cobre).
return ['vendas.dashboard', $this->viewData() + [
'serieVendas' => $this->baseQuery('PedidoVenda'),
]];
}
}
API (todos protected, overridáveis):
| Membro | Papel |
|---|---|
int $cacheTtl = 0 |
Liga a camada (segundos) |
cacheableViewData(): array |
A subclasse implementa: os dados serializáveis da tela |
viewData(): array |
O que o view() consome — cacheableViewData() com cache. Falha do store NUNCA derruba a tela (fallback pro cômputo direto) |
cacheTtlFor(): int |
TTL efetivo por request — override p/ regras tipo "mês fechado = TTL longo" |
viewCacheKey(): string |
Chave completa (raramente precisa de override) |
cacheKeyExtra(): string |
Componente extra da chave — é aqui que entra o escopo do tenant |
Formato da chave:
maddash:{FQCN da tela}:{cacheKeyExtra()}:{md5(filterStateSnapshot())}
filterStateSnapshot() (do MadFiltersTrait) cobre mes/ano/dtIni/dtFim/preset
- TODAS as props públicas de filtro auto-descobertas — qualquer filtro que o usuário mudar gera chave nova automaticamente.
⚠️ MULTI-TENANT (obrigatório): o snapshot de filtros NÃO inclui o tenant. Sem override de
cacheKeyExtra()com o escopo, um tenant LÊ O CACHE DO OUTRO.
protected function cacheKeyExtra(): string
{
return (string) TenantContext::id(); // ou o escopo do seu domínio
}
Invalidação automática por versão dos dados: coloque a "versão" (data da carga,
updated_at máximo etc.) dentro de cacheKeyExtra() — quando os dados mudam,
as chaves mudam e o cache renova sozinho; o TTL vira só housekeeping e pode ser
longo.
Camada B — fallback por página
Com o global mad.chart.cache_ttl desligado (<= 0) e $cacheTtl > 0, o
render() do dashboard aplica o TTL da página na config desta request
(applyPageChartCacheTtl()) — os <mad-db-chart>/<mad-db-metric-card>
:query da tela passam a usar o Mad\Database\QueryCache com o TTL da página.
O global ligado sempre vence (nunca rebaixa nem sobe o TTL de projeto). É
por-request (Octane-safe): nada vaza entre requests/usuários.
Hook onSearch() — controle total
class DashboardX extends MadDashboard
{
protected string $periodType = 'date-range';
protected string $dateField = 'dt_emissao';
protected array $skipAutoFilter = ['cliente_id', 'tipo'];
public string $cliente_id = '';
public string $tipo = '';
public function onSearch(\Illuminate\Database\Eloquent\Builder $q, ?string $model = null): void
{
if ($this->cliente_id !== '') {
$q->where('cliente_id', '=', $this->cliente_id);
}
if ($this->tipo !== '') {
$q->where('tipo', '=', $this->tipo);
}
}
}
onSearch()é detectado por reflection no primeiro parâmetro tipadoBuilder— recebe a query quebaseQuery()/comparePeriodQuery()já aplicaram período + unit + auto-filters; mutate ela direto com->where()/->whereIn()etc., não retorne uma nova. Props listadas em$skipAutoFiltercontinuam fazendo parte do state (sessão, hidratação,<mad-dash-filters>) — só não entram no merge automático, ficando 100% sob seu controle aqui.
Drill-down preservando filtros (navegar pra outra tela)
<mad-btn navigate="PedidoListagem" :params="$that->drillToParams(['extra' => 'X'])">
Ver pedidos
</mad-btn>
drillToParams($extra = []) retorna array com período + todas as filter props
não-vazias + extras — use em <mad-btn navigate :params>. drillTo($class, $method, $extra) retorna a URL equivalente (engine.php?...) quando precisa
de uma string em vez de array de params.
Gotchas
$periodTypedefault émonth-year— diferente doMadDataGrid(defaultnone)$dateFieldobrigatório emdate-rangeoupreset- Comparação de período usa 2 queries — não existe prop
compare/criteriaem<mad-db-metric-card>; use<mad-dashboard-metric-compare>(declarativo) oucomparePeriodQuery()+computeDelta()(manual) - Auto-refresh precisa do
@include('components.dashboard-auto-refresh', ...)manual — não é injetado sozinho pela classe base baseQuery()/comparePeriodQuery()retornam um NOVO Builder a cada chamada — não compartilhe a instância entre cards/gráficos, chame de novo (ou clone) em cada umonSearch()recebe um EloquentBuilderpor referência — mutate com->where(), não retorne novo; o contrato antigo de 2 parâmetros não-Builder não existe mais- Widget toggle não suporta widgets dinâmicos — keys precisam ser conhecidos