Docs›Componentes (Admin)›MadDashboard (base class)
Componentes (Admin)

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): bool
  • onToggleWidget(string $key): void — único método público de toggle; chame direto via mad: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-busy no wrapper);
  • o usuário está digitando num input/select/textarea dentro 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 tipado Builder — recebe a query que baseQuery()/comparePeriodQuery() já aplicaram período + unit + auto-filters; mutate ela direto com ->where()/->whereIn() etc., não retorne uma nova. Props listadas em $skipAutoFilter continuam 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

  • $periodType default é month-year — diferente do MadDataGrid (default none)
  • $dateField obrigatório em date-range ou preset
  • Comparação de período usa 2 queries — não existe prop compare/criteria em <mad-db-metric-card>; use <mad-dashboard-metric-compare> (declarativo) ou comparePeriodQuery() + 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 um
  • onSearch() recebe um Eloquent Builder por 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