@benenutri/mitra-kanban
v1.9.0
Published
Motor de quadros (kanban) configurável para projetos da plataforma Mitra: tabelas, server functions e componentes React instaláveis por versão.
Downloads
1,249
Readme
@benenutri/mitra-kanban
Motor de quadros (kanban) configurável para projetos da plataforma Mitra: tabelas, Server Functions e componentes React que um projeto novo instala e atualiza por versão. O gestor da área monta fases, campos, condições e automações sozinho — a estrutura do banco não muda.
Só para sistemas novos. Legados (SGC, ITSM, CRM Ativa, Comercial 360) não recebem a lib.
O que vem no pacote
| Parte | Caminho | O que é |
|---|---|---|
| Backend | backend/ | schema.mjs (13 tabelas KB_*, UPGRADES), escritas.mjs (30 SQL internas), funcoes.mjs (27 SF públicas), install.mjs (instalar()), index.mjs |
| Motor | motor/motor.mjs | regra pura: condições, visibilidade, validação, normalização — roda na SF, na tela e nos testes; e o modelo de e-mail (montarEmail, spec 016) |
| Mapa | frontend/server-functions.ts | bloco canônico SERVER_FUNCTION_NAMES / SERVER_FUNCTIONS para copiar no consumidor |
| Componentes | dist/ (de src/) | KanbanProvider, KanbanBoard, CardDrawer, BoardAdmin |
| Importação | backend/pipefy/ | extrair.mjs (Pipefy → pacote), mapear.mjs (conversão pura), carregar.mjs (pacote → banco; --conferir, --simulador) — ver Importar quadros do Pipefy |
Spec, plan e tasks: docs/specs/001-motor-kanban/ (motor), 002-aparencia-quadro/ (cores, cara do card,
gravação ao sair do campo), 003-card-aberto/ (card em três colunas, abas, passagens, vários responsáveis),
004-construtor-de-fases/ (administração numa tela só com prévia, observação por campo, capa do card por fase,
os três tempos do cartão), 005-tarefas-do-card/, 006-chat-do-card/ e 007-anexos-do-card/ (as abas Tarefas,
Chat e Anexos do card aberto), 010-etiquetas/ (etiquetas do quadro como selo, filtro por etiqueta, cadastro de
opções em linhas, "+" para etiqueta e responsável no card), 011-importar-pipefy/ (quadros do Pipefy trazidos
por TI em duas etapas — extrair para um pacote, conferir, carregar — com histórico e origem por card) e
013-pontos-de-extensao/ (abas, ações e regras do sistema consumidor no card aberto — ganchos de servidor
para mover e excluir, nota no histórico, card por processo sem sessão, {{anexos}} no e-mail) e 016-email/ (o
modelo de e-mail Benenutri: automação, menção, novo responsável e tarefa atribuída saem no mesmo modelo, e o
sistema consumidor o usa nos e-mails próprios).
Instalar num projeto Mitra novo
- Nos dois lados do projeto:
npm i @benenutri/mitra-kanban(embackend/e emfrontend/). - Copie os dois blocos de
node_modules/@benenutri/mitra-kanban/frontend/server-functions.tspara ofrontend/src/lib/server-functions.tsdo projeto (junto das funções próprias dele). - Crie
backend/add-001-kanban.mjs:
import 'dotenv/config';
import { pathToFileURL, fileURLToPath } from 'node:url';
import { instalar, montarDefinitions, lerIdsDoMapa } from '@benenutri/mitra-kanban/backend';
// Ids das escritas vêm do mapa do frontend: é por ele que o simulador resolve
// chamadas por id, antes e depois do sync-server-function-map.
const MAPA = fileURLToPath(new URL('../frontend/src/lib/server-functions.ts', import.meta.url));
export const definitions = montarDefinitions(lerIdsDoMapa(MAPA));
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const sdk = await import('mitra-sdk');
sdk.configureSdkMitra({
baseURL: process.env.MITRA_BASE_URL, token: process.env.MITRA_TOKEN, integrationURL: process.env.MITRA_BASE_URL_INTEGRATIONS,
});
await instalar({ sdk, projectId: Number(process.env.MITRA_PROJECT_ID), mapaFrontend: MAPA });
}- No simulador do projeto (
backend/simulator/config.mjs):
export const PUBLISH_ORDER = ['add-001-kanban.mjs', /* ...os add-* do projeto */];
export const DDL_SOURCES = [
join(HERE, '..', 'setup-backend.mjs'),
fileURLToPath(import.meta.resolve('@benenutri/mitra-kanban/backend/schema')),
];frontend/src/index.css, logo após os@import:@source "../node_modules/@benenutri/mitra-kanban";— as telas da lib usam as classes de token do design system da Benenutri (bg-card,text-ink-secondary,bg-paper-sunken…), e o Tailwind precisa escanear o pacote para gerá-las. O projeto precisa ter o design system instalado (/benenutri:design), que é quem define os tokens.- Rotas (ver seção Componentes).
- Perfil de acesso do projeto: libere as 27 SF
kb*e as 30kbEsc*para o perfil business. - Anexos (spec 007): passe
enviarArquivoaocriarKanbanApi(ver Componentes). Sem ele, a aba Anexos aceita só link.
Publicar: commit → no sandbox do projeto: cd backend && node add-001-kanban.mjs && node sync-server-function-map.mjs,
depois cd frontend && npm run build e share. A plataforma gera as migrations.
Atualizar a versão num projeto
cd backend && npm i @benenutri/[email protected]
cd frontend && npm i @benenutri/[email protected]Acrescente ao server-functions.ts os nomes novos (o instalar() avisa quais faltam).
No sandbox: node add-001-kanban.mjs && node sync-server-function-map.mjs, build, share.
instalar() é idempotente e só aditivo: CREATE IF NOT EXISTS, UPGRADES guardados por coluna,
SFs upsertadas por nome, versão em KB_META.
A spec 004 não acrescenta server function nenhuma: kbFaseSalvar ganhou o parâmetro capaJson e a
observação viaja no configJson que kbCampoSalvar já aceitava. Quem atualiza não mexe no
server-functions.ts — só roda o add-001-kanban.mjs no sandbox para ganhar a coluna KB_FASE.CAPA_JSON.
A spec 010 (etiquetas) também não acrescenta SF, tabela, coluna nem token de cor — kbCardsListar ganhou
o parâmetro etiqueta, e o selo sai de --data-N, --card e --foreground, que todo projeto com o design
system já tem. Quem atualiza não cola nada: roda o add-001-kanban.mjs e pronto.
Nada muda de aparência para quem não ativar etiquetas: opção colorida de campo comum continua em ponto + rótulo, e só a lista de etiquetas vira selo.
A 1.1.0 (spec 011) acrescenta uma coluna, KB_CARD.ORIGEM_CHAVE (com índice), e nenhuma SF: quem
atualiza só roda o add-001-kanban.mjs no sandbox. A mesma coluna serve à spec 008 (fonte:<id>:<chave>).
A 1.2.0 (specs 005, 006 e 007 — abas Tarefas, Chat e Anexos) acrescenta 4 tabelas (KB_TAREFA,
KB_MENSAGEM, KB_MENSAGEM_LEITURA, KB_ANEXO, pelo CREATE IF NOT EXISTS, sem coluna nova em tabela
existente), 10 SF públicas e 11 escritas. Quem atualiza:
- Copia os dois blocos novos do
frontend/server-functions.tsda lib (ids locais 8017–8026 e 9020–9030) para oserver-functions.tsdo projeto — oinstalar()avisa o que falta. - Libera as 21 SF novas no perfil business.
- Passa
enviarArquivoaocriarKanbanApipara a aba Anexos enviar arquivo (sem ele, só link). - No simulador do projeto, para enviar arquivo localmente: copia de
backend/simulator/server.mjsda lib a rotaPOST /interactions/uploadFilePublice aGET /public/:nome. Sem elas o envio falha só no simulador. - No sandbox:
node add-001-kanban.mjs && node sync-server-function-map.mjs, build, share.
A 1.3.0 (spec 013 — pontos de extensão do sistema consumidor) não acrescenta tabela nem coluna: a
nota é uma linha de KB_LOG e as regras ligadas são linhas de KB_META. Acrescenta uma SF pública,
kbNotaRegistrar. Quem atualiza:
- Copia a linha
kbNotaRegistrarnos dois blocos (SERVER_FUNCTION_NAMESeSERVER_FUNCTIONS) dofrontend/server-functions.tsda lib para oserver-functions.tsdo projeto — o id fica o local até osync-server-function-map.mjstrazer o real. - Libera
kbNotaRegistrarno perfil business. - No sandbox:
node add-001-kanban.mjs && node sync-server-function-map.mjs, build, share. - Quer regras ligadas (mover/excluir por SQL do consumidor)? Passa
ganchosaoinstalar()noadd-001-kanban.mjs— ver Regras ligadas (ganchos de servidor). Semganchos, nada muda.
Os pontos de tela (abasExtras, acoesExtras, podeMover, antesDeMover, podeExcluir, novoCard,
atualizarACadaMs) são props de KanbanBoard/CardDrawer — não pedem nada no sandbox, só o npm i e o
import (ver Pontos de extensão do sistema consumidor).
A 1.4.0 (spec 014 — movimento por processo) não acrescenta tabela, coluna nem SF: kbCardMover
ganhou movidoPor e kbCardAtualizar ganhou atualizadoPor, os dois com origem, e só valem sem sessão.
Quem atualiza não mexe no server-functions.ts nem no perfil — npm i nos dois lados e o
node add-001-kanban.mjs de sempre no sandbox. Quem não chama as SFs sem sessão não vê diferença nenhuma.
A 1.6.0 (spec 016 — modelo de e-mail) não acrescenta tabela, coluna nem SF: os e-mails que já saíam
(automação e menção no chat) passam a sair no modelo Benenutri, e dois avisos novos entram (novo responsável do
card, tarefa atribuída). Quem atualiza roda npm i nos dois lados e o add-001-kanban.mjs de sempre; para o
botão "Abrir card" e o nome do sistema no assunto, passa email: { sistema, urlDoCard } ao instalar() — ver
E-mail (spec 016). Sem isso, os e-mails saem como [Benenutri] … e sem botão.
A 1.6.1 (correção de tela, spec 002 C-009) não acrescenta nada no servidor: só npm i no frontend. O quadro
passa a preencher o main pelo layout (KanbanBoard em h-full, Panel e Board em flex-1) em vez de descontar
21rem da janela — era isso que deixava o main rolando junto com as colunas (duas barras) — e os cabeçalhos das
fases dividem uma linha de grade (subgrid), então a descrição de uma fase não desalinha mais os cards das outras.
A pilha de cada coluna ficou relative: os rótulos sr-only dos cards (absolutos) passam a ser contidos por ela,
e não pela janela — era isso que criava uma barra de rolagem da página inteira que descia sem mostrar nada.
Exige o layout do §10 (main com altura definida e overflow-y-auto); fora dele o quadro fica nos 30rem do padrão.
A 1.7.0 (spec 017 — anexo pelo chat) acrescenta uma coluna, KB_ANEXO.MENSAGEM_ID (com índice, pelo
UPGRADES), e nenhuma SF: kbMensagemEnviar ganhou anexoNome, anexoUrl e anexoTamanho (vazios = como
antes) e kbMensagensListar passa a trazer o anexo de cada mensagem. Quem atualiza roda npm i nos dois lados e o
add-001-kanban.mjs no sandbox — sem ele a leitura do chat falha, porque o join usa a coluna nova. O clipe só
aparece no chat de quem passa enviarArquivo ao criarKanbanApi (o mesmo da aba Anexos); sem ele nada muda.
A 1.8.0 (spec 018 — cabeçalho do quadro) não acrescenta nada no servidor: só npm i no frontend (o backend
pode acompanhar a versão por consistência; o add-001-kanban.mjs não traz nada novo). Na tela do quadro o
cabeçalho fica numa linha (nome do quadro, contagem e ações, sem o rótulo "Quadro", sem grudar no topo), a
descrição do quadro vai para um balão atrás do botão de informação ao lado da contagem, e o piso de altura do
quadro cai de 30rem para 22rem. Num notebook 1366×768 a pilha visível passa de 270px para 325px e a página deixa
de rolar por baixo do cabeçalho. Lista de quadros e administração não mudam. PageHeader do vocabulário ganhou
compacto (sem module nem description), e Popover, PopoverContent e PopoverTrigger passaram a ser
exportados pelo vocabulário.
A 1.9.0 (spec 019 — níveis de acesso) acrescenta uma coluna, KB_BOARD.ACESSO_JSON (pelo UPGRADES), e
nenhuma SF: quem atualiza roda npm i nos dois lados e o add-001-kanban.mjs no sandbox, sem mexer no
server-functions.ts nem no perfil. Os quadros que já existem não mudam: a coluna nasce vazia, que é lida
como "todos membros". Quadro criado pela 1.9.0 nasce com padrão restrito — ver Níveis de acesso (spec 019).
Quadro importado do Pipefy (carga direta, sem kbBoardSalvar) também nasce com a coluna vazia, ou seja, membro.
Importar quadros do Pipefy
Ferramenta de linha de comando para TI (spec 011): um quadro do Pipefy vira um quadro do motor com fases, campos, etiquetas, cards (valores, responsáveis, prazo, datas, conclusão), histórico de passagem por fase e um registro de origem por card, a conversa (comentários) e os anexos (arquivos enviados). Duas etapas com um pacote em disco no meio — o pacote é o seguro contra desligar o Pipefy e a fonte de tudo: carregar de novo acrescenta o que faltou, sem voltar ao Pipefy.
PIPEFY_TOKEN=...no.envdobackend/do projeto (token pessoal ou de serviço do Pipefy). Nunca vai para o git nem para o pacote.- Congele o quadro no Pipefy (somente leitura): o que for editado lá depois da extração não entra.
cd backend
node node_modules/@benenutri/mitra-kanban/backend/pipefy/extrair.mjs --listar # id, cards e nome de cada quadro
node node_modules/@benenutri/mitra-kanban/backend/pipefy/extrair.mjs --pipe 301234567 # pacote em pipefy/301234567/
node node_modules/@benenutri/mitra-kanban/backend/pipefy/carregar.mjs --pacote pipefy/301234567 --conferir
node node_modules/@benenutri/mitra-kanban/backend/pipefy/carregar.mjs --pacote pipefy/301234567 --simulador
node node_modules/@benenutri/mitra-kanban/backend/pipefy/carregar.mjs --pacote pipefy/301234567 --usuario [email protected]- Extrair lê o quadro pela API do Pipefy (≤ 5 requisições/s; espera e repete quando o Pipefy avisa
excesso), grava os cards brutos em
cards.jsonl, baixa os anexos na mesma passagem (o link vale 15 min) e confere a quantidade extraída contra a que cada fase declara. Termina "parcial", dizendo por quê, quando faltou card ou arquivo;--so-arquivos --saida <dir>refaz só os anexos perdidos;--sem-arquivospula os anexos;--paginamuda o tamanho da página (50). - Conferir escreve
relatorio.mdno pacote sem gravar nada: fases e tipos finais, cada campo com a conversão de tipo, pessoas sem par noINT_USER, opções recriadas, históricos aproximados, valores que não couberam no tipo, e quantos comentários e arquivos entram na conversa e nos anexos. - Carregar grava por lotes com o token de desenvolvedor do
.env— sóINSERT/SELECT, nenhuma estrutura (o motor precisa estar na 1.1.0; senão a carga para antes de gravar). Idempotente: rodar de novo não duplica nem sobrescreve; card que ficou pela metade é completado.--usuarioé quem importa (vira administrador do quadro, junto dos admins do Pipefy com par por e-mail);--lotemuda o tamanho do lote (200). Os comentários entram na conversa do card com a data e o autor do Pipefy (pelo cadastro quando há par; pelo nome quando não há), e não contam como mensagens novas.--arquivossobe os arquivos para o PUBLIC do projeto, guarda a URL emarquivos.jsone, na mesma carga, cria os anexos dos cards; sem--arquivoso relatório diz quantos aguardam. Motor anterior à 1.2.0 carrega o resto e deixa conversa e anexos aguardando. --simuladorcarrega o mesmo pacote no SQLite local (simulator/data/mitra-sim.dbou--banco) para o gestor ver o quadro antes de tocar em produção.
O que muda de forma (tabela completa no plan.md da 011): data e hora vira data (a hora fica no registro de
origem); hora, CPF, CNPJ, identificador e fórmula viram texto com observação; conexão entre cards vira texto
"Título (#id)"; responsável de formulário com várias pessoas fica com a primeira; etiquetas do pipe viram o
campo etiquetas (spec 010) com a cor mais próxima da paleta; fase concluída do Pipefy é final — cancelado
quando o nome tem cancel/recus/reprov/perd/arquiv/desist, sucesso nos demais (ajuste na administração). Opção
que sumiu da lista do Pipefy mas ainda está em card é recriada. Histórico: o Pipefy só informa a primeira e a
última entrada em cada fase, sem quem moveu — entra uma passagem por fase visitada, sem autor; card que voltou
de fase fica "aproximado" e as datas brutas vão para o registro de origem, que também guarda o número e o link
do card no Pipefy (a busca do quadro acha o card por esse número). Automações e condicionais do Pipefy não saem
pela API: o relatório conta as condicionais e TI as refaz no construtor de fases.
Componentes
A lib fala com o Mitra pelo transporte do projeto (linhas/acao de mitra-api.ts): ela chama as
server functions pelo nome e o projeto resolve o id. Nenhum SDK, fetch ou id dentro da lib.
import { KanbanProvider, criarKanbanApi, QuadrosLista, KanbanBoard, BoardAdmin } from '@benenutri/mitra-kanban';
import { acao, linhas } from '@/lib/mitra-api';
const api = criarKanbanApi({
acao: (nome, input) => acao(nome as Parameters<typeof acao>[0], input),
linhas: (nome, input) => linhas(nome as Parameters<typeof linhas>[0], input),
// Opcional (spec 007): sem ele, a aba Anexos aceita só link.
enviarArquivo: async (arquivo) => {
const r = await uploadFilePublicMitra({ file: arquivo }); // de 'mitra-interactions-sdk'
if (!r?.result?.publicUrl) throw new Error(`O envio de ${arquivo.name} não devolveu o endereço.`);
return r.result.publicUrl;
},
});
<KanbanProvider api={api}>
{/* /quadros */}
<QuadrosLista onAbrir={(slug) => navigate(`/quadros/${slug}`)} onAdministrar={(slug) => navigate(`/quadros/${slug}/admin`)} />
{/* /quadros/:slug — o card aberto vive na URL (?card=ID) */}
<KanbanBoard slug={slug} cardId={cardId} onCardChange={(id) => setParams(id ? { card: String(id) } : {})}
onAdministrar={() => navigate(`/quadros/${slug}/admin`)} onVoltar={() => navigate('/quadros')} />
{/* /quadros/:slug/admin */}
<BoardAdmin slug={slug} onVoltar={() => navigate(`/quadros/${slug}`)} />
</KanbanProvider>| Componente | O que faz |
|---|---|
| QuadrosLista | lista com busca, "Novo quadro" (criador vira admin) e ações Abrir/Administrar |
| KanbanBoard | colunas por fase numa faixa horizontal (sempre na ordem do fluxo; rola quando não cabem), altura fixa com rolagem por coluna, barra na cor da fase e descrição, busca, filtro por responsável (qualquer um da lista), concluídos, "Novo card" com campos visíveis ao vivo, filtro por etiqueta, card aberto. O cartão é só leitura (emenda de 2026-09-11): sem arrastar e sem seletor de fase na capa — mover é no card aberto, em "Mover para". Cara do card: etiquetas (opções com cor), os campos fixos do quadro e depois os que aquela fase acrescenta, responsáveis, prazo relativo, mensagens novas na conversa para quem olha (spec 006) e os três tempos (criação, fase — com a fração do SLA — e última alteração) |
| CardDrawer | card aberto em três colunas (spec 003): à esquerda responsáveis (vários), prazo, formulário inicial como ficha ("clique para preencher") e histórico por passagem (campos da fase e eventos ao abrir); no meio as abas "Nesta fase" (chip, SLA, campos da fase, condições das próximas fases), "Chat" (spec 006), "Tarefas" (spec 005), "Anexos" (spec 007) e "Alterações" (prosa por dia com filtro); à direita "Mover para" em botões e Excluir. Título editável no lugar. Sem botão Salvar: grava ao sair do campo e ao fechar ("Salvo às HH:MM"); mover grava o formulário antes |
| BoardAdmin | abas Geral, Fases e Automações. "Fases" é o construtor da spec 004: fluxo à esquerda, campos da etapa no meio (setas para a ordem, "+ Adicionar campo" com os tipos, ✎ para editar) e prévia à direita — o cartão do quadro (com "Editar capa") e o card aberto, com dados de exemplo. Campo tem observação e "Editável depois da fase"; CondicaoEditor e AcoesEditor seguem nos diálogos; sem permissão os formulários desabilitam |
| CampoInput, CondicaoEditor, AcoesEditor | peças reutilizáveis para telas próprias do projeto |
| useKanban(), criarKanbanApi | acesso tipado às 27 server functions públicas; enviarArquivo repassado do transporte |
Exemplo completo: mitra-projects/p-59277/frontend (App, AdminLayout e as três páginas).
Desenvolvimento local da lib com um consumidor: npm link aqui e npm link @benenutri/mitra-kanban --no-save
nos dois lados do consumidor (backend/ — schema, server functions e seed — e frontend/), com
npm run build:watch aqui. Linkar só o frontend deixa o simulador do consumidor no schema do registry.
Mudou o schema? Regere o banco do simulador do consumidor (npm run sim:seed, com o simulador parado). O vite.config.ts do consumidor precisa de
resolve.dedupe: ['react', 'react-dom', 'react/jsx-runtime', 'radix-ui', 'cmdk', 'lucide-react'] e
server.fs.allow incluindo a pasta da lib (ver o do p-59277) — sem isso o React da lib é outra cópia.
Pontos de extensão do sistema consumidor (spec 013)
Um sistema com regra própria (o SGC 2 é o primeiro) acrescenta abas, ações e regras ao card aberto sem
copiar o card nem a gravação do motor. KanbanBoard e CardDrawer aceitam estas props, todas opcionais —
ausente, o comportamento é o de hoje:
abasExtras— abas do sistema depois das do motor (Nesta fase, Chat, Tarefas, Anexos, Alterações), na ordem informada, com rótulo, contagem opcional lida do card e o conteúdo desenhado pelo próprio sistema. Ausente, o card só mostra as abas do motor.conteudoDaFase(1.5.0, spec 015) — conteúdo do sistema dentro da aba "Nesta fase", abaixo dos campos da fase e acima das condições de saída. O sistema decide peloctx.card.faseIdo que cada fase mostra (itens na montagem, notas fiscais na expedição, B.O. quando falta produto): o card muda de assunto conforme anda, em vez de uma aba por assunto. Ausente, a aba só tem os campos do motor. Combina comabasExtras(um assunto que vale em toda fase pode continuar aba).acoesExtras— botões do sistema na coluna de "Mover para", acima do Excluir. Ausente, a coluna mostra só o que o motor já mostra.podeMover— síncrono, roda na renderização de cada destino: devolver um texto desabilita o botão com esse motivo (notitle);nulllibera. Soma-se às condições de fase do motor, não as substitui. Ausente, todo destino que a fase permite aparece livre.antesDeMover— assíncrono, roda no clique, antes de gravar:{ ok: false, erro }recusa e mostra o erro na faixa de aviso, sem gravar nada; pode consultar o servidor (alertas, prazo). Ausente, só a checagem do motor vale.podeExcluir—falseesconde o botão Excluir do motor. É espelho de tela: quem decide de verdade é a regra ligada no servidor (ver Regras ligadas abaixo). Ausente, o Excluir segue a regra de hoje (criador do card ou administrador do quadro).novoCard— troca o botão "Novo card" do motor (cabeçalho e estado vazio) pelo do sistema;falsetira o botão do quadro. Ausente, o botão do motor continua.atualizarACadaMs— o quadro recarrega a lista sozinho a cada N milissegundos, sem indicador de carregamento e pausado com a aba escondida. Ausente ou0, sem atualização periódica.
Toda aba e ação extra recebe o mesmo contexto (ExtensaoContexto): o card, a estrutura, o detalhe, os
usuários, se está travado, e mover(faseId), recarregar(), avisar(tone, texto), fechar().
ctx.mover(faseId) é o mesmo mover do card aberto: salva o formulário → passa por antesDeMover → move
pelo motor → mostra o aviso → recarrega o card e avisa o quadro atrás.
Como o SGC 2 liga:
<KanbanBoard
slug="compras"
novoCard={{ rotulo: 'Novo pedido', onClick: () => navigate('/pedidos/novo') }}
atualizarACadaMs={60000}
conteudoDaFase={(ctx) => <ConteudoDaFase {...ctx} />} // itens e alertas na Análise, notas na expedição, B.O. na fase B.O. (spec 015)
acoesExtras={(ctx) => <AcoesDoPedido {...ctx} />}
podeMover={(card, fase, estrutura) => motivoDeDestino(persona, card, fase, estrutura)} // mesma regra da SQL ligada, em TS puro
antesDeMover={(card, fase) => verificarSaida(card, fase)} // alertas, justificativas, prazo — consulta o servidor
podeExcluir={() => permissoes.excluirPedido}
/>A spec 013 também acrescenta, do lado do servidor:
kbNotaRegistrar(nova SF pública, JAVASCRIPT) — grava uma nota do sistema no histórico do card:cardId,texto(até 1.000 caracteres) eusuarioId(só vale sem sessão, para processo automático). Não toca a data de atualização (ATUALIZADO_EM) do card; recusa card excluído ou inexistente, ou texto vazio. Devolve{ ok, id }.kbCardCriarganhacriadoPoreorigem: 'sistema'— só valem sem sessão (ex.: a agenda do consumidor criando pedido de madrugada); com sessão, os dois são ignorados e o criador é sempre quem está logado. Premissa de segurança: "sem sessão" é a situação que a plataforma só produz para o token de desenvolvedor (cron e scripts do sandbox); um usuário logado nunca chega aqui sem:VAR_USER. O mesmo vale para ousuarioIddekbNotaRegistrare para omovidoPordekbCardMovere oatualizadoPordekbCardAtualizar(spec 014). Se o seu projeto tiver algum caminho em que a sessão do usuário não resolve umINT_USER, não libere essas quatro SFs no perfil business antes de fechar esse caminho.kbCardMoverganhamovidoPorekbCardAtualizarganhaatualizadoPor, os dois comorigem: 'sistema'(spec 014, 1.4.0) — pela mesma regra: só valem sem sessão (ex.: o cron que, com 100 % das notas no ERP, escreve o faturado no card e leva o pedido de Trânsito para Faturamento em nome do responsável); com sessão, quem move ou altera é quem está logado. A passagem fica com essa pessoa e com origemsistema, a linha de alteração do histórico fica com ela, e as automações de "card movido" e "campo alterado" rodam com ela. Obrigatórios da fase de origem, condição de entrada, conclusão e "só campo editável agora" valem igual; o que não vale é a regra ligada depodeMover— ela é sobre pessoas.- A ação
enviar_emaildas automações entende{{anexos}}no assunto ou no corpo: com anexo, o marcador some do texto e a lista vira o bloco Anexos do modelo de e-mail (um link por arquivo ou link não removido da aba, na ordem dela); sem anexo, "Nenhum anexo" no lugar do marcador. Só consulta os anexos quando o marcador aparece de fato — a palavra "anexos" solta na prosa não custa nada (a spec 016 mudou a forma, não a regra).
Regras ligadas (ganchos de servidor)
Regra do sistema consumidor que o motor consulta no servidor antes de mover ou excluir um card — não é só a tela que decide, para não deixar passar quem chama por fora dela. Sem gancho ligado, vale a regra de hoje: mover segue as condições da fase, excluir é de quem criou o card ou do administrador do quadro.
O consumidor publica uma SF SQL por regra, com este contrato:
| Entrada ({{param}}) | Saída (uma linha) |
|---|---|
| usuarioId (quem age — o motor passa a sessão; nunca :VAR_USER, porque a chamada é aninhada), cardId, boardId, faseId (destino do movimento; 0 no excluir) | PODE (1 ou 0) e MOTIVO (texto; vazio quando pode) |
Exemplo (SGC 2, sgcPodeMover):
SELECT
CASE WHEN P.PERSONA IN ('admin', 'analista', 'gestor') THEN 1
WHEN P.PERSONA = 'diretoria' AND C.FASE_ID = F.FASE_ID THEN 1
ELSE 0 END AS PODE,
CASE WHEN P.PERSONA = 'diretoria' AND C.FASE_ID <> F.FASE_ID
THEN 'Você só movimenta cards na fase Aprovação da Diretoria'
ELSE '' END AS MOTIVO
FROM KB_CARD C
LEFT JOIN SGC_USUARIO_PERSONA P ON P.USER_ID = {{usuarioId}}
...
WHERE C.ID = {{cardId}}Onde fica: KB_META, chaves gancho:podeMover e gancho:podeExcluir; o valor é o id da SF do
consumidor.
Como ligar, no add-001-kanban.mjs do consumidor:
await instalar({
sdk, projectId,
ganchos: { podeMover: 'sgcPodeMover', podeExcluir: 'sgcPodeExcluir' },
});instalar() resolve os nomes pelas server functions do projeto e upserta KB_META (idempotente). Sem
ganchos, nada é gravado e nada muda.
Quando o motor consulta: só em movimento e exclusão feitos por um usuário. Automação (mover_fase)
e criação ou movimento de card por processo sem sessão (origem: 'sistema') não passam pelo gancho — a regra
ligada é sobre pessoas. Quem precisa barrar o processo o barra no próprio processo.
Quando a SQL falha ou não devolve linha: o motor recusa com "Regra do sistema indisponível." — fecha, em vez de abrir. Foi o consumidor que ligou a regra; deixar passar seria pular a checagem em silêncio.
A tela precisa espelhar a mesma regra em podeMover/podeExcluir (ver Pontos de extensão do sistema
consumidor acima) — senão a pessoa vê um botão habilitado que o servidor recusa no clique. Há uma diferença
de momento entre as duas recusas: a de antesDeMover (tela) acontece antes de qualquer gravação, inclusive do
formulário; a do gancho de servidor chega depois que o card aberto já salvou o que estava no formulário
(o motor grava o formulário e só então pede o movimento). Nada de fase, movimento ou histórico é gravado
em nenhum dos dois casos — mas é mais um motivo para a tela não deixar chegar ao servidor o que ele vai recusar.
Ligar com a chave certa: instalar() só aceita podeMover e podeExcluir em ganchos; qualquer outra
chave (ou um nome de SF que não existe no projeto) interrompe a instalação antes de criar tabela ou
publicar função — uma regra "ligada" numa chave que o motor não lê seria uma regra desligada com mensagem de sucesso.
Níveis de acesso (spec 019)
Cada quadro tem quatro níveis. O administrador (quem criou e quem está em Administradores) vale mais que qualquer marca. Os outros três são escolhidos na aba Geral da administração, em Acesso: um padrão para quem não foi marcado e, pessoa a pessoa, as exceções.
| Nível | Vê | Altera | |---|---|---| | Administrador | todos os cards | tudo, inclusive a estrutura | | Membro | todos os cards | cards, tarefas, chat e anexos | | Restrito | só os cards que criou ou em que é responsável (principal ou na lista) | os mesmos; cria card; não reordena a coluna | | Somente leitura | todos os cards, com histórico, tarefas, chat e anexos | nada (só a marca de "mensagens lidas") |
- Padrão. Quadro criado pela 1.9.0 nasce restrito. Quadro anterior, com
ACESSO_JSONvazio, é membro para todos até o administrador trocar. - No servidor. As 13 SFs de escrita sobre card,
kbCardsListar,kbCardDetalheekbMensagensListarconferem o nível. Card fora do alcance do restrito responde "Card não encontrado.", igual a um card que não existe. Leitor recebe "Você tem acesso somente leitura a este quadro.". - Antes dos ganchos. O nível é conferido antes das regras ligadas
podeMover/podeExcluir(spec 013), que só são consultadas para quem o nível deixa agir. - Processo e automação não passam pelo nível. Chamada sem sessão "em nome de" (specs 013/014) e automações agem como hoje.
- Na tela.
kbBoardCarregardevolvepapel(admin,membro,restritoouleitor), e a estrutura levapapel. Para o leitor, o quadro tira o botão de criar (inclusive onovoCarddo consumidor) e mostra o selo "Somente leitura", e o card fica no mesmo modo desabilitado do card excluído. O restrito vê o selo "Restrito: seus cards". As abas e ações do consumidor recebemctx.travadocomo antes; quem desenha botão próprio fora do card usapapelNoQuadro(board, usuarioId), exportado pela lib, que é a mesma regra do servidor. - Gravar por código.
kbBoardSalvaraceitaacessoJson({"padrao":"restrito","pessoas":{"3":"leitor"}}). Ausente ou vazio, mantém o que está gravado; nível desconhecido é recusado.
Responsáveis e campos de fase passada
Um card tem uma lista de responsáveis (KB_CARD_RESPONSAVEL); KB_CARD.RESPONSAVEL_ID continua
existindo como espelho do primeiro, para quem lê a coluna. kbCardCriar/kbCardAtualizar recebem
responsaveisIds ("3,4"); responsavelId sozinho ainda vale como lista de um. Filtro do quadro, condição
$responsavel e e-mail de automação valem para qualquer um da lista; "Atribuir responsável" redefine a
lista como só aquela pessoa.
Campo de fase é editável com o card na fase. Depois que o card sai, só se o admin marcar "Editável
depois da fase" no campo (e só em fase já visitada). A regra é camposEditaveis no motor e vale na
tela e no kbCardAtualizar (valor de campo não editável é ignorado; de fase não visitada, recusado).
Capa do card, observação e os três tempos
Capa por fase (spec 004): a cara do card tem duas partes, que se somam.
- Fixos do quadro — campos marcados "na capa de todas as fases" (
NO_CARD): aparecem em qualquer fase, na ordem dos campos (campo de fase continua só na fase dele). É o que existia antes da 004. - O que a fase acrescenta — a lista de chaves em
KB_FASE.CAPA_JSON, na ordem dela, inclusive campo de outra fase (o que os fixos não fazem).
A regra é camposDoCard(campos, card, fase) no motor; chave morta, campo inativo, condição falsa e valor
vazio não ocupam linha, e um campo citado nas duas partes aparece uma vez. Fixo não se tira de uma fase:
para ele valer só em algumas, desmarque "todas as fases" e acrescente-o à lista de cada uma. A lista se edita
na prévia da administração ("Editar capa"), que mostra as duas partes na ordem em que saem no cartão.
kbFaseSalvar grava o que recebe: quem chama sem capaJson devolve a fase ao padrão do quadro, do
mesmo jeito que chamar sem descricao limpa a descrição. O BoardAdmin sempre manda a capa atual junto.
Observação do campo: uma linha guardada em CONFIG_JSON (config.observacao) e exposta como
campo.observacao. Aparece sob o campo onde ele é editável (novo card, formulário inicial, fase atual e
passagem "editável depois"); erro de validação ocupa o mesmo lugar e tem precedência. Não vai para a cara
do card nem para o texto de busca.
Três tempos: todo cartão mostra criação, tempo na fase e última alteração, com ícone e ordem fixa
(Tempos, no vocabulário). Vêm de CRIADO_EM, FASE_DESDE e ATUALIZADO_EM — nenhuma consulta nova.
A unidade é a maior que couber (duracao: min → h → d) e, com SLA na fase, o relógio mostra a fração
21 h / 48 h, em tom de atenção quando passa (tempoNaFase). Não é configurável: vale em todo quadro.
Cores
Cor de fase e de opção de seleção vem de uma paleta fixa de 8 tokens da rampa de dados do design
system (data-1 Azul … data-8 Vermelho — CORES no motor); o motor guarda o nome do token, nunca hex,
e a tela renderiza var(--data-N), que existe nos dois temas. Na coluna a cor pinta a barra (o título
continua ink-muted, porque três matizes da rampa não passam 4,5:1 como texto) e cada card leva uma
faixa na borda esquerda. Na administração a cor se escolhe por bolinhas (nome no title e no hint).
As colunas usam a altura tela do Board: a tela ocupa o main inteiro (h-full, layout do §10) e o
quadro cresce até o rodapé dele, nunca abaixo de 30rem — quando a janela é mais baixa que isso, quem rola é o
main, não uma segunda barra dentro do quadro. Os cabeçalhos das fases dividem uma linha de grade (subgrid):
uma descrição de duas linhas numa fase não desalinha os cards das outras.
A etiqueta (e só ela) aparece como selo (Selo, exportado): o rótulo por dentro da cor. O selo
contrasta com o tema — escuro com rótulo claro no tema claro, claro com rótulo escuro no escuro —, e a
regra é simétrica, sem caso por tema: background: color-mix(in srgb, var(--data-N) 55%, var(--foreground))
e color: var(--background). 55% é o teto medido em que os oito matizes ainda passam de 4,5:1 nos dois temas
(design.md §12) — nenhum token novo. O cartão mostra três e resume o resto em "+N".
Etiquetas (spec 010)
Etiqueta é campo, não tabela: cada quadro pode ter um campo reservado de chave etiquetas
(seleção múltipla, sem fase, na capa), e a lista de etiquetas é o config.opcoes dele. Com isso ela herda
validação, condição, automação, capa da fase, aba Alterações e busca — sem nenhuma tabela, coluna ou SF nova.
- Ativar: administração › Fases › formulário inicial › "Ativar etiquetas" (cria o campo com a primeira).
- Cadastrar: uma linha por etiqueta — rótulo, cor em bolinhas, selo de prévia. O valor nasce do rótulo na criação e não muda: é ele que está gravado nos cards, e é o que faz renomear valer para todos de uma vez.
- Marcar: no card aberto, selos com
×e um+que abre a lista e não fecha ao marcar. - Filtrar: um seletor na barra do quadro. O motor grava
#chave#noTEXTO_BUSCAdo card e o filtro procura essa forma — por isso ele acha pelo que está marcado, e não pela palavra no título.
Tarefas, chat e anexos (specs 005–007)
As três abas do meio do card aberto. Cada uma tem tabela própria, grava por server function e registra em
Alterações o que importa; abrir o card continua sendo uma chamada (kbCardDetalhe traz tarefas, anexos e o
total da conversa).
Tarefas (KB_TAREFA): título, responsável e prazo opcionais; concluir guarda quem e quando; setas para a
ordem (subir grava só as duas que trocaram); excluir é de quem criou a tarefa ou do administrador. Condição de
fase, de visibilidade e de automação ganham "Tarefas pendentes" ($tarefas): "Tarefas pendentes igual a 0"
na entrada de uma fase exige tudo concluído. Tarefa não mexe no "alterado há" do cartão.
Chat (KB_MENSAGEM, KB_MENSAGEM_LEITURA): texto até 4.000 caracteres; remetente e hora sempre do servidor.
Com a aba visível, um ciclo de kbMensagensListar a cada 5 s (20 s depois de 1 min sem mensagem nova), sempre
incremental, só com a janela à mostra e uma consulta por vez — ciclo sem novidade não grava nada. Abre nas 50
mais recentes ("Carregar anteriores" traz o resto). "@" abre a lista de pessoas; o mencionado recebe e-mail
(quem escreve não; editar não reenvia; desde a 1.6.0 o e-mail sai no modelo Benenutri, com a mensagem citada e o
botão "Responder no card" quando email.urlDoCard foi configurado — ver E-mail (spec 016)).
O autor edita e exclui nos primeiros 5 minutos; o administrador do quadro exclui qualquer uma, e a exclusão
apaga o texto e fica em Alterações. O cartão do quadro mostra as mensagens novas para quem olha — de outras
pessoas, depois da última que ela viu naquele card — e o número some quando ela abre a conversa.
Desde a 1.7.0 (spec 017) a mensagem leva um arquivo: o clipe do composer (só com enviarArquivo) sobe o
arquivo pelo projeto e a mesma kbMensagemEnviar grava mensagem, anexo e registro; o arquivo entra na aba Anexos
com "pelo chat" e o chip da mensagem o abre. Só o arquivo, sem texto, vale. Excluir a mensagem não remove o
arquivo (é documento do card); remover o arquivo pela aba Anexos deixa a mensagem dizendo "anexo removido".
Anexos (KB_ANEXO): o arquivo sobe pelo enviarArquivo do projeto e a lib só registra o endereço
(kbAnexoRegistrar); link entra do mesmo jeito, com nome opcional (sem nome, o domínio). Só http(s); até 20 MB
por arquivo. A aba avisa que arquivo enviado fica em endereço público — documento sensível vai como link.
Remover é de quem anexou ou do administrador; some da lista, fica em Alterações e não apaga o arquivo do
armazenamento.
E-mail (spec 016)
Todo e-mail que o motor manda sai num modelo só (design system Benenutri, §15): marca em texto, nome do sistema, título em frase, blocos opcionais (resumo, aviso, corpo, citação, dados, tabela, anexos), um botão e o rodapé com o motivo. Quatro envios usam o modelo:
- a ação "Enviar e-mail" das automações — o texto do administrador é texto (linha em branco vira parágrafo,
endereço vira link,
**negrito**; HTML é escapado) e{{anexos}}vira o bloco de links; - a menção no chat — a mensagem como citação e "Responder no card";
- novo responsável — "Você é responsável pelo card #N — título", só para quem entrou na lista (criação, edição ou ação "Atribuir responsável"), nunca para quem age nem para quem já estava;
- tarefa atribuída — "Tarefa para você no card #N: título", na criação e na troca de responsável; a si mesmo, não.
Movimento, campo alterado e prazo continuam sendo assunto de automação do administrador. Falha no envio nunca
desfaz a gravação: a tarefa fica, a mensagem fica (emailFalhou: true), e na automação a ação registra erro.
Configuração, uma vez, no add-001-kanban.mjs:
await instalar({
sdk, projectId, mapaFrontend: MAPA,
email: { sistema: 'SGC 2', urlDoCard: 'https://sgc.benenutri.com/#/quadro?card={id}' },
});sistema é o nome curto (cabeçalho, assunto [SGC 2] … e rodapé; sem ele, Benenutri); urlDoCard é o
endereço do card com {id} — sem ele o e-mail sai sem botão, nunca com link quebrado. Os dois ficam em
KB_META (email:sistema, email:urlDoCard); sem email no instalar(), nada é tocado.
Os e-mails próprios do sistema (pedido à indústria, agenda, cobrança de NF) usam o mesmo modelo:
import { montarEmail } from '@benenutri/mitra-kanban/motor';
const { assunto, html } = montarEmail({
sistema: 'SGC 2',
rotulo: 'Pedido de compra · nº 482',
titulo: 'Pedido de compra 482 — Benenutri',
corpo: 'Prezados,\n\nSegue o pedido conforme os itens abaixo.',
dados: [['Fornecedor', 'Nestlé Brasil'], ['Condição', '28 dias · CIF']],
tabela: {
titulo: 'Itens',
colunas: [{ rotulo: 'Produto' }, { rotulo: 'Qtd', numerico: true }, { rotulo: 'Total', numerico: true }],
linhas: [['Leite em pó 400 g', '120', 'R$ 18.450,00']],
rodape: ['Total', '120', 'R$ 18.450,00'],
},
anexos: [{ nome: 'Pedido-482.pdf', url, tamanho: '184 KB' }],
// destinatário de fora: sem `acao` (não abre o card) e rodapé que convida a responder
rodape: 'Enviado pelo SGC 2 · Benenutri. Responda a este e-mail para falar com Compras.',
});
await sendEmailMitra({ projectId, to, subject: assunto, body: html });Partes: sistema, rotulo, titulo (obrigatório), resumo, aviso: { tom: 'atencao' | 'alerta', texto },
corpo, citacao: { autor, quando, texto }, dados: [[rótulo, valor | { antes, depois }]], tabela, anexos,
acao: { rotulo, url }, motivo, rodape, preheader, logoUrl. Tipos em motor/motor.d.mts
(PartesDeEmail). Regras que valem para todo e-mail: título diz o que aconteceu; um botão só; texto é texto;
dado ausente é travessão; o tom é uma faixa dentro do cartão e nunca recolore o e-mail; sempre claro; a marca é
#45963d, nunca o verde da interface. O e-mail de referência está em assets/email.html da skill
benenutri:design.
Desenvolver a lib
npm install
npm test # sim:check + sim:smoke (57 SFs reais em SQLite) + test:motor
npm run sim # simulador em http://localhost:3120 (senha mitra123; logins no seed)
npm run build # tsc → dist/Fluxo de uma versão nova: mudar → npm test verde → bump em package.json → coluna nova vai
no CREATE e em UPGRADES → npm publish (o prepublishOnly roda os testes; publishConfig
já é public) → atualizar os consumidores.
Versões: 0.1.0 = só backend (motor, schema, server functions, instalador); 1.0.0 = backend +
componentes React (exports["."] → dist/). Peer dependencies do frontend: react, react-dom,
radix-ui, cmdk, lucide-react, class-variance-authority, clsx, tailwind-merge — o kit do
design system já as instala.
Para trabalhar com um consumidor local: npm link aqui e npm link @benenutri/mitra-kanban
no consumidor; o package.json do consumidor continua apontando para a versão publicada
(o sandbox não enxerga file:).
Fuso horário
O servidor grava instantes em UTC (AAAA-MM-DDTHH:MM:SS, sem Z). A tela converte para o fuso do
navegador antes de mostrar hora e antes de decidir o dia (formatarDataHora, formatarData, dataLocal,
hoje, relativo, agruparPorDia — todos em src/formato.ts). No servidor, a única decisão por dia — prazo vencido
no cron — usa hojeNoFuso() (FUSO_MINUTOS = -180, Brasil sem horário de verão), nunca o dia UTC.
Regras que não se negocia
- Nunca rode
instalar()/install.mjsda máquina local: só no sandbox do projeto (migration). schema.mjssó cresce: nada deDROP,RENAMEou mudança de tipo.- Nome de SF é contrato: renomear quebra todo consumidor.
motor/motor.mjsnão importa nada e não contém{{literal.- Commit sem trailer de agente (
Co-Authored-By: Claude…é proibido).
