Docs›Componentes (Admin)›mad-kanban
Componentes (Admin)

mad-kanban

Quadro Kanban drag-and-drop.

Quadro Kanban com drag-drop, scroll infinito, filtros declarativos, ações por card/stage e toolbar customizável. Toda config visual mora no Blade via tags declarativas (<mad-kanban> + filhos). O controller PHP só guarda model/database/stageModel/stageField e ações.

Princípio: controller é casca — quanto mais atributos no Blade, menos PHP.

Quick start

Controller mínimo

<?php
use Mad\Calendar\MadKanban;

class PedidoVendaKanbanView extends MadKanban
{
    protected static string $wrapper = self::INTERNAL;

    protected string $model      = 'PedidoVenda';
    protected string $database   = 'minierp';
    protected string $stageModel = 'EstadoPedidoVenda';
    protected string $stageField = 'estado_pedido_venda_id';
    protected string $valueField = 'valor_total';

    protected function view(): string|array
    {
        return 'pedido.pedido-venda-kanban-view';
    }
}

Quais stages aparecem no board? O MadKanban não tem mais um hook de query pros stages (tipo stageCriteria()) — _loadStages() roda $stageModel::query()->orderBy($stageOrderField, $stageOrderDirection) puro. Pra restringir (ex: só estágios com kanban = 'T'), use um global scope Eloquent no model do stage:

class EstadoPedidoVenda extends Model
{
    protected static function booted(): void
    {
        static::addGlobalScope('kanban', fn ($q) => $q->where('kanban', '=', 'T'));
    }
}

Ordenação dos stages é via props (stageOrderField/stageOrderDirection, ver tabela abaixo) — não precisa de override pra isso.

View declarativa

<mad-page-container>
    <mad-page-header title="Kanban de pedidos" icon="layout-grid">
        <actions>
            <mad-btn navigate="PedidoVendaForm" variant="primary" icon="plus">Novo</mad-btn>
        </actions>
    </mad-page-header>

    <mad-page-content>
        <mad-kanban-filters style="toolbar">
            <mad-input-field name="busca" label="Busca" placeholder="ID ou obs..." />
            <mad-dbcombo-field name="vendedor_id" label="Vendedor" model="Pessoa" display="nome" />
        </mad-kanban-filters>

        <mad-kanban top-scroll>
            <mad-kanban-card title="obs">
                <mad-kanban-meta icon="user" path="cliente.nome" />
                <mad-kanban-footer type="money" path="valor_total" prefix="R$" />
            </mad-kanban-card>
        </mad-kanban>
    </mad-page-content>
</mad-page-container>

<mad-kanban> — atributos do board

Todos opcionais — quando ausentes, fallback nas props PHP do controller.

Atributo Tipo Default Descrição
model string $this->model Classe do model Eloquent dos cards
database string MAIN_DATABASE Conexão do banco
stage-model string $this->stageModel Classe do model Eloquent das colunas/stages
stage-field string $this->stageField FK no card model que referencia o stage
stage-title-field string 'nome' Campo do stage usado como título da coluna
stage-color-field string 'cor' Campo do stage usado como cor (bolinha no header)
stage-order-field string 'ordem' Campo do stage usado pra ordenar as colunas
stage-order-direction string 'asc' asc ou desc
stages-reorderable bool false Reservado p/ drag das próprias colunas (futuro)
card-order-field string 'ordem' Coluna p/ ordenação de cards na stage. Vazio/coluna = PK desliga reorder
value-field string '' Campo numérico p/ totalizar por coluna
value-format string 'currency:R$ :2' Formato do total via MadChartFormatter (currency:, integer, numeric:, percent:, abbreviate:)
cards-per-load int 20 Page size do scroll infinito
cards-draggable bool true Permite drag-drop de cards
no-cards-draggable bool false Atalho explícito p/ desativar drag
top-scroll bool false Renderiza scrollbar horizontal duplicada no topo
click-target string '' Classe::método aberto ao clicar no card. Ex: PedidoForm::onShow({id}). Atalho declarativo pro hook cardClickTarget() (tem prioridade sobre ele — ver "Hooks PHP")
card-view string 'components.kanban-card-default' Override total do template Blade do card
<mad-kanban
    model="PedidoVenda"
    stage-model="EstadoPedidoVenda"
    stage-field="estado_pedido_venda_id"
    value-field="valor_total"
    value-format="currency:R$ :2"
    cards-per-load="30"
    top-scroll
    click-target="PedidoVendaForm::onEdit({id})">
    ...
