Files
jogos_matheus/.claude/skills/vitruvio-criar-dashboard-desktop/SKILL.md
T
2026-09-23 12:29:08 -03:00

1615 lines
73 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
name: vitruvio-criar-dashboard-desktop
description: >
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.xml` depois, 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.xml` completo 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.xml`
- `dashboard-contrato-desktop.xml` é a versão mais recente/evoluída do padrão (ex: usa a global
`_mobPreviewMode` que `dashboard-tecnicos-desktop.xml` ainda não tem) — em caso de divergência
entre os dois, prefira o padrão do `dashboard-contrato-desktop.xml`.
- `.claude/skills/vitruvio-criar-dashboard-desktop/dashboard_ia.js` documenta a API do `dashLib`
(`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 um `scripts/dashboard_ia.js` no repositório de destino.** O
painel gerado apenas chama `libService.loadScript('dashboard_ia')`; a lib em si é externa ao
escopo desta skill.
### JavaScript — ES5 Rhino somente
```javascript
// 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
```bash
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:
1. Leia o arquivo XML completo.
2. Identifique o que já existe: `formKey`, layouts, campos, queries SQL, funções JS.
3. 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.).
4. Documente o delta antes de fazer perguntas ao usuário.
5. 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()` (no `run()`) 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) ou `DESCRIBE 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:
```javascript
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.
```xml
<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:
```javascript
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()`:
```javascript
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)
```javascript
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:
```javascript
// 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:**
```html
<th data-l="Cliente" onclick="movSort(2,false)" style="cursor:pointer;" data-v="">
Cliente
</th>
```
**Células com data-v para sort e filter:**
```html
<td data-v="VALOR_SORT_NUMERICO">VALOR_EXIBIDO</td>
```
**Linha base + linha detalhe (par de TRs):**
```html
<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:**
```javascript
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 `⬇ CSV` em PostgreSQL e `CSV` em 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)`:
```javascript
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`:
```javascript
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`:
```javascript
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()`:
```javascript
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()`:
```javascript
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()`:
```javascript
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`/`renderDash` etc.) já checam
> `isMobileCtx()`/`mobileClass` com `isGlobalVariableSet('isMobile') || isGlobalVariableSet('_mobileRender')`
> — o `dashboard-contrato-desktop.xml` real 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:
```javascript
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: row` para 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:
```javascript
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:
```javascript
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).
```javascript
// ✅ 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:**
```javascript
// ❌ 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:
```javascript
// 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):
```javascript
// ❌ 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:**
1. Sempre carregar `dashLib = libService.loadScript('dashboard_ia')` no `run()`
2. Sempre aplicar o tema salvo nas preferências antes de qualquer render: `dashLib.loadPreferences(login)` determina o tema inicial — nunca assume `dark` por padrão sem verificar
3. 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
```javascript
// 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):
1. **Botão no topBar** — `btnToggleChat`, abre/fecha a sidebar `chatBar`:
```xml
<ButtonWidget id="btnToggleChat" caption="Pietro &#x25C4;" 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>
```
2. **Sidebar `chatBar`** (irmã de `mainArea`, dentro de `rootLayout`, 320px, oculta por padrão) —
header com título + botão fechar, área de mensagens rolável, barra de input:
```xml
<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;">&#129302; Pietro</span>]]></value>
</Label>
<ButtonWidget id="btnFecharChat" caption="&#x2715;" 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>
```
3. **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:
```xml
<!-- some no desktop; fica visible=true dentro do bloco _mobileRender/enterMobilePreview -->
<ButtonWidget id="btnChatFabMobile" caption="&#128172;" 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 ao `MAP` do `ico()` (seção 7.2.2) — ex: `'chat_fab': { pg: '💬', ora: 'IA' }`,
> `'pietro_abrir'/'pietro_fechar'` — e, quando `isOracle`, reescreva os captions de `btnToggleChat`,
> `btnFecharChat` e `btnChatFabMobile` no `run()` (junte-os ao `aplicarIconesOracleNaTopBar()`).
4. **Inicialização do agente** — no `<afterFormRenderScript>` da raiz do form (roda **depois** do
`run()`, garantindo que `chatBar` já tem o tema certo aplicado):
```javascript
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) {}
}
```
5. **Convenção de contexto ao vivo** — no ScriptWidget, um helper `atualizarContextoIA(view, filtros, secoes)`
substitui `dados_contexto` por inteiro a cada render (nunca mescla — reflete só a view atual):
```javascript
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ê:
```javascript
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);
```
6. **CSS** — adicione ao `addCss()`:
```javascript
".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; } "
```
7. **Tema** — sincronize `chatBar`/`caScope` com `dash-chatbar-dark`/`dash-chatbar-light` nos
mesmos pontos onde `filterBar` já é sincronizado: `doToggleTheme`, `renderView`, e no
`enterMobilePreview`/`exitMobilePreview`.
---
### 7.9 — vitruvio.json
Adicione a entrada do painel:
```json
{
"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:**
```markdown
# 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: <lista>
- 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: <lista na ordem>
## 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 |
|---|---|
| <data> | 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/<panel-key>/<panel-key>-desktop.xml.
O processo de criação do mobile vai:
- Criar panels/<panel-key>/<panel-key>-mobile.xml
- Adaptar o `<panel-key>-desktop.xml` para 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
```javascript
// 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
```javascript
// 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
```javascript
// 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
```javascript
// 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
```javascript
// 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-contratos` e `dashboard-tecnicos`) antes de escrever
- [ ] Não inventou nomes de tabelas ou campos — tudo validado com o usuário
- [ ] `<panel-key>-desktop.xml` criado com estrutura correta: rootLayout → topBar + mainArea (filterBar + contentPanel)
- [ ] `topBar` tem: btnToggleFiltros, btnDash, btnMov, spacer, btnDevPreview, btnExitPreview, btnTheme
- [ ] `filterBar` tem: `btnThemeMob` como **primeiro filho** (antes dos filtros), campos de filtro, spacer (expandRatio=1)
- [ ] `btnThemeMob` aparece no topo da filterBar no mobile — não no rodapé
- [ ] `enterMobilePreview` usa `base.setHeight(null)` (altura natural, cresce ao conteúdo) — não copia a altura fixa `700px` do `_mobileRender` real, que só faz sentido para a tela do celular
- [ ] `exitMobilePreview` restaura `base.setHeight("100%")`
- [ ] No mobile (`_mobileRender` e `enterMobilePreview`): `btnTheme` do topBar oculto via `setVisible(false)` — evita dois botões de tema simultâneos
- [ ] No `exitMobilePreview`: `btnThemeMob` oculto e `btnTheme` restaurado via `setVisible(true)`
- [ ] `btnThemeMob` e `btnTheme` chamam `doToggleTheme` (via global var)
- [ ] `doToggleTheme` atualiza caption de ambos os botões
- [ ] `run()` carrega `dashLib`, aplica CSS, detecta mobile, verifica vi_developer, aplica tema salvo das preferências (`dashLib.loadPreferences`) — nunca assume dark sem verificar
- [ ] `renderView()` registrado como global, chamado por todos os filtros e botões de nav
- [ ] `renderView()` marca botão ativo com `dash-nav-active`
- [ ] `renderHtml()` distingue preview (`isMobile` sem `_mobileRender`) de mobile real/desktop: só o preview usa `base.setHeight(null)` + `comp.setWidth("100%")`; os outros dois usam `comp.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
- [ ] `filterBar` começa **fechado** (`setVisible(false)`)
- [ ] Se há filtro sem auto-reload: `btnPesquisar` existe e, ao clicar, aplica os filtros **e recolhe a filterBar** (mesmo caption de "fechado" que o `run()` e o `btnToggleFiltros` usam) — seção 7.2.1
- [ ] `run()` detecta o banco com `banco.isOracle()` e grava `isOracle` + `ico` como 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` / `exitMobilePreview` registrados como globals
- [ ] Não usou ES6+ (sem const, let, =>, template literals, class)
- [ ] `vitruvio.json` atualizado com a entrada do painel
- [ ] `CONTEXT.md` criado em `panels/<panel-key>/CONTEXT.md` com todas as seções preenchidas
- [ ] No mobile: cards sempre empilhados verticalmente (`flex-direction:column`), cada um com `width:100%`
- [ ] No mobile: fontes maiores via `isMobileCtx()` no HTML inline ou via CSS `.mobile-view`
- [ ] No mobile: `btnThemeMob` posicionado antes dos campos de filtro na filterBar
- [ ] Tema sempre verificado via `dashLib.loadPreferences` ao entrar — nunca assumir dark por padrão
- [ ] CSS de scroll mobile (`.mov-scroll`, `.tec-mob-scroll`, `.hbar-scroll`) está em `addCss()` — **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