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
MadKanbannão tem mais um hook de query pros stages (tipostageCriteria()) —_loadStages()roda$stageModel::query()->orderBy($stageOrderField, $stageOrderDirection)puro. Pra restringir (ex: só estágios comkanban = '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 viano-state-badge), não algo que se declara viatype.
<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>
<mad-kanban-footer> — rodapé
| 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 permanecerpublic— 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, useonSearch(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
stageModelestageFieldobrigatórios — sem eles, kanban não monta as colunas (mount()só chamaloadData()quando ambos estão preenchidos)titleno<mad-kanban-card>é só recomendado, não obrigatório — se vazio, o card renderiza normalmente com o fallbackItem #<id>(nenhum card é ocultado por falta de título)query()não é stage-aware — ver caveat na seção "Hooks PHP"; useonSearch()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 é viastage-order-field/stage-order-direction onSearch()é builder-native — assinaturaonSearch(Builder $q): void, aplica$q->where(...)direto. Não confundir com o estilo$this->searchQuery(closure) doMadDataGridonCardMove()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 porfind()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:
hasMorevem do servidor — cadaappend_cardsdevolvehasMore = (cards retornados === cards-per-load). A op é emitida SEMPRE, inclusive na página vazia; sem ela a coluna ficaria comloadingMoretravado $rememberFilterspersiste filtros mas NÃO stage selecionado — kanban sempre mostra todos os stages resolvidos porstageModel