</mad-kanban>

<mad-kanban-toolbar> — barra superior

Body Blade arbitrário renderizado acima do board.

<mad-kanban-toolbar>
    <mad-btn navigate="PedidoVendaForm" variant="primary" size="sm" icon="plus">Novo</mad-btn>
    <mad-btn mad:click="onReload" variant="ghost" size="sm" icon="refresh-cw">Recarregar</mad-btn>
</mad-kanban-toolbar>

Variáveis no escopo: $kanban (instância do controller).

Pra exibir um indicador agregado com os MESMOS filtros do board (período + auto-filters + onSearch(), sem o filtro de stage), use $kanban->getBaseQueryMerged(): Builder:

<mad-kanban-toolbar>
    <mad-db-metric-card :query="$kanban->getBaseQueryMerged()" field="valor_total"
        total="sum" label="Total filtrado" icon="circle-dollar-sign" format="money:R$" />
</mad-kanban-toolbar>

<mad-kanban-stage-action> — ação no header de cada coluna

<mad-kanban-stage-action
    target="PedidoVendaForm::onCreateForStage({stageId})"
    icon="plus"
    label="Novo nesta etapa" />
Atributo Tipo Default Descrição
method string — Método PHP do controller (recebe string $stageId)
target string — Navega para outra classe (placeholder {stageId} substituído)
icon string — Ícone Lucide
label string — Tooltip / aria-label
variant string — primary, success, danger, warning, ghost
confirm string — Mensagem de confirmação antes de disparar
drawer bool false Abre target num drawer em vez de navegação cheia
display-condition string — Classe::metodo — recebe (?Model $stage, array $row): bool (ou só (array $row): bool, auto-detectado por reflection)
when-field / when-value / when-in / when-nin string — Visibilidade condicional por campo do stage, mesma semântica do <mad-act> da grid
empty-state bool false Quando true, a action só renderiza como placeholder pontilhado dentro da coluna vazia (em vez de no header)

<mad-kanban-card> — configuração do card

Renderiza cada card. title é o path do título (dotted notation, ou template {campo}/{relacao->campo} para HTML composto). Recomendado, mas não obrigatório — se ausente ou vazio (após strip_tags), cai no fallback Item #<id>.

Atributo (na tag <mad-kanban-card>) Descrição
title Path do título — dotted (cliente.nome) ou template {obs} <br><small>{cliente->nome}</small>
actions-mode menu (default, dropdown ...) | dropdown (botão "Ações" no rodapé) | inline (todas as actions sem inline viram inline)
no-id-badge Remove o badge #ID automático
no-state-badge Remove o badge de estado (cor do stage) automático
<mad-kanban-card title="obs">
    <mad-kanban-badge type="id" />
    <mad-kanban-badge type="text" path="prioridade" color-path="prioridade_cor" />

    <mad-kanban-meta icon="circle-user-round" path="cliente.nome" />
    <mad-kanban-meta icon="user" path="vendedor.nome" muted />

    <mad-kanban-footer type="money" path="valor_total" prefix="R$" />
    <mad-kanban-footer type="date" path="dt_pedido" icon="calendar" format="d/m/Y" />

    <mad-kanban-action inline
        target="PedidoVendaForm::onEdit({id})"
        icon="pencil" label="Editar" />

    <mad-kanban-action
        method="onAprovar" icon="check-circle" label="Aprovar"
        variant="success"
        confirm="Aprovar este pedido?"
        display-condition="PedidoVendaKanbanView::podeAprovar" />
</mad-kanban-card>

<mad-kanban-badge> — etiquetas no topo

Se o card não declarar nenhum <mad-kanban-badge>, dois badges são gerados automaticamente: #ID (mutado) + estado atual do card (cor via stage-color-field, título via stage-title-field). Desative com no-id-badge / no-state-badge na tag <mad-kanban-card>.

Atributo Descrição
type="id" Renderiza #1234 (id do card)
type="text" (default) Badge livre — texto vem de path="..." (navega o record) ou value="literal"
color Cor fixa do badge (CSS), ex: color="#16a34a"
color-path Cor vinda do record, ex: color-path="estado.cor"
{{-- Badge livre lendo do banco --}}
<mad-kanban-badge type="text" path="prioridade" color-path="prioridade_cor" />

{{-- Badge com cor fixa --}}
<mad-kanban-badge type="text" value="Urgente" color="#dc2626" />

