73 KiB
name, description
| name | description |
|---|---|
| vitruvio-criar-dashboard-desktop | Use when creating or adapting the DESKTOP form of a Vitruvio indicator dashboard panel — KPI cards, SVG charts (line/donut/bar), and a drill-down movimentação table, following the dashboard-contratos / dashboard-tecnicos pattern. Triggers: "criar dashboard", "criar indicador", "criar painel indicador", "novo dashboard de KPIs", "dashboard com gráficos", or adapting an existing panel XML to this pattern. Called by vitruvio-criar-indicador-dashboard for the desktop part; can also be invoked directly with an optional existing XML path. For the mobile shell of an existing indicator dashboard use vitruvio-criar-dashboard-mobile. |
Criar Painel Indicador Dashboard
Todas as mensagens ao usuário devem ser em Português.
Você está criando ou adaptando um painel indicador no padrão Vitruvio com gráficos, KPIs e movimentação com drill-down. O padrão é baseado nos painéis dashboard-contratos e dashboard-tecnicos deste repositório.
Receba como argumento opcional o caminho de um arquivo XML existente (ex: panels/meu-painel/meu-painel-desktop.xml) para adaptar ao padrão. Sem argumento, cria um novo painel do zero.
LEITURA OBRIGATÓRIA AO INICIAR — CONTEXT.md
Esta é a primeira coisa a fazer, sempre, antes de qualquer outra ação.
Se o argumento fornecido aponta para um painel existente (ex: panels/meu-painel/meu-painel-desktop.xml), derive o diretório do painel e tente ler o arquivo de contexto:
panels/<panel-key>/CONTEXT.md
Se o CONTEXT.md existir:
- Leia-o completo antes de abrir qualquer outro arquivo.
- Use-o como fonte primária de verdade sobre o estado atual do painel: queries SQL reais, IDs dos campos, funções JS, pendências, histórico.
- Só leia o
<panel-key>-desktop.xmldepois, para verificar se o CONTEXT.md ainda está em sincronia com o código. Se houver divergência, atualize o CONTEXT.md ao final. - Informe ao usuário: "Li o contexto do painel. Última modificação: [data]. Pendências encontradas: [lista do TODO]."
Se o CONTEXT.md não existir ainda:
- Informe ao usuário que não há arquivo de contexto e que ele será criado ao final.
- Leia o
<panel-key>-desktop.xmlcompleto para reconstruir o contexto.
Para novo painel (sem argumento): não há CONTEXT.md ainda — será criado na Fase 8.
Princípios obrigatórios — leia antes de qualquer coisa
Nunca invente — pergunte quando tiver dúvida
- Não invente nomes de tabelas, colunas, views ou sequências. Se o usuário não fornecer o DDL ou a estrutura da tabela, pergunte. Uma pergunta custa menos do que um bug em produção.
- Se o usuário mencionar uma tabela mas não os campos, pergunte quais campos existem nela antes de escrever SQL.
- Se houver dúvida sobre tipo de dado (numérico, texto, data), pergunte.
- Baseie-se sempre em código real. Os dois painéis de referência canônicos deste padrão estão
empacotados junto com esta skill — leia-os por completo antes de escrever qualquer linha:
.claude/skills/vitruvio-criar-dashboard-desktop/dashboard-contrato-desktop.xml.claude/skills/vitruvio-criar-dashboard-desktop/dashboard-tecnicos-desktop.xmldashboard-contrato-desktop.xmlé a versão mais recente/evoluída do padrão (ex: usa a global_mobPreviewModequedashboard-tecnicos-desktop.xmlainda não tem) — em caso de divergência entre os dois, prefira o padrão dodashboard-contrato-desktop.xml..claude/skills/vitruvio-criar-dashboard-desktop/dashboard_ia.jsdocumenta a API dodashLib(addSharedCss,loadPreferences,savePreferences,applyTheme, paleta de cores, etc.). É material de consulta apenas — leia-o para saber que métodos existem e como usá-los, mas nunca crie, copie ou registre umscripts/dashboard_ia.jsno repositório de destino. O painel gerado apenas chamalibService.loadScript('dashboard_ia'); a lib em si é externa ao escopo desta skill.
JavaScript — ES5 Rhino somente
// PROIBIDO: const, let, =>, template literals, destructuring, class, async/await, import/export
// OBRIGATÓRIO: var, function(){}, concatenação de strings, for(var i...)
Paleta de cores padrão (do script dashboard_ia)
| Contexto | Cor |
|---|---|
| Fundo dark (wrapper) | #0f1923 |
| Card/box dark | #1a2733 |
| Borda/grid dark | #243447 |
| Texto primário dark | #fff / #ccc |
| Texto secundário dark | #8899aa |
| Azul destaque | #4a9edd |
| Verde positivo | #2ecc71 |
| Vermelho negativo | #e74c3c |
| Laranja atenção | #e67e22 / #f39c12 |
| Fundo light (wrapper) | #f0f2f5 |
| Card/box light | #ffffff |
| Texto primário light | #1e293b / #334155 |
| Texto secundário light | #64748b |
Nunca use cores fora desta paleta sem justificativa explícita do usuário.
FASE 0 — Verificar repositório e modo de operação
ls vitruvio.json 2>/dev/null && echo "OK" || echo "NOT_A_VITRUVIO_REPO"
Se NOT_A_VITRUVIO_REPO, pare e peça para o usuário entrar no diretório correto.
Se recebeu um arquivo como argumento:
- Leia o arquivo XML completo.
- Identifique o que já existe:
formKey, layouts, campos, queries SQL, funções JS. - Identifique o que está faltando em relação ao padrão (topBar com botões padrão, filterBar colapsável, detecção mobile, funções de sort/filter client-side, tema duplo, etc.).
- Documente o delta antes de fazer perguntas ao usuário.
- Informe o usuário o que será preservado e o que será alterado — peça confirmação antes de reescrever qualquer lógica de negócio existente.
Se não recebeu arquivo (novo painel):
Prossiga direto para a Fase 1.
FASE 1 — Coleta de informações básicas
Pergunte ao usuário (pode fazer tudo de uma vez em um bloco organizado):
Preciso de algumas informações para criar o painel. Responda o que souber —
se não souber algo agora, pode deixar em branco e adicionar depois.
1. IDENTIFICAÇÃO
a) Nome do painel (ex: "Dashboard de Rentabilidade")
b) Chave do painel em kebab-case (ex: "dashboard-rentabilidade")
c) Descrição curta (uma linha)
d) Categoria no menu (ex: "Financeiro/Indicadores")
2. GRUPOS DE ACESSO
Quais grupos de usuários terão acesso? (ex: "financeiro", "diretoria")
Se não souber agora, pode deixar vazio.
FASE 2 — Coleta da fonte de dados
Esta é a fase mais crítica. Nunca avance sem ter as tabelas e campos reais.
Preciso entender de onde vêm os dados.
3. FONTE DE DADOS
a) Quais tabelas ou views serão consultadas?
(Se não tiver certeza dos nomes exatos, cole o DDL das tabelas ou
o resultado de um SELECT * LIMIT 1 para eu ver os campos)
b) Como as tabelas se relacionam? (JOINs principais)
c) Qual datasource usar? (padrão: vitruvio_producao)
d) A query é PostgreSQL ou Oracle?
(Isso afeta funções de data: EXTRACT vs TRUNC, CURRENT_DATE vs SYSDATE, etc.)
Independente da resposta em (d), o painel sempre detecta o banco em tempo de execução com
banco.isOracle()(norun()) para tratar os ícones — ver seção 7.2.2. A pergunta (d) é só para escrever o SQL no dialeto certo.
Se o usuário não fornecer DDL suficiente, pergunte especificamente:
"Você pode colar o resultado de
\d nome_da_tabela(PostgreSQL) ouDESCRIBE nome_da_tabela(Oracle) para eu ver os campos exatos?"
FASE 3 — Coleta dos filtros
4. FILTROS (sidebar lateral)
Para cada filtro, me diga:
- Nome exibido ao usuário
- Tipo: ComboBox (lista fixa), DBComboBox (lista do banco),
DateField (data), NumericField (número), TwinColSelect (multi-seleção)
- Se for DBComboBox: qual SQL gera as opções?
- Valor padrão (se houver)
- O filtro dispara re-render automático ao mudar,
ou precisa de um botão "Pesquisar"?
Exemplos comuns:
- Ano: DBComboBox com generate_series → auto-reload
- Mês: ComboBox fixo com 12 meses → auto-reload
- Período: DateField De/Até + botão Pesquisar
- Técnico: DBComboBox da tabela de usuários
- Unidade/Filial: DBComboBox
Se o filtro vem de banco de dados, solicite o SQL de população antes de escrever.
Sempre que houver ao menos um filtro que precisa de botão "Pesquisar", esse botão — ao ser clicado — aplica os filtros e recolhe a filterBar ao estado inicial (fechada). É o comportamento padrão, não pergunte ao usuário; ver seção 7.2.1.
FASE 4 — Coleta dos KPIs e gráficos
5. KPIs (cards de valor no topo do dashboard)
Quais valores numéricos importantes devem aparecer como KPI cards?
Para cada um:
- Rótulo (ex: "Total de Contratos", "MRR", "Chamados Abertos")
- Cor do valor: azul (#4a9edd), verde (#2ecc71), vermelho (#e74c3c),
laranja (#e67e22), branco (padrão)
- Comparar com período anterior? (mostra diferença + / -)
6. GRÁFICOS
Tipos disponíveis (todos gerados como SVG inline, sem bibliotecas externas):
A) Linha temporal — evolução mês a mês ou dia a dia
Preciso saber: eixo X (datas/meses), eixo Y (qual valor)
B) Donut — proporção entre 2 ou 3 categorias
Preciso saber: quais categorias e como calcular cada fatia
C) Barras horizontais (ranking) — lista de itens ordenados por valor
Preciso saber: o que é cada barra (item) e qual valor
D) Barras verticais — comparação por período ou categoria
Preciso saber: eixo X (categorias), eixo Y (valor), cores
E) Barras empilhadas — composição de totais
Preciso saber: as fatias e suas cores
Para cada gráfico, informe:
- Qual tipo (A/B/C/D/E)
- Título do gráfico
- Disposição no desktop: linha única, ou ao lado de outro gráfico?
(ex: "Donut SLA ao lado de Barras por Tipo de Serviço")
- SQL que gera os dados (ou descreva o que calcular)
FASE 5 — Coleta da Movimentação (tabela de detalhes)
7. MOVIMENTAÇÃO (visão tabular com linha base + drill-down)
A) LINHA BASE (colunas visíveis na tabela principal)
Para cada coluna:
- Nome exibido (cabeçalho)
- Campo no banco / expressão
- Tipo: texto, número, data, badge colorido
- Ordenável pelo usuário? (clique no cabeçalho)
- Incluso no filtro de pesquisa global? (campo de busca)
- Largura aproximada: pequena / média / grande
B) DRILL-DOWN (linha de detalhe expandida ao clicar)
Ao clicar em uma linha, o que aparece abaixo dela?
- Campos adicionais não visíveis na linha principal
- Sub-tabela com itens relacionados?
- SQL adicional executado no servidor OU dados já carregados no HTML?
(Para performance, prefira carregar tudo de uma vez se o volume permitir)
C) FILTROS CLIENT-SIDE da movimentação
- Campo de pesquisa de texto livre? (em quais colunas busca?)
- Selects de filtro rápido? (ex: filtrar por Status, por Técnico)
D) EXPORTAÇÃO CSV
- Quais colunas exportar?
- Nome do arquivo de download
E) BADGES / CORES de linha
Alguma linha tem cor de fundo especial por status?
(ex: linha verde para "novo", vermelha para "cancelado")
FASE 6 — Validação antes de gerar
Após coletar todas as informações, apresente um resumo estruturado ao usuário:
## Resumo do painel "NOME"
**Identificação:** key = painel-key | Categoria: X
**Filtros (sidebar):**
- Ano: DBComboBox → auto-reload
- Mês: ComboBox fixo → auto-reload
- [outros]
**Dashboard — KPIs:**
1. Total de X → R$ 0,00 (azul)
2. [outros]
**Dashboard — Gráficos:**
1. Linha temporal: evolução mensal de X (largura total)
2. Donut: proporção A vs B (lado esquerdo) | Barras: ranking por cliente (lado direito)
**Movimentação:**
Colunas: [lista]
Drill-down: [descrição]
Filtro global: busca em [colunas]
Badges: [descrição]
**Pendências / dúvidas antes de gerar:**
- [listar qualquer ponto incerto]
Posso prosseguir com a geração?
Só gere código após o usuário confirmar o resumo.
FASE 7 — Geração do painel
7.1 — Estrutura obrigatória do XML
Todo painel indicador neste repositório deve ter exatamente esta estrutura:
rootLayout (VerticalLayout, 100% x 100%)
├── topBar (HorizontalLayout)
│ ├── btnToggleFiltros → colapsa/expande filterBar
│ ├── btnDash → muda currentView para 'dashboard'
│ ├── btnMov → muda currentView para 'movimentacao'
│ ├── Label (spacer, expandRatio=1)
│ ├── btnDevPreview → preview mobile (vi_developer, visible=false)
│ ├── btnExitPreview → sair preview (visible=false)
│ └── btnTheme → alterna tema dark/light (chama doToggleTheme)
│
└── mainArea (HorizontalLayout, expandRatio=1)
├── filterBar (VerticalLayout, 220px)
│ ├── btnThemeMob (visible=false — PRIMEIRO filho; aparece no mobile)
│ ├── [campos de filtro do painel]
│ ├── btnPesquisar (só se houver filtro sem auto-reload; ao clicar aplica
│ │ os filtros E recolhe a filterBar — ver seção 7.2.1)
│ └── Label (spacer, expandRatio=1)
│
└── contentPanel (Panel, expandRatio=1)
└── panelRoot (VerticalLayout)
└── scriptDashboard (ScriptWidget)
7.2 — initScript (run())
O run() no <initScript> da raiz do form deve sempre ter esta sequência:
function run() {
var dashLib = libService.loadScript('dashboard_ia');
var login = String(engine.getLoggedUser().getLogin());
engine.setGlobalVariable('dashLib', dashLib);
engine.setGlobalVariable('userLogin', login);
var page = Packages.com.vaadin.ui.UI.getCurrent().getPage();
dashLib.addSharedCss(page);
addCss(page); // CSS específico do painel
var _topBar = engine.getLayout('topBar');
if (_topBar) { _topBar.getRootComposition().addStyleName('dash-topbar'); }
// ── Detecção de banco: Oracle x PostgreSQL (ver seção 7.2.2) ──
// Precisa vir cedo, antes de qualquer setCaption com ícone, porque no Oracle
// os ícones astrais (emoji) não renderizam e precisam de fallback BMP/ASCII.
var _dbIco = libService.loadScript('db');
var _bancoIco = new _dbIco(_dbIco.VITRUVIO_DATASOURCE);
var _isOracle = false;
try { _isOracle = !!_bancoIco.isOracle(); } catch(e) {}
engine.setGlobalVariable('isOracle', _isOracle);
engine.setGlobalVariable('ico', ico); // helper de ícone (seção 7.2.2)
// aplicarIconesOracleNaTopBar() é chamado MAIS ABAIXO, depois de dashLib.applyTheme,
// senão o applyTheme reescreve o caption do btnTheme por cima.
// ── Detecção mobile ──
if (engine.isGlobalVariableSet('_mobileRender')) {
engine.setGlobalVariable('isMobile', true);
var _rootLayout = engine.getLayout('rootLayout');
if (_rootLayout) {
var _rootLayoutComp = _rootLayout.getRootComposition();
// 700px é só o placeholder inicial — o server não sabe a altura da tela do cliente.
// O script abaixo troca por um valor proporcional a window.screen.height assim que a
// página carrega (ver detalhes na skill vitruvio-criar-dashboard-mobile, seção 4.1).
_rootLayoutComp.setHeight("700px");
_rootLayoutComp.addStyleName('mobile-root-frame');
page.getJavaScript().execute(
"(function(){" +
"var el=document.querySelector('.mobile-root-frame');" +
"if(!el){return;}" +
"var sh=window.screen.height;" +
"var byMargin=sh-220;" +
"var byRatio=Math.round(sh*0.80);" +
"var h=Math.max(320,Math.min(byMargin,byRatio));" +
"el.style.height=h+'px';" +
"})();"
);
}
if (_topBar) { _topBar.getRootComposition().addStyleName('mobile-topbar'); }
var _filterBar = engine.getLayout('filterBar');
if (_filterBar) { _filterBar.getRootComposition().setVisible(false); }
var _btnThemeMob = engine.getWidgetController('btnThemeMob');
if (_btnThemeMob) { _btnThemeMob.getButton().setVisible(true); }
// Oculta btnTheme do topBar — no mobile só btnThemeMob (dentro da filterBar) é visível.
// Sem isso o usuário vê dois botões de tema ao mesmo tempo.
var _btnThemeTb = engine.getWidgetController('btnTheme');
if (_btnThemeTb) { _btnThemeTb.getButton().setVisible(false); }
page.getStyles().add(
".mobile-view .mov-scroll { height:520px !important; overflow-y:auto !important;" +
" -webkit-overflow-scrolling:touch !important; overscroll-behavior:contain; touch-action:pan-y; } "
);
}
// ── Verificar vi_developer ──
try {
var _dbLib = libService.loadScript('db');
var _bancoChk = new _dbLib(_dbLib.VITRUVIO_DATASOURCE);
var _devRow = _bancoChk.queryRow(
"SELECT COUNT(*) AS CNT FROM nauth.usuario u " +
"INNER JOIN nauth.usuario_grupo ug ON u.usuario_id = ug.usuario_fk " +
"INNER JOIN nauth.grupo g ON g.grupo_id = ug.grupo_fk " +
"WHERE u.login = :login AND g.sigla = 'vi_developer'",
{ login: login }
);
var _isDev = _devRow && Number(_devRow.CNT) > 0;
engine.setGlobalVariable('isDeveloper', _isDev);
var _btnDevCtrl = engine.getWidgetController('btnDevPreview');
if (_btnDevCtrl) { _btnDevCtrl.getButton().setVisible(_isDev); }
} catch(e) {}
// ── filterBar começa fechado ──
var _filterBarInit = engine.getLayout('filterBar');
if (_filterBarInit) { _filterBarInit.getRootComposition().setVisible(false); }
var _btnToggle = engine.getWidgetController('btnToggleFiltros');
if (_btnToggle) { _btnToggle.getButton().setCaption(ico('filtros_abrir')); }
// ── Tema salvo nas preferências ──
var prefs = dashLib.loadPreferences(login);
var savedTheme = prefs.dark_mode ? 'dark' : 'light';
engine.setGlobalVariable('theme', savedTheme);
if (_filterBarInit) {
_filterBarInit.getRootComposition().addStyleName(
savedTheme === 'light' ? 'dash-filterbar-light' : 'dash-filterbar-dark'
);
}
dashLib.applyTheme(savedTheme, page, engine.getWidgetController('btnTheme').getButton());
// ── Ícones Oracle: reescreve os captions estáticos do topBar (seção 7.2.2) ──
// Depois do applyTheme para o caption do btnTheme não ser sobrescrito.
if (_isOracle) {
aplicarIconesOracleNaTopBar();
var _bTh = engine.getWidgetController('btnTheme');
if (_bTh) { _bTh.getButton().setCaption(ico(savedTheme === 'dark' ? 'tema_escuro' : 'tema_claro')); }
}
// ── Registra doToggleTheme como global (chamado por btnTheme e btnThemeMob) ──
engine.setGlobalVariable('doToggleTheme', doToggleTheme);
engine.setGlobalVariable('enterMobilePreview', enterMobilePreview);
engine.setGlobalVariable('exitMobilePreview', exitMobilePreview);
// ── Registra função JS client-side de aplicar tema no HTML gerado ──
page.getJavaScript().execute(
"window.<panelId>ApplyTheme=function(){" +
"var w=document.getElementById('<panel-id>-main');" +
"if(!w)return;" +
"if(document.body.classList.contains('theme-light-app'))w.classList.add('theme-light');" +
"else w.classList.remove('theme-light');" +
"};"
);
// ── Registra sort e filter client-side ──
// (adaptar as colunas conforme a movimentação do painel)
page.getJavaScript().execute(
"window.movSort=function(c,t){ /* ... padrão dos outros painéis ... */ };" +
"window.movFilter=function(){ /* ... adaptar colunas ... */ };" +
"window.movExportCSV=function(){ /* ... adaptar colunas ... */ };"
);
// ── Valores padrão dos filtros (se aplicável) ──
// ex: engine.getField('dtIni').setValue(...); engine.getField('nmfSla').setValue(3);
}
7.2.1 — Botão Pesquisar (padrão obrigatório quando há filtros sem auto-reload)
Filtros que disparam re-render sozinhos ao mudar (ComboBox de ano/mês) não precisam de botão.
Mas sempre que houver filtros que só devem ser aplicados sob demanda (DateField De/Até, campos de
texto, multi-seleção), a filterBar ganha um btnPesquisar.
Comportamento obrigatório do btnPesquisar: ao clicar, além de reaplicar os filtros
(renderView()), a filterBar volta a ficar oculta, exatamente como estava ao abrir o painel.
A sidebar de filtros é um overlay de trabalho — depois que o usuário escolheu o que queria, o valor
está na tela do dashboard, não na sidebar. Deixá-la aberta só rouba largura do conteúdo e obriga um
segundo clique no toggle. Fechar sozinha devolve o dashboard inteiro e deixa claro que a pesquisa
foi aplicada.
<ButtonWidget id="btnPesquisar" caption="Pesquisar" style="BLUE" width="100%">
<onClickScript language="JavaScript">
<![CDATA[
function run() {
// 1) aplica os filtros
var fn = engine.getGlobalVariable('renderView');
if (fn) { fn(); }
// 2) recolhe a filterBar ao estado inicial (fechada)
var fb = engine.getLayout('filterBar');
if (fb) { fb.getRootComposition().setVisible(false); }
var bt = engine.getWidgetController('btnToggleFiltros');
if (bt) {
var icoFn = engine.getGlobalVariable('ico');
bt.getButton().setCaption(icoFn ? icoFn('filtros_abrir') : '► Filtros');
}
}
]]>
</onClickScript>
</ButtonWidget>
O caption de "fechada" tem que ser o mesmo que o run() e o btnToggleFiltros usam para o
estado fechado — se você mudar o glifo num lugar, mude nos três, senão o botão de toggle passa a
mentir sobre o estado.
7.2.2 — Ícones e Oracle (padrão obrigatório)
O ambiente Oracle corrompe caracteres fora do plano BMP — emojis e símbolos astrais
(📱 U+1F4F1, 🤖 U+1F916, 💬 U+1F4AC, ⬇ U+2B07…) chegam à tela como ¿, ? ou quadrados,
porque o charset/NLS do driver não os transporta. Símbolos BMP (◄ U+25C4, ► U+25BA,
☽ U+263D, ☀ U+2600, ✕ U+2715, ✓ U+2713) sobrevivem normalmente.
Por isso todo painel deve descobrir o banco uma única vez — banco.isOracle() num new db(...) —
e, quando for Oracle, trocar os ícones astrais por um equivalente BMP ou ASCII. Nunca gere um
emoji direto no caption ou no HTML sem passar por esse tratamento; o painel roda igual nos dois
bancos e o mesmo XML pode ser importado num cliente Oracle e noutro PostgreSQL.
1 — Detecção no run() — já incluída no bloco da seção 7.2
(engine.setGlobalVariable('isOracle', ...)).
2 — Helper ico(chave) — declare no <initScript> da raiz (junto de run, doToggleTheme) e
registre como global para o ScriptWidget usar o mesmo mapa:
function ico(chave) {
var oracle = engine.getGlobalVariable('isOracle') == true;
// pg = glifo usado em PostgreSQL (pode ser emoji); ora = fallback seguro no Oracle
var MAP = {
'filtros_abrir': { pg: '► Filtros', ora: '► Filtros' }, // ► BMP, ok nos dois
'filtros_fechar': { pg: '◄ Filtros', ora: '◄ Filtros' }, // ◄ BMP, ok nos dois
'tema_escuro': { pg: '☽', ora: '☽' }, // ☽ BMP, ok nos dois
'tema_claro': { pg: '☀', ora: '☀' }, // ☀ BMP, ok nos dois
'fechar': { pg: '✕', ora: 'X' }, // ✕
'preview_mobile': { pg: '📱', ora: '[M]' }, // 📱 astral → fallback
'csv': { pg: '⬇ CSV', ora: 'CSV' }, // ⬇ astral → fallback
'robo': { pg: '🤖', ora: '[IA]' }, // 🤖 astral → fallback
'chat': { pg: '💬', ora: 'Chat' } // 💬 astral → fallback
};
var e = MAP[chave];
if (!e) { return ''; }
return oracle ? e.ora : e.pg;
}
Amplie o MAP com toda chave de ícone que o painel usar — a regra é: símbolo BMP pode repetir
pg/ora; emoji/astral precisa de um ora em ASCII ou BMP.
3 — Reescrever os captions estáticos do XML — os ButtonWidget do topBar têm o caption
declarado no XML (não passam pelo ico()). Quando Oracle, corrija-os no run():
function aplicarIconesOracleNaTopBar() {
var mapa = {
btnToggleFiltros: 'filtros_abrir', // filterBar começa fechada
btnDevPreview: 'preview_mobile',
btnExitPreview: 'fechar'
// btnTheme fica de fora: o caption dele depende do tema atual (☽/☀) e é
// ajustado logo após esta chamada, no próprio run() (ver seção 7.2).
};
for (var id in mapa) {
if (!mapa.hasOwnProperty(id)) { continue; }
var c = engine.getWidgetController(id);
if (c) { c.getButton().setCaption(ico(mapa[id])); }
}
}
4 — No HTML gerado pelo ScriptWidget — sempre que injetar um ícone no HTML (toolbar da
movimentação, botão CSV, FAB do chat), busque o helper: var ico = engine.getGlobalVariable('ico');
e use ico('csv') em vez de escrever ⬇ na string.
7.3 — doToggleTheme (padrão obrigatório)
function doToggleTheme() {
var dashLib = engine.getGlobalVariable('dashLib');
var login = engine.getGlobalVariable('userLogin');
var current = String(engine.getGlobalVariable('theme') || 'dark');
var next = current === 'dark' ? 'light' : 'dark';
engine.setGlobalVariable('theme', next);
try {
var ui = Packages.com.vaadin.ui.UI.getCurrent();
if (dashLib && ui) { dashLib.applyTheme(next, ui.getPage(), null); }
if (dashLib && login) { dashLib.savePreferences(login, { dark_mode: next === 'dark' }); }
} catch(e) {}
var icoFn = engine.getGlobalVariable('ico');
var capTema = next === 'dark'
? (icoFn ? icoFn('tema_escuro') : '☽')
: (icoFn ? icoFn('tema_claro') : '☀');
var bT = engine.getWidgetController('btnTheme');
if (bT) { bT.getButton().setCaption(capTema); }
var bM = engine.getWidgetController('btnThemeMob');
if (bM) { bM.getButton().setCaption(capTema); }
var _fb = engine.getLayout('filterBar');
if (_fb) {
var fbComp = _fb.getRootComposition();
if (next === 'light') { fbComp.removeStyleName('dash-filterbar-dark'); fbComp.addStyleName('dash-filterbar-light'); }
else { fbComp.removeStyleName('dash-filterbar-light'); fbComp.addStyleName('dash-filterbar-dark'); }
}
try {
Packages.com.vaadin.ui.UI.getCurrent().getPage().getJavaScript().execute(
"var el=document.getElementById('<panel-id>-main');" +
"if(el){if('" + next + "'==='light')el.classList.add('theme-light');else el.classList.remove('theme-light');}"
);
} catch(e2) {}
var fn = engine.getGlobalVariable('renderView');
if (fn) fn();
}
7.4 — ScriptWidget: estrutura interna
O ScriptWidget scriptDashboard deve sempre ter:
// Carregados uma vez, acessíveis para todas as funções do script
var components = libService.loadScript('vaadinComponents');
var db = libService.loadScript('db');
var banco = new db(db.VITRUVIO_DATASOURCE);
var base = null;
// Helpers de tema
function getTheme() { return String(engine.getGlobalVariable('theme') || 'dark'); }
function isLight() { return getTheme() === 'light'; }
function isMobileCtx() { return engine.isGlobalVariableSet('isMobile') || engine.isGlobalVariableSet('_mobileRender'); }
// Helper de ícone — mesmo mapa registrado no run() (seção 7.2.2). Use SEMPRE que
// injetar um ícone no HTML gerado (toolbar da movimentação, botão CSV, FAB do chat),
// para o painel funcionar igual em Oracle e PostgreSQL.
function ico(chave) {
var fn = engine.getGlobalVariable('ico');
return fn ? fn(chave) : '';
}
function isOracle() { return engine.getGlobalVariable('isOracle') == true; }
// Função de renderização obrigatória
function renderHtml(html) { /* padrão dos outros painéis */ }
// Funções de gráfico (apenas as que o painel usa)
function svgLineChart(...) { ... }
function svgDonut(...) { ... }
function svgHBar(...) { ... }
// View: dashboard
function renderDash() { ... }
// View: movimentação
function renderMov() { ... }
// Dispatcher principal — chamado por botões e filtros
function renderView() {
var view = String(engine.getGlobalVariable('currentView') || 'dashboard');
// Atualiza estilo do botão ativo
var bD = engine.getWidgetController('btnDash');
var bM = engine.getWidgetController('btnMov');
if (bD) { var bd = bD.getButton(); bd.removeStyleName('dash-nav-active'); if (view === 'dashboard') bd.addStyleName('dash-nav-active'); }
if (bM) { var bm = bM.getButton(); bm.removeStyleName('dash-nav-active'); if (view === 'movimentacao') bm.addStyleName('dash-nav-active'); }
// Exibe o filtro de ordenação só na movimentação (se aplicável)
var cmbOrdem = engine.getField('cmbOrdem');
if (cmbOrdem) { cmbOrdem.setVisible(view === 'movimentacao'); }
if (view === 'movimentacao') { renderMov(); }
else { renderDash(); }
}
// Chama renderView no init do ScriptWidget
function init(mapa) {
base = mapa.get('base');
engine.setGlobalVariable('renderView', renderView);
engine.setGlobalVariable('currentView', 'dashboard');
renderView();
}
7.5 — Gráficos SVG disponíveis
Copie apenas os gráficos que o painel usar, adaptando cores e labels:
- svgLineChart: linha temporal com área sombreada — do
dashboard-contratos - svgDonut: donut de proporção 2 fatias — do
dashboard-tecnicos - svgHBar: barras horizontais de ranking — do
dashboard-tecnicos - svgBarChart (novo): barras verticais por categoria
- svgStackedBar (novo): barras verticais empilhadas
Para gráficos novos, use as mesmas convenções de cores e variáveis isLight, colorGrid, colorLbl etc.
7.6 — Movimentação: padrões obrigatórios
Cabeçalho da tabela — colunas sortáveis:
<th data-l="Cliente" onclick="movSort(2,false)" style="cursor:pointer;" data-v="">
Cliente
</th>
Células com data-v para sort e filter:
<td data-v="VALOR_SORT_NUMERICO">VALOR_EXIBIDO</td>
Linha base + linha detalhe (par de TRs):
<tr class="row-main" onclick="movToggleDetail(this)">
<td data-v="...">...</td>
</tr>
<tr class="row-detail" style="display:none;">
<td colspan="N">... conteúdo do drill-down ...</td>
</tr>
Toolbar da movimentação:
html += '<div class="mov-toolbar">';
html += '<input id="mov-fi" class="mov-filter-input" placeholder="Pesquisar..." oninput="movFilter()">';
// Selects de filtro rápido (se houver):
html += '<select id="mov-stf" class="mov-export-btn" onchange="movFilter()"><option value="">Status</option>...</select>';
html += '<span id="mov-rc" style="color:#8899aa;font-size:11px;white-space:nowrap;">' + linhas.length + ' reg.</span>';
html += '<button class="mov-export-btn" onclick="movExportCSV()">' + ico('csv') + '</button>';
html += '</div>';
ico('csv')devolve⬇ CSVem PostgreSQL eCSVem Oracle (o⬇é astral e não renderiza no Oracle — ver 7.2.2). Vale para qualquer ícone que você injete no HTML da movimentação.
7.7 — CSS do painel
Sempre inclua no addCss(page):
var addCss = function(page) {
var style =
// Base dark
".dash-wrapper { background:#0f1923; padding:16px; font-family:Arial,Helvetica,sans-serif; box-sizing:border-box; width:100%; height:100%; overflow-y:auto; } " +
".dash-wrapper.mov-view { overflow:hidden; display:flex; flex-direction:column; } " +
".mov-scroll { flex:1; overflow-y:auto; min-height:0; } " +
".mov-scroll::-webkit-scrollbar { width:6px; } " +
".mov-scroll::-webkit-scrollbar-track { background:#0f1923; } " +
".mov-scroll::-webkit-scrollbar-thumb { background:#243447; border-radius:3px; } " +
// KPIs
".dash-kpi-row { display:flex; gap:16px; margin-bottom:16px; } " +
".dash-kpi { background:#1a2733; border-radius:6px; padding:20px 24px; flex:1; min-width:180px; } " +
".dash-kpi-label { color:#8899aa; font-size:13px; margin-bottom:6px; } " +
".dash-kpi-value { font-size:26px; font-weight:bold; color:#fff; } " +
".dash-kpi-sm { background:#1a2733; border-radius:6px; padding:10px 14px; flex:1; min-width:0; } " +
".dash-kpi-sm .dash-kpi-label { color:#8899aa; font-size:12px; margin-bottom:4px; } " +
".dash-kpi-sm .dash-kpi-value { font-size:17px; font-weight:bold; } " +
// Charts
".dash-chart-row { display:flex; gap:16px; margin-bottom:16px; } " +
".dash-chart-box { background:#1a2733; border-radius:6px; padding:16px; } " +
".dash-chart-title { color:#8899aa; font-size:13px; margin-bottom:10px; } " +
// Tabela movimentação
"table.mov-table { border-collapse:collapse; width:100%; font-size:12px; font-family:Arial; } " +
"table.mov-table th { background:#243447; color:#8899aa; padding:7px 10px; text-align:left; border-bottom:1px solid #2a3a4a; white-space:nowrap; position:sticky; top:0; z-index:5; } " +
"table.mov-table th.sortable { cursor:pointer; user-select:none; } " +
"table.mov-table th.sortable:hover { background:#2a3a4a; } " +
"table.mov-table td { padding:6px 10px; color:#ccc; border-bottom:1px solid #162030; } " +
"table.mov-table tr.row-main { cursor:pointer; } " +
"table.mov-table tr.row-main:hover td { background:#1e2d3d; } " +
"table.mov-table tr.row-detail td { cursor:default; background:#0a1525; white-space:normal; } " +
"table.mov-table tr.row-detail:hover td { filter:none; } " +
// Toolbar movimentação
".mov-toolbar { display:flex; align-items:center; gap:8px; margin-bottom:8px; flex-wrap:wrap; } " +
".mov-filter-input { background:#1a2733; border:1px solid #243447; color:#ccc; padding:5px 10px; border-radius:4px; font-size:11px; flex:1; min-width:120px; outline:none; font-family:Arial; } " +
".mov-filter-input:focus { border-color:#4a9edd; } " +
".mov-export-btn { background:#1a2733; border:1px solid #243447; color:#8899aa; padding:5px 12px; border-radius:4px; font-size:11px; cursor:pointer; white-space:nowrap; font-family:Arial; } " +
".mov-export-btn:hover { border-color:#4a9edd; color:#4a9edd; } " +
// Sidebar filterBar
".dash-filterbar-dark { background:#0d1921 !important; border-right:1px solid #1a2a38; } " +
".dash-filterbar-light { background:#f1f5f9 !important; border-right:1px solid #e2e8f0; } " +
".dash-filterbar-dark .v-caption { color:#8899aa !important; font-size:12px !important; } " +
".dash-filterbar-light .v-caption { color:#64748b !important; font-size:12px !important; } " +
// Tema light — overrides
".theme-light.dash-wrapper { background:#f0f2f5; } " +
".theme-light .dash-kpi { background:#ffffff; box-shadow:0 1px 3px rgba(0,0,0,.08); } " +
".theme-light .dash-kpi-label { color:#64748b; } " +
".theme-light .dash-kpi-value { color:#1e293b; } " +
".theme-light .dash-chart-box { background:#ffffff; box-shadow:0 1px 3px rgba(0,0,0,.08); } " +
".theme-light .dash-chart-title { color:#64748b; } " +
".theme-light table.mov-table th { background:#f8fafc; color:#64748b; border-bottom:1px solid #e2e8f0; } " +
".theme-light table.mov-table td { color:#334155; border-bottom:1px solid #e2e8f0; } " +
".theme-light table.mov-table tr.row-main:hover td { background:#f1f5f9; } " +
".theme-light table.mov-table tr.row-detail td { background:#f8fafc; } " +
".theme-light .mov-filter-input { background:#ffffff; border-color:#e2e8f0; color:#334155; } " +
".theme-light .mov-export-btn { background:#ffffff; border-color:#e2e8f0; color:#64748b; } " +
".theme-light .mov-export-btn:hover { border-color:#2563eb; color:#2563eb; } " +
// Preview mobile frame
".preview-mobile-frame { margin:0 auto !important; border:2px solid #4a9edd !important; border-radius:12px !important; overflow:hidden !important; box-shadow:0 0 40px rgba(74,158,221,0.25) !important; } " +
".preview-exit-btn { position:fixed !important; top:10px !important; right:12px !important; z-index:99999 !important; background:#e74c3c !important; border:none !important; border-radius:6px !important; padding:7px 16px !important; font-weight:bold !important; box-shadow:0 2px 12px rgba(0,0,0,0.4) !important; cursor:pointer !important; } " +
".preview-exit-btn .v-button-caption { color:#fff !important; font-size:12px !important; } " +
// Mobile overrides
".mobile-view.dash-wrapper { height:auto !important; overflow:visible !important; padding-bottom:24px; } " +
".mobile-view .dash-kpi-row { flex-direction:column; gap:8px; } " +
".mobile-view .dash-chart-row { flex-direction:column; } " +
".mobile-view .dash-kpi { min-width:unset; width:100%; box-sizing:border-box; } " +
".mobile-view .dash-kpi-sm { min-width:unset; width:100%; box-sizing:border-box; } " +
// Mobile — fontes maiores (tela menor exige texto mais legível)
".mobile-view .dash-kpi-label { font-size:15px !important; } " +
".mobile-view .dash-kpi-sm .dash-kpi-label { font-size:13px !important; } " +
".mobile-view .dash-kpi-value { font-size:22px !important; } " +
".mobile-view .dash-kpi-sm .dash-kpi-value { font-size:16px !important; } " +
".mobile-view .dash-chart-title { font-size:14px !important; } " +
".mobile-view .mov-filter-input { font-size:13px !important; } " +
".mobile-view .mov-export-btn { font-size:13px !important; } " +
".mobile-view table.mov-table td { font-size:13px !important; } " +
".mobile-view table.mov-table th { font-size:13px !important; } " +
// [adicionar aqui CSS mobile específico do painel]
"";
page.getStyles().add(style);
};
7.8 — enterMobilePreview / exitMobilePreview
Padrão real (confirmado em dashboard-contrato-desktop.xml): o preview de desenvolvedor não usa uma altura fixa tipo 700px/3000px como o _mobileRender real (seção 4.1 da skill vitruvio-criar-dashboard-mobile) — usa altura natural. base.setHeight(null) faz o ScriptWidget crescer para o tamanho do conteúdo, o Panel (contentPanel) enxerga o overflow real e rola sozinho. Isso é mais simples que qualquer técnica de "colapso via JS" e funciona porque o preview roda inteiramente dentro do navegador desktop — não há altura de tela de celular real para calcular.
Em enterMobilePreview:
function enterMobilePreview() {
engine.setGlobalVariable('isMobile', true);
// base com height null → cresce ao tamanho do conteúdo → Panel enxerga overflow e rola
try { base.setHeight(null); } catch(e) {}
var _rl = engine.getLayout('rootLayout');
if (_rl) {
var _rlComp = _rl.getRootComposition();
_rlComp.setWidth("375px");
_rlComp.addStyleName('preview-mobile-frame');
}
// ... (filterBar, botões de tema, btnDevPreview, btnExitPreview)
renderView();
}
Em exitMobilePreview:
function exitMobilePreview() {
engine.unsetGlobalVariable('isMobile');
// Restaura base para height 100% (modo desktop normal)
try { base.setHeight("100%"); } catch(e) {}
var _rl = engine.getLayout('rootLayout');
if (_rl) {
var _rlComp = _rl.getRootComposition();
_rlComp.setWidth("100%");
_rlComp.removeStyleName('preview-mobile-frame');
}
// ... (filterBar, botões de tema, btnDevPreview)
renderView();
}
Controle de botões de tema — obrigatório em ambas as funções:
Em enterMobilePreview, antes de renderView():
var _btnThMob = engine.getWidgetController('btnThemeMob');
if (_btnThMob) _btnThMob.getButton().setVisible(true);
var _btnThTb = engine.getWidgetController('btnTheme');
if (_btnThTb) _btnThTb.getButton().setVisible(false);
Em exitMobilePreview, antes de renderView():
var _btnThMob = engine.getWidgetController('btnThemeMob');
if (_btnThMob) _btnThMob.getButton().setVisible(false);
var _btnThTbExit = engine.getWidgetController('btnTheme');
if (_btnThTbExit) _btnThTbExit.getButton().setVisible(true);
renderHtml distingue preview de mobile real / desktop — só o preview usa altura natural; mobile real (_mobileRender) e desktop continuam setSizeFull():
function renderHtml(html) {
var comp = components.html(html);
// isPreviewMode: preview de dev (isMobile=true SEM _mobileRender). Mobile WebView real
// (_mobileRender=true) e desktop normal continuam usando setSizeFull() — só o preview
// precisa de altura natural para o Panel rolar corretamente dentro do navegador do dev.
var isPreviewMode = engine.isGlobalVariableSet('isMobile') && !engine.isGlobalVariableSet('_mobileRender');
if (isPreviewMode) {
try { base.setHeight(null); } catch(e) {}
comp.setWidth("100%");
} else {
comp.setSizeFull();
}
base.removeAllComponents();
base.addComponent(comp);
if (!isPreviewMode) { base.setExpandRatio(comp, 1.0); }
// JS após o render: aplica tema e, apenas dentro do frame de preview
// (.mobile-root-frame — NUNCA redimensionado, sua altura já foi fixada uma única vez pelo
// bloco _mobileRender do run() via window.screen.height), libera overflow:visible nos pais
// intermediários para o scroll externo funcionar sem "arrastar" a topBar junto.
Packages.com.vaadin.ui.UI.getCurrent().getPage().getJavaScript().execute(
"setTimeout(function(){" +
"if(typeof meuPainelApplyTheme==='function')meuPainelApplyTheme();" +
"var el=document.getElementById('meu-painel-main');" +
"if(el){" +
"var p=el,n=0;" +
"while(p&&n<30){p=p.parentElement;n++;" +
"if(!p){break;}" +
"if(p.classList&&p.classList.contains('mobile-root-frame')){break;}" +
"var cs=window.getComputedStyle(p);" +
"if(cs&&(cs.overflowY==='auto'||cs.overflowY==='scroll'||cs.overflowY==='hidden')){p.style.overflowY='visible';}" +
"if(cs&&(cs.overflow==='auto'||cs.overflow==='scroll'||cs.overflow==='hidden')){p.style.overflow='visible';}" +
"}" +
"}" +
"},400);"
);
}
Nota: as funções que geram HTML (
desenharDashboard/renderDashetc.) já checamisMobileCtx()/mobileClasscomisGlobalVariableSet('isMobile') || isGlobalVariableSet('_mobileRender')— odashboard-contrato-desktop.xmlreal adiciona uma terceira condição,_mobPreviewMode, que nenhuma das duas funções acima define (provavelmente um hook para um mecanismo externo). Inclua-a por segurança/compatibilidade, mas não é necessário defini-la para o preview de dev funcionar:engine.isGlobalVariableSet('isMobile') || engine.isGlobalVariableSet('_mobileRender') || engine.isGlobalVariableSet('_mobPreviewMode').
7.10 — Padrões obrigatórios para Mobile
Botão de tema (claro/escuro) — posição no mobile
No mobile, o btnThemeMob deve sempre aparecer junto à barra de filtros, no topo da filterBar — antes dos campos de filtro. Nunca posicionar o botão de tema isolado no rodapé da sidebar no mobile.
Estrutura correta da filterBar no mobile:
filterBar (VerticalLayout)
├── btnThemeMob ← PRIMEIRO, antes dos filtros
├── [campos de filtro]
├── [botão Pesquisar, se houver]
└── Label spacer (expandRatio=1)
No form XML, posicione btnThemeMob como primeiro filho da filterBar. No run(), ao detectar mobile, torne-o visível:
var _btnThemeMob = engine.getWidgetController('btnThemeMob');
if (_btnThemeMob) { _btnThemeMob.getButton().setVisible(true); }
Cards (KPIs e blocos) — layout vertical obrigatório no mobile
No mobile, todos os cards devem ser empilhados verticalmente, um embaixo do outro, ocupando 100% da largura:
- Nunca use
flex-direction: rowpara cards no mobile - Cada card ocupa
width: 100%; box-sizing: border-box - Se houver 3 cards de mesmo contexto (ex: 3 KPIs do mesmo grupo temático), renderize-os juntos em sequência vertical — eles preenchem o espaço de forma coesa
- Use
isMobileCtx()para condicionar o HTML gerado:
if (isMobileCtx()) {
// Cards empilhados, 100% largura
html += '<div style="display:flex;flex-direction:column;gap:8px;width:100%;">';
html += '<div class="dash-kpi" style="width:100%;">...</div>';
html += '<div class="dash-kpi" style="width:100%;">...</div>';
html += '<div class="dash-kpi" style="width:100%;">...</div>';
html += '</div>';
} else {
// Desktop: linha horizontal
html += '<div class="dash-kpi-row">';
html += '<div class="dash-kpi">...</div>';
html += '</div>';
}
Fontes maiores no mobile
Gere os tamanhos de fonte condicionalmente com isMobileCtx() sempre que o tamanho estiver em style="" inline — o CSS da classe .mobile-view já cobre os casos de classe:
var fsLabel = isMobileCtx() ? '15px' : '13px';
var fsValue = isMobileCtx() ? '22px' : '26px';
html += '<div class="dash-kpi-label" style="font-size:' + fsLabel + ';">' + label + '</div>';
html += '<div class="dash-kpi-value" style="font-size:' + fsValue + ';">' + value + '</div>';
| Elemento | Desktop | Mobile |
|---|---|---|
| Label de KPI | 13px | 15px |
| Valor de KPI | 26px | 22px |
| KPI sm label | 12px | 13px |
| KPI sm valor | 17px | 16px |
| Título de gráfico | 13px | 14px |
| Texto de tabela | 12px | 13px |
| Toolbar (input/btn) | 11px | 13px |
Listas e gráficos — alturas fixas e scroll obrigatórios no mobile
Regra crítica: no mobile, toda lista de cards e todo gráfico de barras com N variável de itens deve ter altura fixa e overflow-y:auto. Sem isso, o conteúdo cresce infinitamente e o frame não rola corretamente.
O CSS DEVE estar no addCss() — não apenas no bloco _mobileRender. Se ficar só em _mobileRender, o preview mobile do desenvolvedor não funciona (porque isMobile=true mas _mobileRender não está setado).
// ✅ CERTO — em addCss(), funciona em preview E mobile real
var addCss = function(page) {
var style =
// ...outros estilos...
".mobile-view.dash-wrapper { height:auto !important; overflow:visible !important; padding-bottom:24px; } " +
".mobile-view .mov-scroll { max-height:520px !important; overflow-y:auto !important; -webkit-overflow-scrolling:touch !important; overscroll-behavior:contain; touch-action:pan-y; } " +
".mobile-view .tec-mob-scroll { max-height:420px !important; overflow-y:auto !important; -webkit-overflow-scrolling:touch !important; overscroll-behavior:contain; touch-action:pan-y; } " +
".mobile-view .hbar-scroll { max-height:280px !important; overflow-y:auto !important; -webkit-overflow-scrolling:touch !important; } " +
// ...
Classes de scroll padrão:
| Classe | Altura | Uso |
|---|---|---|
.mov-scroll |
max 520px | Container da lista de movimentação (cresce com o conteúdo, scroll acima de 520px) |
.tec-mob-scroll |
max 420px | Container de lista de técnicos/ranking (cresce com o conteúdo, scroll acima de 420px) |
.hbar-scroll |
max 280px | Container de gráfico de barras horizontais (svgHBar) |
Padrão de uso nas funções de render mobile:
// ❌ ERRADO — sem scroll container, conteúdo cresce infinito
if (mobileClass) {
html += '<div id="lista-cards">';
for (var i = 0; i < itens.length; i++) {
html += '<div class="mov-card">...</div>';
}
html += '</div>';
}
// ✅ CERTO — sempre com scroll container de altura fixa
if (mobileClass) {
// Barra de pesquisa FORA do scroll (fica fixo no topo)
html += '<div class="mob-search-bar">...</div>';
// Lista DENTRO do scroll
html += '<div class="mov-scroll">';
for (var i = 0; i < itens.length; i++) {
html += '<div class="mov-card">...</div>';
}
if (itens.length === 0) html += '<div style="padding:28px;text-align:center;color:#556677;">Sem dados</div>';
html += '</div>';
}
Para lista de técnicos/ranking com botões de ordenação:
// O header com botões de sort fica FORA do scroll (não some ao rolar)
html += '<div class="dash-chart-box" style="margin-bottom:4px;">';
html += '<div style="display:flex;align-items:center;justify-content:space-between;">';
html += '<span>Técnicos</span>';
html += '<div><button onclick="tecSortCards(\'total\')">Total</button></div>';
html += '</div></div>';
// A lista fica DENTRO do scroll
html += '<div class="tec-mob-scroll">';
html += '<div id="tec-mob-list" data-sc="total" data-sd="-1">';
for (var i = 0; i < tecArr.length; i++) {
html += '<div class="tec-mob-card" ...>...</div>';
}
html += '</div>';
html += '</div>';
Para gráficos de barras horizontais (svgHBar):
// ❌ ERRADO — gráfico sem limitação de altura no mobile
html += '<div class="dash-chart-box">';
html += svgHBar(dados, '#4a9edd');
html += '</div>';
// ✅ CERTO — gráfico dentro de container com max-height + scroll
html += '<div class="dash-chart-box">';
html += '<div class="dash-chart-title" style="font-size:13px;">Título</div>';
html += '<div class="hbar-scroll">' + svgHBar(dados, '#4a9edd') + '</div>';
html += '</div>';
Lib e tema — sempre carregados ao entrar na tela
Obrigatório em todo painel:
- Sempre carregar
dashLib = libService.loadScript('dashboard_ia')norun() - Sempre aplicar o tema salvo nas preferências antes de qualquer render:
dashLib.loadPreferences(login)determina o tema inicial — nunca assumedarkpor padrão sem verificar - O tema deve ser aplicado em dois lugares:
- Vaadin (server-side):
dashLib.applyTheme(savedTheme, page, btnTheme) - HTML gerado (client-side): via
window.<panelId>ApplyTheme()registrado na página
- Vaadin (server-side):
// CORRETO — sempre verificar preferência salva
var prefs = dashLib.loadPreferences(login);
var savedTheme = prefs.dark_mode ? 'dark' : 'light'; // nunca assuma 'dark' sem verificar
engine.setGlobalVariable('theme', savedTheme);
dashLib.applyTheme(savedTheme, page, engine.getWidgetController('btnTheme').getButton());
7.11 — (Opcional) Assistente de IA "Pietro" (chatBar)
Só inclua este bloco se o usuário pedir explicitamente um assistente de IA/chat no dashboard.
Não é parte obrigatória do padrão de indicador — é um add-on visto em dashboard-contrato-desktop.xml
e dashboard-tecnicos-desktop.xml (ambos os painéis de referência o incluem, mas o padrão
"indicador dashboard" em si — KPIs, gráficos, movimentação — funciona plenamente sem ele).
Dependência externa: o chat roda sobre um script IA_chat_agente_ia que não é criado por
esta skill — precisa já existir no repositório (é uma lib de chat genérica, reutilizável por
qualquer painel). Se o usuário pedir o Pietro mas esse script não existir no repo, avise antes de
prosseguir.
Peças do padrão (nomes fixos — mantenha-os, o script IA_chat_agente_ia espera exatamente isso):
- Botão no topBar —
btnToggleChat, abre/fecha a sidebarchatBar:
<ButtonWidget id="btnToggleChat" caption="Pietro ◄" description="Abrir/fechar assistente Pietro">
<onClickScript language="JavaScript">
<![CDATA[
function run() {
var cb = engine.getLayout('chatBar');
if (!cb) return;
var comp = cb.getRootComposition();
var visible = comp.isVisible();
comp.setVisible(!visible);
var btn = engine.getWidgetController('btnToggleChat').getButton();
btn.setCaption(visible ? 'Pietro ◄' : '► Pietro');
}
]]>
</onClickScript>
</ButtonWidget>
- Sidebar
chatBar(irmã demainArea, dentro derootLayout, 320px, oculta por padrão) — header com título + botão fechar, área de mensagens rolável, barra de input:
<VerticalLayout id="chatBar" width="320px" height="100%" visible="false" spacing="false" margin="false" styleName="dash-chatbar-dark">
<HorizontalLayout id="chatBarHeader" width="100%" spacing="false" margin="true">
<Label contentMode="HTML" width="100%" expandRatio="1">
<value><![CDATA[<span style="font-weight:bold;font-size:13px;color:#f78259;">🤖 Pietro</span>]]></value>
</Label>
<ButtonWidget id="btnFecharChat" caption="✕" description="Fechar Pietro" visible="false">
<onClickScript language="JavaScript">
<![CDATA[
function run() {
engine.getLayout('chatBar').getRootComposition().setVisible(false);
var btn = engine.getWidgetController('btnToggleChat');
if (btn) btn.getButton().setCaption('Pietro ◄');
}
]]>
</onClickScript>
</ButtonWidget>
</HorizontalLayout>
<VerticalLayout id="caScope" width="100%" height="100%" expandRatio="1" spacing="false" margin="false">
<VerticalLayout id="caSurface" width="100%" height="100%" expandRatio="1" spacing="false" margin="false">
<ScrollPanel id="caPanel" width="100%" height="100%" expandRatio="1">
<VerticalLayout id="caMsgs" width="100%" margin="false" spacing="false" />
</ScrollPanel>
</VerticalLayout>
</VerticalLayout>
<HorizontalLayout id="caInputBar" width="100%" spacing="true" margin="true">
<TextArea id="caTexto" width="100%" expandRatio="1" type="string" rows="2" />
<ButtonWidget id="btnChatEnviar" caption="Enviar" defaultIcon="SEND" modifierKey="CTRL" keyCode="ENTER" align="MIDDLE_CENTER">
<onClickScript language="JavaScript">
<![CDATA[
importClass(Packages.com.vaadin.ui.UI);
function run() {
var ca = engine.getGlobalVariable('chatAgente');
if (ca) ca.enviar();
UI.getCurrent().getPage().getJavaScript().execute(
"setTimeout(function(){var ta=document.querySelector('textarea');" +
"if(ta){ta.value='';ta.dispatchEvent(new Event('input',{bubbles:true}));ta.focus();}},0);"
);
}
]]>
</onClickScript>
</ButtonWidget>
</HorizontalLayout>
</VerticalLayout>
<!-- Exigida pelo script IA_chat_agente_ia; o conteúdo real fica na sidebar chatBar -->
<WindowLayout id="wndChatAgente" windowHeight="200px" windowWidth="300px" windowResizable="false" windowClosable="false" windowModal="false">
<VerticalLayout width="100%" height="100%" />
</WindowLayout>
- FAB mobile — no
_mobileRender/preview, a sidebar web some e um botão flutuante abre o chat nativo do app (React Native) reaproveitando o mesmo contexto:
<!-- some no desktop; fica visible=true dentro do bloco _mobileRender/enterMobilePreview -->
<ButtonWidget id="btnChatFabMobile" caption="💬" description="Abrir assistente Pietro" visible="false">
<onClickScript language="JavaScript">
<![CDATA[
function run() {
var chatConfig = engine.getGlobalVariable('nativeChatConfig') || {};
var st = engine.getGlobalVariable('chatAgenteState');
var dadosContexto = (st && st.config && st.config.dados_contexto) ? st.config.dados_contexto : null;
// ... dispara o chat nativo (postMessage) com chatConfig + dadosContexto
}
]]>
</onClickScript>
</ButtonWidget>
No bloco _mobileRender do run() e em enterMobilePreview/exitMobilePreview: oculte
btnToggleChat e mostre btnChatFabMobile (e vice-versa ao sair do preview) — mesmo tratamento
dado a btnTheme/btnThemeMob.
Os ícones do Pietro são todos astrais (
🤖U+1F916,💬U+128172,Pietro ◄…). Adicione as chaves aoMAPdoico()(seção 7.2.2) — ex:'chat_fab': { pg: '💬', ora: 'IA' },'pietro_abrir'/'pietro_fechar'— e, quandoisOracle, reescreva os captions debtnToggleChat,btnFecharChatebtnChatFabMobilenorun()(junte-os aoaplicarIconesOracleNaTopBar()).
- Inicialização do agente — no
<afterFormRenderScript>da raiz do form (roda depois dorun(), garantindo quechatBarjá tem o tema certo aplicado):
function run() {
try {
var dashLib = libService.loadScript('dashboard_ia');
var login = String(engine.getLoggedUser().getLogin());
var prefs = dashLib.loadPreferences(login);
var savedTheme = prefs.dark_mode ? 'dark' : 'light';
var chatAgente = libService.loadScript('IA_chat_agente_ia');
engine.setGlobalVariable('chatAgente', chatAgente);
// Guardado à parte (não só passado pro abrir()) pro FAB mobile reaproveitar
// exatamente o mesmo contexto/prompt do chat web, sem duplicar o texto.
var chatConfig = {
contexto: '<panel-key>',
titulo: 'Pietro',
nomeAgente: 'Pietro',
tema: savedTheme,
saudacao: 'Olá! Posso ajudar com análise dos dados deste dashboard.',
prompt: {
persona: 'Você é Pietro, assistente de análise de dados do <Nome do Painel>.',
tarefa: 'Ajude o usuário a interpretar os KPIs, gráficos e a movimentação exibidos no painel agora. Use SEMPRE os dados de "secoes" em dados_contexto como sua única fonte de verdade.',
regras: 'Responda apenas com os dados de "secoes" da view ATUAL (dados_contexto.view) — nunca misture com outra view, outro período/filtro, ou mensagens anteriores. Se faltar dado, diga que não está disponível no contexto atual. Nunca estime ou invente valor.',
saida: 'Seja direto e objetivo. Ao citar um dado, use o mesmo título de seção exibido na tela.'
}
};
engine.setGlobalVariable('nativeChatConfig', chatConfig);
chatAgente.abrir(chatConfig);
chatAgente.fechar();
} catch(e) {}
}
- Convenção de contexto ao vivo — no ScriptWidget, um helper
atualizarContextoIA(view, filtros, secoes)substituidados_contextopor inteiro a cada render (nunca mescla — reflete só a view atual):
function atualizarContextoIA(view, filtros, secoes) {
var st = engine.getGlobalVariable('chatAgenteState');
if (!st || !st.config) return;
st.config.dados_contexto = { view: view, filtros: filtros, secoes: secoes };
}
Chame no fim de cada função de view (desenharDashboard/desenharMovimentacao etc.), logo
antes do renderHtml(html). secoes é um array de { titulo, tipo, dados } — o titulo deve
ser exatamente o mesmo texto exibido na tela (mesmo título do KPI/gráfico/card), para o agente
nunca divergir do que o usuário vê:
atualizarContextoIA('dashboard', { ano: ano, mes: mes }, [
{ titulo: 'Total de Contratos', tipo: 'kpi', dados: { valor: total, valor_formatado: formatBRL(total) } },
{ titulo: 'Evolução Mês a Mês — ' + ano, tipo: 'grafico_linha', dados: dadosMeses },
{ titulo: 'Movimentação', tipo: 'tabela', dados: { total: linhas.length, itens: linhasCtxIA } }
]);
renderHtml(html);
- CSS — adicione ao
addCss():
".dash-chatbar-dark { background:#0d1921 !important; border-left:1px solid #1a2a38 !important; } " +
".dash-chatbar-light { background:#f1f5f9 !important; border-left:1px solid #e2e8f0 !important; } " +
".dash-chat-fab { position:fixed !important; right:16px !important; bottom:20px !important; width:56px !important; height:56px !important; min-width:0 !important; border-radius:50% !important; overflow:hidden !important; border:none !important; background:#4a9edd !important; box-shadow:0 4px 16px rgba(0,0,0,.4) !important; z-index:9000 !important; padding:0 !important; } " +
".dash-chat-fab .v-button-wrap { border-radius:50% !important; background:transparent !important; } " +
".dash-chat-fab .v-button-caption { font-size:24px !important; line-height:56px !important; color:#fff !important; } "
- Tema — sincronize
chatBar/caScopecomdash-chatbar-dark/dash-chatbar-lightnos mesmos pontos ondefilterBarjá é sincronizado:doToggleTheme,renderView, e noenterMobilePreview/exitMobilePreview.
7.9 — vitruvio.json
Adicione a entrada do painel:
{
"key": "<panel-key>",
"name": "<Nome do Painel>",
"description": "<descrição>",
"category": "<Categoria>",
"displayOrder": <próximo disponível>,
"showInPresentation": false,
"openInNewWindow": false,
"showInMobileList": false,
"displayTimeInSeconds": 0,
"allowedGroups": ["<grupo>"],
"allowedUsers": [],
"forms": {
"desktop": "panels/<panel-key>/<panel-key>-desktop.xml"
}
}
FASE 8 — Arquivo de contexto do painel (OBRIGATÓRIO)
Após criar ou modificar o painel, sempre gere o arquivo panels/<panel-key>/CONTEXT.md.
Este arquivo permite retomar o trabalho em uma nova janela de contexto sem precisar reler todo o XML.
Formato obrigatório do CONTEXT.md:
# Contexto: <Nome do Painel>
> Última atualização: <data>
## Identificação
- **Key:** `<panel-key>`
- **formKey:** `<form-key>`
- **Arquivo desktop:** `panels/<panel-key>/<panel-key>-desktop.xml`
- **Arquivo mobile:** `panels/<panel-key>/<panel-key>-mobile.xml` _(se existir)_
- **Lib de base:** `dashboard_ia` (tema, preferências, CSS compartilhado)
## Propósito
<Descrição do que este painel mostra e para quem>
## Datasource
- **Datasource:** `vitruvio_producao`
- **Banco:** PostgreSQL / Oracle
- **Tabelas principais:**
- `nome_tabela` — <o que representa>
- `nome_tabela2` — <o que representa>
## Filtros (sidebar)
| ID do Campo | Tipo | Descrição | Comportamento |
|---|---|---|---|
| `cmbAno` | DBComboBox | Ano | Auto-reload ao mudar |
| `cmbMes` | ComboBox | Mês (1–12) | Auto-reload ao mudar |
| `dtIni` | DateField | Data inicial | Requer botão Pesquisar |
| ... | ... | ... | ... |
## KPIs
| Rótulo | SQL / Cálculo | Cor |
|---|---|---|
| Total | `SUM(valor)` da tabela X | `#4a9edd` |
| ... | ... | ... |
## Gráficos no Dashboard
| # | Tipo | Título | SQL / Fonte | Disposição desktop |
|---|---|---|---|---|
| 1 | svgLineChart | Evolução Mensal | `SELECT mes, SUM(valor)...` | Linha completa |
| 2 | svgDonut | Proporção A vs B | Calculado dos dados já carregados | Esquerda |
| 3 | svgHBar | Ranking por Cliente | Agrupamento da query principal | Direita |
## Movimentação
### Query principal
```sql
-- Cole aqui o SQL principal da movimentação
SELECT ...
FROM ...
WHERE ...
Colunas da tabela
| # | Cabeçalho | Campo (data-v) |
Tipo | Sortável | No filtro global |
|---|---|---|---|---|---|
| 0 | Código | CODIGO |
texto | sim | sim |
| ... | ... | ... | ... | ... | ... |
Drill-down
- Campos adicionais:
- Sub-tabela: <descrição ou "não tem">
- Dados: carregados junto com a query principal / query separada no servidor
Filtros client-side da movimentação
- Campo de busca global:
mov-fi→ busca em colunas [0, 1, 2] - Select de status:
mov-stf→ filtra coluna [3] - Select de tipo:
mov-sef→ filtra coluna [4]
Exportação CSV
- Arquivo:
<nome>.csv - Colunas:
Funções JavaScript principais
| Função | Onde | O que faz |
|---|---|---|
run() |
initScript raiz | Inicializa CSS, tema, detecta mobile, detecta Oracle, registra globals |
ico(chave) |
initScript raiz | Devolve o glifo do ícone conforme isOracle (fallback BMP/ASCII) |
aplicarIconesOracleNaTopBar() |
initScript raiz | Reescreve os captions estáticos do topBar quando Oracle |
btnPesquisar onClick |
filterBar | Aplica filtros (renderView) e recolhe a filterBar (estado inicial) |
doToggleTheme() |
initScript raiz | Alterna dark/light, salva preferência, re-renderiza |
renderView() |
ScriptWidget | Dispatcher: chama renderDash() ou renderMov() |
renderDash() |
ScriptWidget | Gera HTML do dashboard (KPIs + gráficos) |
renderMov() |
ScriptWidget | Gera HTML da tabela de movimentação |
renderHtml(html) |
ScriptWidget | Injeta HTML no Vaadin, aplica tema, colapsa no mobile |
svgLineChart(...) |
ScriptWidget | Gera SVG do gráfico de linha |
svgDonut(...) |
ScriptWidget | Gera SVG do donut |
svgHBar(...) |
ScriptWidget | Gera SVG de barras horizontais |
enterMobilePreview() |
initScript raiz | Ativa preview mobile 375px no desktop |
exitMobilePreview() |
initScript raiz | Sai do preview mobile |
window.movSort(c,t) |
JS client-side | Ordena tabela movimentação por coluna c |
window.movFilter() |
JS client-side | Filtra linhas da movimentação |
window.movExportCSV() |
JS client-side | Exporta CSV da movimentação |
IDs de layout e campo (para mobile / forceRender)
Layouts: rootLayout, topBar, mainArea, filterBar, contentPanel, panelRoot
Campos: <lista dos IDs de todos os campos de filtro>
Variáveis globais do engine
| Variável | Tipo | O que armazena |
|---|---|---|
dashLib |
objeto | Biblioteca dashboard_ia |
userLogin |
string | Login do usuário logado |
theme |
'dark' ou 'light' |
Tema atual |
currentView |
'dashboard' ou 'movimentacao' |
View ativa |
isMobile |
boolean | true quando em modo mobile |
isDeveloper |
boolean | true se o usuário é vi_developer |
isOracle |
boolean | true quando o datasource é Oracle (define o fallback de ícones) |
ico |
function | Helper ico(chave) — glifo do ícone conforme o banco (seção 7.2.2) |
renderView |
function | Dispatcher principal |
doToggleTheme |
function | Toggle de tema |
enterMobilePreview |
function | Entra no preview mobile |
exitMobilePreview |
function | Sai do preview mobile |
Paleta de cores aplicada
| Uso | Cor dark | Cor light |
|---|---|---|
| Fundo wrapper | #0f1923 |
#f0f2f5 |
| Card / box | #1a2733 |
#ffffff |
| Borda | #243447 |
#e2e8f0 |
| [Cor de destaque 1 do painel] | #4a9edd (azul) |
#2563eb |
| [Cor de destaque 2 do painel] | #e67e22 (laranja) |
#d97706 |
Histórico de modificações
| Data | O que foi feito |
|---|---|
| Criação inicial do painel |
Pendências / TODO
- <item pendente 1>
- <item pendente 2>
Versão mobile
- Status: (não criada / em andamento / concluída)
- Arquivo:
panels/<panel-key>/<panel-key>-mobile.xml - Para criar: invoque a skill vitruvio-criar-dashboard-mobile com
panels/<panel-key>/<panel-key>-desktop.xml
---
## FASE 9 — Atualizar o CONTEXT.md em toda modificação
**Sempre que fizer qualquer alteração no painel (bugfix, novo gráfico, novo filtro, novo campo na movimentação, adaptação para mobile), atualize o CONTEXT.md:**
1. Adicione uma linha no **Histórico de modificações** com a data e o que foi feito
2. Atualize as seções afetadas (filtros, funções, colunas, etc.)
3. Marque pendências como concluídas ou adicione novas
O objetivo é que qualquer pessoa (ou você mesmo em outra sessão) possa ler o CONTEXT.md e entender completamente o painel sem precisar abrir o XML.
---
## FASE 10 — Oferecer criação do mobile
Após gerar o painel e o CONTEXT.md, sempre pergunte:
O painel desktop foi criado com sucesso!
Deseja criar a versão mobile agora? Vou invocar a skill vitruvio-criar-dashboard-mobile com panels//-desktop.xml.
O processo de criação do mobile vai:
- Criar panels//-mobile.xml
- Adaptar o
<panel-key>-desktop.xmlpara detectar o contexto mobile - Adicionar sidebar de filtros colapsável no mobile
- Converter gráficos e tabelas para layout mobile (cards empilhados)
- Adicionar suporte a preview mobile para desenvolvedores
Responda SIM para iniciar agora, ou NÃO para fazer depois.
Se o usuário confirmar, invoque a skill **vitruvio-criar-dashboard-mobile** passando `panels/<panel-key>/<panel-key>-desktop.xml`.
---
## Armadilhas conhecidas — não repita estes erros
### SQL sem validação de tipo
```javascript
// ERRADO — Number(null) = 0, pode gerar query errada
var ano = Number(engine.getField('cmbAno').getValue());
// CERTO — verificar antes
var anoStr = engine.getField('cmbAno').getValue();
if (!anoStr) { renderHtml('<div class="dash-aviso">Selecione o ano.</div>'); return; }
var ano = Number(anoStr);
Java Boolean com strict equality
// ERRADO
if (_wasVisible !== false) { ... } // Java Boolean.FALSE !== JS false
// CERTO
if (_wasVisible == true) { ... } // loose equality funciona
if (!!_wasVisible) { ... } // coerção funciona
setExpandRatio antes de addComponent
// ERRADO
base.setExpandRatio(comp, 1.0); // comp não é filho ainda
base.addComponent(comp);
// CERTO
base.addComponent(comp);
base.setExpandRatio(comp, 1.0); // sempre APÓS addComponent
Iteração de resultado null do banco
// ERRADO
var rows = banco.query(sql, {}); rows.each(...); // NullPointerException se sem resultado
// CERTO
var rows = banco.query(sql, {});
if (rows) { rows.each(function(row) { ... }); }
CSS inline sobrescreve class
// Se o elemento tem style="font-size:12px", !important não sobrescreve
// Solução: gerar o tamanho diretamente no HTML conforme o contexto
var fs = isMobileCtx() ? '14px' : '12px';
html += '<div style="font-size:' + fs + ';">...</div>';
movFilter com colunas erradas
// Sempre validar que o índice de coluna existe antes de acessar
var t0 = (m.cells[0] ? m.cells[0].getAttribute('data-v') || '' : '').toLowerCase();
Checklist de entrega
Antes de declarar pronto:
- Leu os painéis de referência (
dashboard-contratosedashboard-tecnicos) antes de escrever - Não inventou nomes de tabelas ou campos — tudo validado com o usuário
<panel-key>-desktop.xmlcriado com estrutura correta: rootLayout → topBar + mainArea (filterBar + contentPanel)topBartem: btnToggleFiltros, btnDash, btnMov, spacer, btnDevPreview, btnExitPreview, btnThemefilterBartem:btnThemeMobcomo primeiro filho (antes dos filtros), campos de filtro, spacer (expandRatio=1)btnThemeMobaparece no topo da filterBar no mobile — não no rodapéenterMobilePreviewusabase.setHeight(null)(altura natural, cresce ao conteúdo) — não copia a altura fixa700pxdo_mobileRenderreal, que só faz sentido para a tela do celularexitMobilePreviewrestaurabase.setHeight("100%")- No mobile (
_mobileRendereenterMobilePreview):btnThemedo topBar oculto viasetVisible(false)— evita dois botões de tema simultâneos - No
exitMobilePreview:btnThemeMoboculto ebtnThemerestaurado viasetVisible(true) btnThemeMobebtnThemechamamdoToggleTheme(via global var)doToggleThemeatualiza caption de ambos os botõesrun()carregadashLib, aplica CSS, detecta mobile, verifica vi_developer, aplica tema salvo das preferências (dashLib.loadPreferences) — nunca assume dark sem verificarrenderView()registrado como global, chamado por todos os filtros e botões de navrenderView()marca botão ativo comdash-nav-activerenderHtml()distingue preview (isMobilesem_mobileRender) de mobile real/desktop: só o preview usabase.setHeight(null)+comp.setWidth("100%"); os outros dois usamcomp.setSizeFull()+base.setExpandRatio(comp, 1.0)- JS client-side registrado:
movSort,movFilter,movExportCSV - Movimentação tem toolbar com campo de busca, contador de registros e botão CSV
filterBarcomeça fechado (setVisible(false))- Se há filtro sem auto-reload:
btnPesquisarexiste e, ao clicar, aplica os filtros e recolhe a filterBar (mesmo caption de "fechado" que orun()e obtnToggleFiltrosusam) — seção 7.2.1 run()detecta o banco combanco.isOracle()e gravaisOracle+icocomo globals- Quando Oracle:
aplicarIconesOracleNaTopBar()reescreve os captions estáticos do topBar - Nenhum emoji/ícone astral escrito direto em caption ou HTML — tudo passa por
ico(chave) - Tema inicial lido das preferências do usuário (
dashLib.loadPreferences) enterMobilePreview/exitMobilePreviewregistrados como globals- Não usou ES6+ (sem const, let, =>, template literals, class)
vitruvio.jsonatualizado com a entrada do painelCONTEXT.mdcriado empanels/<panel-key>/CONTEXT.mdcom todas as seções preenchidas- No mobile: cards sempre empilhados verticalmente (
flex-direction:column), cada um comwidth:100% - No mobile: fontes maiores via
isMobileCtx()no HTML inline ou via CSS.mobile-view - No mobile:
btnThemeMobposicionado antes dos campos de filtro na filterBar - Tema sempre verificado via
dashLib.loadPreferencesao entrar — nunca assumir dark por padrão - CSS de scroll mobile (
.mov-scroll,.tec-mob-scroll,.hbar-scroll) está emaddCss()— não apenas no bloco_mobileRender - No mobile: listas de cards (movimentação) envolvidas em
<div class="mov-scroll"> - No mobile: listas de técnicos/ranking envolvidas em
<div class="tec-mob-scroll"> - No mobile: gráficos svgHBar envolvidos em
<div class="hbar-scroll"> - Perguntou ao usuário sobre criação da versão mobile