MAD Lab
/
Tutor > Dashboards e widgets > Gráficos

Gráficos

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 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>