Não existe type="state" — o badge de estado é o auto-gerado por default (desativável via no-state-badge), não algo que se declara via type.

<mad-kanban-meta> — linhas de metadata

<mad-kanban-meta icon="user" path="cliente.nome" />
<mad-kanban-meta icon="phone" path="cliente.fone" muted />
<mad-kanban-meta icon="dollar-sign" path="valor_frete" type="money" prefix="R$" inline />
Attr Descrição
icon Lucide icon name
path Dot-notation: cliente.nome
sub-path Segunda linha menor abaixo do valor principal
muted Cor secundária
inline Renderiza como chip horizontal (em vez de linha empilhada)
position left | right | center (afeta alinhamento do chip inline)
type text (default) | money/currency | date | datetime | integer | numeric | percent | abbreviate
prefix, decimals, format Parâmetros do formatador conforme type (format é a máscara de data, ex: d/m/Y H:i)

Pra agrupar várias metas num layout custom (linha/coluna, gap, alinhamento), envolva com <mad-kanban-meta-group>:

Attr Valores Default Descrição
direction row | column column Empilhado ou em linha
justify start | end | between | center | around start justify-content do grupo
align start | end | center start align-items do grupo
gap int (px) 6 Espaçamento entre itens
<mad-kanban-meta-group direction="row" justify="between" gap="12">
    <mad-kanban-meta icon="calendar" path="dt_prazo" type="date" format="d/m" />
    <mad-kanban-meta icon="user" path="responsavel.nome" />
</mad-kanban-meta-group>
Attr Descrição
type money | date | text (default)
path Dot-notation do valor
icon Lucide icon (usado em date/text)
prefix Prefixo monetário quando type="money" (default R$)
decimals Casas decimais quando type="money" (default 2)
format Máscara de data quando type="date" (default d/m/Y)
class Classe CSS extra no wrapper do item
<mad-kanban-footer type="money" path="valor_total" prefix="R$" />
<mad-kanban-footer type="date" path="dt_pedido" format="d/m/Y" icon="calendar" />
<mad-kanban-footer type="text" path="status" />

<mad-kanban-action> — ações no card

Renderiza menu de ações no card. inline força botão visível direto no rodapé; sem inline, a action vai pro menu (controlado por actions-mode na tag <mad-kanban-card> pai).

Attr Descrição
method Método PHP do controller (recebe int $id)
target Navega para outra classe (placeholder {id})
icon Lucide
label Texto/tooltip
variant primary, success, danger, warning, ghost
confirm Mensagem de confirmação
drawer Abre target num drawer em vez de navegação cheia
display-condition Classe::metodo — recebe (?Model $item, array $row): bool (ou (array $row): bool, auto-detectado)
when-field / when-value / when-in / when-nin Visibilidade condicional por campo do registro
inline Botão direto no rodapé (não no dropdown)

Filtros declarativos — <mad-kanban-filters>

Mesma API de <mad-grid-filters> / <mad-dash-filters>. Ver filtros declarativos.

<mad-kanban-filters style="toolbar">
    <mad-input-field name="busca" label="Busca" />
    <mad-dbcombo-field name="vendedor_id" label="Vendedor" model="Pessoa" display="nome" />
    <mad-period-monthyear />
</mad-kanban-filters>

Controller — onSearch() no MadKanban é builder-native: recebe o Builder Eloquent direto (não o estilo 0-arg + closure do MadDataGrid):

use Illuminate\Database\Eloquent\Builder;

class NegociacaoKanbanView extends MadKanban
{
    protected bool   $rememberFilters = true;
    protected string $periodType      = 'date-range';
    protected string $dateField       = 'dt_inicio';
    protected array  $skipAutoFilter  = ['busca', 'vendedor_id'];

    public string $busca       = '';
    public string $vendedor_id = '';

    public function onSearch(Builder $q): void
    {
        $q->whereNotIn('etapa_negociacao_id', [
            EtapaNegociacao::CANCELADA,
            EtapaNegociacao::FINALIZADA,
        ]);

        if ($this->busca !== '') {
            $v = trim($this->busca);
            ctype_digit($v)
                ? $q->where('id', '=', (int) $v)
                : $q->where('obs', 'like', "%{$v}%");
        }

        if ($this->vendedor_id !== '') {
            $q->where('usuario_id', '=', $this->vendedor_id);
        }
    }
}

onSearch() é chamado por stage, sempre por cima da query base do card (model/stageField) — não precisa repetir o filtro de stage, o board já aplica.

