--- 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//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 `-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 `-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 `` 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.ApplyTheme=function(){" + "var w=document.getElementById('-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 ``` 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 `` 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('-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 Cliente ``` **Células com data-v para sort e filter:** ```html VALOR_EXIBIDO ``` **Linha base + linha detalhe (par de TRs):** ```html ... ... conteúdo do drill-down ... ``` **Toolbar da movimentação:** ```javascript html += '
'; html += ''; // Selects de filtro rápido (se houver): html += ''; html += '' + linhas.length + ' reg.'; html += ''; html += '
'; ``` > `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 += '
'; html += '
...
'; html += '
...
'; html += '
...
'; html += '
'; } else { // Desktop: linha horizontal html += '
'; html += '
...
'; html += '
'; } ``` #### 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 += '
' + label + '
'; html += '
' + value + '
'; ``` | 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 += '
'; for (var i = 0; i < itens.length; i++) { html += '
...
'; } html += '
'; } // ✅ CERTO — sempre com scroll container de altura fixa if (mobileClass) { // Barra de pesquisa FORA do scroll (fica fixo no topo) html += ''; // Lista DENTRO do scroll html += '
'; for (var i = 0; i < itens.length; i++) { html += '
...
'; } if (itens.length === 0) html += '
Sem dados
'; html += '
'; } ``` 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 += '
'; html += '
'; html += 'Técnicos'; html += '
'; html += '
'; // A lista fica DENTRO do scroll html += '
'; html += '
'; for (var i = 0; i < tecArr.length; i++) { html += '
...
'; } html += '
'; html += '
'; ``` Para gráficos de barras horizontais (svgHBar): ```javascript // ❌ ERRADO — gráfico sem limitação de altura no mobile html += '
'; html += svgHBar(dados, '#4a9edd'); html += '
'; // ✅ CERTO — gráfico dentro de container com max-height + scroll html += '
'; html += '
Título
'; html += '
' + svgHBar(dados, '#4a9edd') + '
'; html += '
'; ``` #### 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.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 ``` 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