Dashboards e widgets
mad-chart
mad-db-chart
mad-bar-chart
mad-line-chart
mad-pie-chart
mad-donut-chart
mad-radar-chart
mad-mixed-chart
mad-funnel-chart
mad-treemap-chart
Bug de framework: GROUP BY quebra em alias de JOIN
O motor por trás de
Workaround usado nas demos desta página: a agregação roda em PHP/Collection no controller (
mad-db-chart (e dos aliases mad-bar-chart,
mad-pie-chart, mad-radar-chart etc. usados abaixo) monta o
GROUP BY sempre prefixado com a tabela BASE da query
(Mad\Chart\Engine\BaseChart::loadData()) — quebra com
SQLSTATE... no such column quando o campo de agrupamento é um alias que só
existe no SELECT: vindo de JOIN (ex.: tc.nome as categoria_nome)
ou computado (ex.: substr(dt_pedido,1,7) as mes_ref). Bug real do framework,
documentado em docs/tutor-framework-findings.md (item 3) — não corrigido aqui
porque packages/mad-framework é symlink pro repo oficial.
Workaround usado nas demos desta página: a agregação roda em PHP/Collection no controller (
TutorDashboardsCharts) e o mapa label => valor pronto vai pra
prop :data — que ignora esse pipeline quebrado. A única exceção é o gráfico
"misto" da galeria avançada: a série de linha exige uma 2ª agregação de verdade no banco,
então ali o group-by usa a coluna DIRETA categoria_id (não o alias) e o rótulo
legível vem de um legend-transformer, não do group-by.
Barra e linha
mad-bar-chart e mad-line-chart — aliases que compilam para mad-db-chart com o type já fixado. Dados via :data (agregados em PHP) por causa do bug acima.
Receita por categoria
Receita por mês
resources/views/tutor/examples/dashboards-charts/bar-e-linha.blade.php
@php($__component = \Mad\Component\MadRenderContext::getComponent())
<mad-form-grid :cols="2">
<mad-bar-chart name="receita_categoria" :data="$__component->receitaPorCategoria()"
title="Receita por categoria" format="currency:R$" height="260" />
<mad-line-chart name="receita_mensal" :data="$__component->receitaPorMes()"
title="Receita por mês" format="currency:R$" area smooth height="260" />
</mad-form-grid>
app/control/Tutor/TutorDashboardsCharts.php
/** Receita (soma de valor_total) por categoria, maior primeiro. */
public function receitaPorCategoria(): array
{
return $this->queryPedidosComCategoria()
->get(['categoria_nome', 'valor_total'])
->groupBy('categoria_nome')
->map(fn ($grupo) => (float) $grupo->sum('valor_total'))
->sortDesc()
->all();
}
Pizza e rosca
mad-pie-chart (percentual) e mad-donut-chart (legenda) sobre a mesma fonte de dados — também via :data pelo mesmo motivo.
Pedidos por estado
Pedidos por categoria
resources/views/tutor/examples/dashboards-charts/pizza-e-rosca.blade.php
@php($__component = \Mad\Component\MadRenderContext::getComponent())
<mad-form-grid :cols="2">
<mad-pie-chart name="pedidos_estado_pizza" :data="$__component->pedidosPorEstado()"
title="Pedidos por estado" percentage height="260" />
<mad-donut-chart name="pedidos_categoria_rosca" :data="$__component->pedidosPorCategoria()"
title="Pedidos por categoria" legend height="260" />
</mad-form-grid>
mad-db-chart com dados manuais
O componente genérico por trás de todos os aliases — aqui em modo :data, sem model nenhum.
Receita por canal (dados manuais, sem model)
resources/views/tutor/examples/dashboards-charts/db-chart-dados-manuais.blade.php
<mad-db-chart type="bar" name="canais_manuais"
:data="['Loja física' => 62000, 'E-commerce' => 41000, 'Marketplace' => 27500, 'Televendas' => 9800]"
title="Receita por canal (dados manuais, sem model)" format="currency:R$" height="260" />
mad-chart (builder PHP)
Para lógica pesada demais pro mad-db-chart declarativo — builder fluente Mad\Chart\MadChart.
Receita por categoria
construído com Mad\Chart\MadChart (builder PHP)
resources/views/tutor/examples/dashboards-charts/chart-builder-php.blade.php
@php($__component = \Mad\Component\MadRenderContext::getComponent())
<mad-chart name="categorias_builder" :config="$__component->chartCategoriasBuilder()" />
app/control/Tutor/TutorDashboardsCharts.php
/**
* mad-chart (builder PHP) — já funcionava antes do fix desta onda; reusa
* receitaPorCategoria() (mesma agregação, sem duplicar lógica).
*/
public function chartCategoriasBuilder(): MadChart
{
return MadChart::bar('categorias_builder')
->data($this->receitaPorCategoria())
->title('Receita por categoria', 'construído com Mad\\Chart\\MadChart (builder PHP)')
->currency()
->height(280)
->legend(true, 'bottom');
}
Galeria: radar, misto, funil e treemap
Radar/funil/treemap via :data; o misto é a única demo com group-by SQL de verdade (coluna categoria_id + legend-transformer), porque a série de linha precisa agregar no banco.
Pedidos (barra) x Receita (linha) por categoria
SQL Debug — mensal_misto
With inlined binds
select tutor_pedidos.categoria_id as "categoria_id", count(tutor_pedidos.categoria_id) as total, sum(tutor_pedidos.valor_total) as line_total from "tutor_pedidos" group by tutor_pedidos.categoria_id order by tutor_pedidos.categoria_id
Prepared statement
select tutor_pedidos.categoria_id as "categoria_id", count(tutor_pedidos.categoria_id) as total, sum(tutor_pedidos.valor_total) as line_total from "tutor_pedidos" group by tutor_pedidos.categoria_id order by tutor_pedidos.categoria_id
Funil por estado do pedido
Receita por categoria (treemap)
resources/views/tutor/examples/dashboards-charts/galeria-avancados.blade.php
@php
$__component = \Mad\Component\MadRenderContext::getComponent();
// legend-transformer do mixed: traduz categoria_id (coluna DIRETA usada
// no group-by pra fugir do bug #3 do GROUP BY) pro nome legível.
$labelCategoria = fn ($v) => $__component->labelCategoria($v);
@endphp
<mad-form-grid :cols="2">
<mad-radar-chart name="radar_categorias" :data="$__component->receitaPorCategoria()"
title="Receita por categoria (radar)" height="260" no-panel />
<mad-mixed-chart name="mensal_misto" :query="$__component->queryPedidos()"
group-by="categoria_id" total="count"
line-total="sum" line-field="valor_total" line-label="Receita" line-secondary-axis
:legend-transformer="$labelCategoria"
title="Pedidos (barra) x Receita (linha) por categoria" height="260" />
<mad-funnel-chart name="funil_estados" :data="$__component->pedidosPorEstado()"
title="Funil por estado do pedido" height="260" />
<mad-treemap-chart name="treemap_categoria" :data="$__component->receitaPorCategoria()"
title="Receita por categoria (treemap)" height="260" />
</mad-form-grid>
Código-fonte completo da página
app/control/Tutor/TutorDashboardsCharts.php
<?php
namespace App\Control\Tutor;
use App\Models\TutorDemo\TutorDemoCategoria;
use App\Models\TutorDemo\TutorDemoPedido;
use App\Support\Tutor\TutorDemoSeed;
use App\Support\Tutor\TutorPage;
use Illuminate\Database\Eloquent\Builder;
use Mad\Chart\MadChart;
/**
* TutorDashboardsCharts — página "Gráficos" do catálogo.
*
* BUG DE FRAMEWORK (ver docs/tutor-framework-findings.md, item 3, e o
* <mad-callout> no topo de resources/views/tutor/dashboards/charts.blade.php):
* o motor por trás de mad-db-chart (Mad\Chart\Engine\BaseChart::loadData())
* monta o GROUP BY sempre prefixado com a tabela BASE da query
* (`{$entity}.{$campo}`) — quebra com "SQLSTATE... no such column" quando o
* campo de agrupamento é um alias que só existe no SELECT: vindo de JOIN
* (ex.: `tc.nome as categoria_nome`) ou computado (ex.:
* `substr(dt_pedido,1,7) as mes_ref`).
*
* Workaround (o mesmo documentado no findings): a agregação roda em
* PHP/Collection — Eloquent normal, sem group-by de SQL — e o mapa
* `label => valor` pronto vai pra prop `:data` do componente declarativo,
* que ignora completamente esse pipeline. Isso cobre bar/line/pie/donut/
* radar/funnel/treemap (todos os métodos abaixo prefixados com
* "receitaPor"/"pedidosPor").
*
* A ÚNICA demo que continua com group-by de SQL de verdade é o mixed (galeria
* avançada): o modo B do line-metric roda uma 2ª agregação NO BANCO, então
* não dá pra resolver com :data manual. Ali o group-by usa a coluna DIRETA
* `categoria_id` (não o alias `categoria_nome`) — coluna real da tabela base,
* então o bug não se aplica — e o rótulo legível vem de um legend-transformer
* (callable PHP), não do group-by.
*/
class TutorDashboardsCharts extends TutorPage
{
protected string $catalogKey = 'dashboards_charts';
/** Cache de instância p/ o legend-transformer do mixed (evita N+1). */
private ?array $categoriaNomes = null;
public function mount(array $params = []): void
{
TutorDemoSeed::ensure();
}
// ── Queries base com JOIN (usadas para agregar EM PHP, não como :query
// declarativo — é isso que evita o bug) ────────────────────────────────
public function queryPedidosComCategoria(): Builder
{
return TutorDemoPedido::query()
->join('tutor_categorias as tc', 'tc.id', '=', 'tutor_pedidos.categoria_id')
->select('tutor_pedidos.*', 'tc.nome as categoria_nome');
}
public function queryPedidosComEstado(): Builder
{
return TutorDemoPedido::query()
->join('tutor_estados_pedido as te', 'te.id', '=', 'tutor_pedidos.estado_id')
->select('tutor_pedidos.*', 'te.nome as estado_nome')
->orderBy('te.ordem');
}
/** Query plana (sem join) — usada pelo mixed, que agrupa por categoria_id direto. */
public function queryPedidos(): Builder
{
return TutorDemoPedido::query();
}
// ── :data pronto — agregação em PHP/Collection, evita o group-by quebrado ──
/** Receita (soma de valor_total) por categoria, maior primeiro. */
public function receitaPorCategoria(): array
{
return $this->queryPedidosComCategoria()
->get(['categoria_nome', 'valor_total'])
->groupBy('categoria_nome')
->map(fn ($grupo) => (float) $grupo->sum('valor_total'))
->sortDesc()
->all();
}
/** Receita por mês (yyyy-mm agregado em PHP), ordenado cronologicamente. */
public function receitaPorMes(): array
{
return TutorDemoPedido::query()
->get(['dt_pedido', 'valor_total'])
->groupBy(fn ($pedido) => substr($pedido->dt_pedido, 0, 7))
->sortKeys()
->mapWithKeys(fn ($grupo, $anoMes) => [
self::mesAnoLabel($anoMes) => (float) $grupo->sum('valor_total'),
])
->all();
}
/** Contagem de pedidos por estado, na ordem do funil (Novo → Cancelado). */
public function pedidosPorEstado(): array
{
return $this->queryPedidosComEstado()
->get(['estado_nome'])
->groupBy('estado_nome')
->map(fn ($grupo) => $grupo->count())
->all();
}
/** Contagem de pedidos por categoria. */
public function pedidosPorCategoria(): array
{
return $this->queryPedidosComCategoria()
->get(['categoria_nome'])
->groupBy('categoria_nome')
->map(fn ($grupo) => $grupo->count())
->all();
}
private static function mesAnoLabel(string $anoMes): string
{
static $meses = [
'01' => 'Jan', '02' => 'Fev', '03' => 'Mar', '04' => 'Abr',
'05' => 'Mai', '06' => 'Jun', '07' => 'Jul', '08' => 'Ago',
'09' => 'Set', '10' => 'Out', '11' => 'Nov', '12' => 'Dez',
];
[$ano, $mes] = explode('-', $anoMes) + [1 => ''];
return ($meses[$mes] ?? $mes) . '/' . substr($ano, -2);
}
/**
* legend-transformer do mixed (galeria avançada) — traduz categoria_id
* (a coluna DIRETA usada no group-by, pra fugir do bug) pro nome legível,
* sem precisar de Transformers::x (registro fora do escopo desta página).
*/
public function labelCategoria($categoriaId): string
{
$this->categoriaNomes ??= TutorDemoCategoria::query()->pluck('nome', 'id')->all();
return $this->categoriaNomes[(int) $categoriaId] ?? "Categoria #{$categoriaId}";
}
/**
* mad-chart (builder PHP) — já funcionava antes do fix desta onda; reusa
* receitaPorCategoria() (mesma agregação, sem duplicar lógica).
*/
public function chartCategoriasBuilder(): MadChart
{
return MadChart::bar('categorias_builder')
->data($this->receitaPorCategoria())
->title('Receita por categoria', 'construído com Mad\\Chart\\MadChart (builder PHP)')
->currency()
->height(280)
->legend(true, 'bottom');
}
}
resources/views/tutor/dashboards/charts.blade.php
<x-tutor-page page="dashboards_charts">
<mad-callout type="warning" title="Bug de framework: GROUP BY quebra em alias de JOIN" icon="triangle-alert">
O motor por trás de <code>mad-db-chart</code> (e dos aliases <code>mad-bar-chart</code>,
<code>mad-pie-chart</code>, <code>mad-radar-chart</code> etc. usados abaixo) monta o
<code>GROUP BY</code> sempre prefixado com a tabela BASE da query
(<code>Mad\Chart\Engine\BaseChart::loadData()</code>) — quebra com
<code>SQLSTATE... no such column</code> quando o campo de agrupamento é um alias que só
existe no <code>SELECT</code>: vindo de JOIN (ex.: <code>tc.nome as categoria_nome</code>)
ou computado (ex.: <code>substr(dt_pedido,1,7) as mes_ref</code>). Bug real do framework,
documentado em <code>docs/tutor-framework-findings.md</code> (item 3) — não corrigido aqui
porque <code>packages/mad-framework</code> é symlink pro repo oficial.
<br><br>
Workaround usado nas demos desta página: a agregação roda em PHP/Collection no controller
(<code>TutorDashboardsCharts</code>) e o mapa <code>label => valor</code> pronto vai pra
prop <code>:data</code> — que ignora esse pipeline quebrado. A única exceção é o gráfico
"misto" da galeria avançada: a série de linha exige uma 2ª agregação de verdade no banco,
então ali o group-by usa a coluna DIRETA <code>categoria_id</code> (não o alias) e o rótulo
legível vem de um <code>legend-transformer</code>, não do group-by.
</mad-callout>
<x-tutor-demo demo="tutor.examples.dashboards-charts.bar-e-linha"
title="Barra e linha" desc="mad-bar-chart e mad-line-chart — aliases que compilam para mad-db-chart com o type já fixado. Dados via :data (agregados em PHP) por causa do bug acima."
php="TutorDashboardsCharts::receitaPorCategoria" />
<x-tutor-demo demo="tutor.examples.dashboards-charts.pizza-e-rosca"
title="Pizza e rosca" desc="mad-pie-chart (percentual) e mad-donut-chart (legenda) sobre a mesma fonte de dados — também via :data pelo mesmo motivo." />
<x-tutor-demo demo="tutor.examples.dashboards-charts.db-chart-dados-manuais"
title="mad-db-chart com dados manuais" desc="O componente genérico por trás de todos os aliases — aqui em modo :data, sem model nenhum." />
<x-tutor-demo demo="tutor.examples.dashboards-charts.chart-builder-php"
title="mad-chart (builder PHP)" desc="Para lógica pesada demais pro mad-db-chart declarativo — builder fluente Mad\Chart\MadChart."
php="TutorDashboardsCharts::chartCategoriasBuilder" />
<x-tutor-demo demo="tutor.examples.dashboards-charts.galeria-avancados"
title="Galeria: radar, misto, funil e treemap" desc="Radar/funil/treemap via :data; o misto é a única demo com group-by SQL de verdade (coluna categoria_id + legend-transformer), porque a série de linha precisa agregar no banco." />
</x-tutor-page>