Hooks PHP

Hook Quando
onSearch(Builder $q): void Filtros declarativos — aplica $q->where(...) direto no builder do stage (ver seção acima)
afterCardMove(int $cardId, string $oldStageId, string $newStageId): void Pós drag-drop entre colunas
renderCard(object $item): string Override total do template do card (bypassa tags <mad-kanban-card>)
cardClickTarget(): ?string Classe-alvo do clique no card (null desabilita). Se <mad-kanban click-target="..."> estiver setado no Blade, ele tem prioridade sobre este override PHP
query(): array Hook de extensão p/ injetar registros prontos em vez do auto-query builder-native. Retorne [] (default) pra manter o comportamento padrão

Overrides de renderCard()/cardClickTarget() DEVEM permanecer public — PHP não deixa reduzir a visibilidade de um método ao sobrescrever.

query() não é stage-aware: é chamado uma vez por coluna dentro do loop de carregamento, sem receber o $stageId — se você sobrescrever, o MESMO array retornado é usado para TODAS as colunas. Pra filtrar por stage, use onSearch(Builder $q), que já roda por-coluna sobre o builder correto.

Não existe mais stageCriteria() — veja a nota em "Quick start" sobre filtrar stages via global scope no model.

Ações no card via método PHP

use Illuminate\Support\Facades\DB;

public function onAprovar(int $id): MadResponse
{
    DB::connection('minierp')->transaction(function () use ($id) {
        $p = PedidoVenda::findOrFail($id);
        $p->estado_pedido_venda_id = EstadoPedidoVenda::APROVADO;
        $p->save();
    });

    return (new MadResponse())
        ->toast("Aprovado #{$id}", 'success')
        ->manageCard($id, static::class);
}

public static function podeAprovar(?PedidoVenda $item, array $row): bool
{
    return $item && $item->estado_pedido_venda_id == EstadoPedidoVenda::AGUARDANDO;
}

manageCard($id, $kanbanClass) atualiza/insere card sem reload — análogo a manageRow do MadDataGrid.

Reorder dentro da coluna

protected string $cardOrderField = 'ordem';

Já é o default. Habilita drag-drop dentro da mesma coluna — o framework persiste a nova ordem automaticamente após o drop (onCardMove(), nativo, não precisa de código). Para desligar o reorder dentro da coluna, aponte cardOrderField pra string vazia ou pra mesma coluna da PK.

Override total do card via Blade view

Se as tags <mad-kanban-card> + filhos não bastarem:

// MadKanban::renderCard() é public — overrides DEVEM manter public
// (PHP nao permite reduzir visibilidade ao sobrescrever um metodo).
public function renderCard(object $item): string
{
    return MadBlade::render('crm.negociacao-card', ['item' => $item]);
}

Ou no Blade:

<mad-kanban card-view="crm.negociacao-card-custom">
    {{-- tags ignoradas, card-view tem prioridade --}}
</mad-kanban>

Gotchas

  • stageModel e stageField obrigatórios — sem eles, kanban não monta as colunas (mount() só chama loadData() quando ambos estão preenchidos)
  • title no <mad-kanban-card> é só recomendado, não obrigatório — se vazio, o card renderiza normalmente com o fallback Item #<id> (nenhum card é ocultado por falta de título)
  • query() não é stage-aware — ver caveat na seção "Hooks PHP"; use onSearch() pra filtrar por coluna
  • Não existe mais hook de query pros stages (stageCriteria() foi removido) — filtre via global scope no model do stage; ordenação é via stage-order-field/stage-order-direction
  • onSearch() é builder-native — assinatura onSearch(Builder $q): void, aplica $q->where(...) direto. Não confundir com o estilo $this->searchQuery (closure) do MadDataGrid
  • onCardMove() só move card VISÍVEL no escopo do board — o drop resolve o registro por uma query escopada (unit/período/auto-filters/onSearch()), não por find() cru. Card fora do escopo do usuário levanta "registro não encontrado" em vez de ser movido (proteção contra IDOR). Consequência prática: filtro ativo que esconde o card impede movê-lo
  • Scroll infinito: hasMore vem do servidor — cada append_cards devolve hasMore = (cards retornados === cards-per-load). A op é emitida SEMPRE, inclusive na página vazia; sem ela a coluna ficaria com loadingMore travado
  • $rememberFilters persiste filtros mas NÃO stage selecionado — kanban sempre mostra todos os stages resolvidos por stageModel