1615 lines
73 KiB
Markdown
1615 lines
73 KiB
Markdown
---
|
||
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 ◄" 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;">🤖 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>
|
||
```
|
||
|
||
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="💬" 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
|