785 lines
39 KiB
Markdown
785 lines
39 KiB
Markdown
---
|
||
name: vitruvio-criar-dashboard-mobile
|
||
description: >
|
||
Use when creating the MOBILE version of an existing Vitruvio indicator dashboard panel — a
|
||
DesktopPanel shell (`<key>-mobile.xml`) that delegates to `<key>-desktop.xml`, plus the mobile
|
||
detection/adaptation added to the desktop form: collapsible filter sidebar, stacked cards,
|
||
dark/light theme toggle, and developer mobile preview. Triggers: "criar mobile do dashboard",
|
||
"adicionar versão mobile ao indicador", "mobile preview do painel indicador",
|
||
"CriarMobileIndicador". Called by vitruvio-criar-indicador-dashboard for the mobile part; can
|
||
also be invoked directly with the target `<key>-desktop.xml` path. For the desktop part use
|
||
vitruvio-criar-dashboard-desktop.
|
||
---
|
||
|
||
# Criar Mobile para Painel Indicador
|
||
|
||
> Todas as mensagens ao usuário devem ser em Português.
|
||
|
||
Você está criando a versão mobile de um painel Vitruvio existente. O padrão usado neste repositório é o **DesktopPanel delegado**: o `<key>-mobile.xml` é um shell mínimo que delega toda a renderização ao `<key>-desktop.xml`, que detecta o contexto mobile e adapta o layout sozinho.
|
||
|
||
Receba o caminho do `<key>-desktop.xml` alvo como argumento (ex: `panels/meu-painel/meu-painel-desktop.xml`).
|
||
|
||
---
|
||
|
||
## LEITURA OBRIGATÓRIA AO INICIAR — CONTEXT.md
|
||
|
||
**Esta é a primeira coisa a fazer, antes de abrir qualquer outro arquivo.**
|
||
|
||
Derive o diretório do painel a partir do argumento e leia:
|
||
|
||
```
|
||
panels/<panel-key>/CONTEXT.md
|
||
```
|
||
|
||
**Se o CONTEXT.md existir:**
|
||
- Leia-o completo primeiro. Ele contém os IDs reais dos campos, layouts, funções JS e o estado atual do painel — tudo que você precisa para criar o mobile corretamente sem reler o XML inteiro.
|
||
- Use a seção **"IDs de layout e campo"** para preencher `forceLayoutsRender` e `forceFieldsRender` no `<key>-mobile.xml`.
|
||
- Use a seção **"Funções JavaScript principais"** para entender o que já existe e evitar duplicar.
|
||
- Informe ao usuário: _"Li o contexto do painel. Vou criar o mobile com base nele."_
|
||
- Ao final, **atualize o CONTEXT.md**: marque a versão mobile como "concluída", adicione o caminho do `<key>-mobile.xml` e registre a modificação no histórico.
|
||
|
||
**Se o CONTEXT.md não existir:**
|
||
- Informe ao usuário que não há arquivo de contexto para este painel.
|
||
- Leia o `<key>-desktop.xml` completo para extrair os IDs necessários.
|
||
- Ao final, crie o CONTEXT.md completo (veja o formato na Fase 8 da skill **vitruvio-criar-dashboard-desktop**).
|
||
|
||
---
|
||
|
||
## Princípios obrigatórios — leia antes de qualquer coisa
|
||
|
||
### Nunca invente — pergunte quando tiver dúvida
|
||
|
||
- Se não conhecer um componente, atributo ou comportamento da plataforma: **pergunte ao usuário** antes de inventar. Uma pergunta custa menos do que um bug em produção.
|
||
- Baseie-se sempre no que já existe: leia o painel alvo e outros painéis deste repositório para entender o padrão adotado. O que já funciona aqui é a referência.
|
||
- Não suponha assinaturas de serviços, nomes de atributos ou comportamentos de componentes que não estejam evidentes no código existente.
|
||
|
||
### Se o painel tem filtros acima do conteúdo — mova-os para uma sidebar colapsável
|
||
|
||
Se o painel alvo tem filtros (combos, campos de texto, datas) posicionados **acima** do conteúdo principal (em linha no topo), **não deixe assim no mobile**. Filtros acima ocupam espaço valioso e poluem a tela.
|
||
|
||
O padrão deste repositório é a **sidebar de filtros colapsável** — os dois painéis de referência
|
||
canônicos estão empacotados junto com a skill desktop, leia-os antes de aplicar o padrão:
|
||
- `.claude/skills/vitruvio-criar-dashboard-desktop/dashboard-contrato-desktop.xml`
|
||
- `.claude/skills/vitruvio-criar-dashboard-desktop/dashboard-tecnicos-desktop.xml`
|
||
|
||
O painel usa a global `dashLib` (carregada pelo `run()` desktop via `libService.loadScript('dashboard_ia')`)
|
||
para tema/preferências — veja `.claude/skills/vitruvio-criar-dashboard-desktop/dashboard_ia.js` para
|
||
a API disponível. **É material de consulta apenas: nunca crie, copie ou registre um
|
||
`scripts/dashboard_ia.js` no repositório de destino.**
|
||
|
||
O padrão é:
|
||
|
||
**Layout estrutural (XML)**:
|
||
```
|
||
rootLayout (VerticalLayout, 100% x 100%)
|
||
├── topBar (HorizontalLayout) — botão "◄ Filtros" + navegação + spacer + tema
|
||
└── mainArea (HorizontalLayout, expandRatio=1)
|
||
├── filterBar (VerticalLayout, width=220px) — os filtros em coluna
|
||
└── contentPanel (VerticalLayout, expandRatio=1) — o conteúdo principal
|
||
```
|
||
|
||
**Botão toggle no topBar**:
|
||
```xml
|
||
<ButtonWidget id="btnToggleFiltros" caption="◄ Filtros">
|
||
<onClickScript language="JavaScript">
|
||
<![CDATA[
|
||
function run() {
|
||
var fb = engine.getLayout('filterBar');
|
||
if (!fb) return;
|
||
var filterLayout = fb.getRootComposition();
|
||
var estaVisivel = filterLayout.isVisible();
|
||
filterLayout.setVisible(!estaVisivel);
|
||
var btn = engine.getWidgetController('btnToggleFiltros').getButton();
|
||
btn.setCaption(estaVisivel ? '► Filtros' : '◄ Filtros');
|
||
}
|
||
]]>
|
||
</onClickScript>
|
||
</ButtonWidget>
|
||
```
|
||
|
||
**No mobile**: o `filterBar` começa invisível (escondido no bloco `_mobileRender` do `run()`). O mobile tem seu próprio botão de filtros dentro da `filterBar` (`btnPreviewFiltros`) que só aparece no mobile — ele aciona o mesmo toggle, mantendo consistência sem adicionar nova lógica.
|
||
|
||
**Botão de tema dentro da filterBar (obrigatório no mobile)**: adicione um `ButtonWidget id="btnThemeMob"` no fundo do `filterBar` (após o spacer Label), com `visible="false"`. No bloco `_mobileRender` do `run()`, torne-o visível. Ele deve chamar a mesma função `doToggleTheme` que o `btnTheme` do topBar — defina essa função no ScriptWidget InitScript, registre-a com `engine.setGlobalVariable('doToggleTheme', doToggleTheme)` no `init()`, e tenha ambos os botões chamando-a via `engine.getGlobalVariable('doToggleTheme')`. A função atualiza a caption de AMBOS os botões de tema (`btnTheme` e `btnThemeMob`), o CSS do filterBar e o HTML gerado.
|
||
|
||
```xml
|
||
<!-- dentro do filterBar, após o spacer Label -->
|
||
<ButtonWidget id="btnThemeMob" caption="☽" description="Alternar tema" visible="false" width="100%">
|
||
<onClickScript language="JavaScript">
|
||
<![CDATA[
|
||
function run() {
|
||
var fn = engine.getGlobalVariable('doToggleTheme');
|
||
if (fn) fn();
|
||
}
|
||
]]>
|
||
</onClickScript>
|
||
</ButtonWidget>
|
||
```
|
||
|
||
```javascript
|
||
// No bloco _mobileRender do run():
|
||
var _btnThemeMob = engine.getWidgetController('btnThemeMob');
|
||
if (_btnThemeMob) { _btnThemeMob.getButton().setVisible(true); }
|
||
// Oculta btnTheme do topBar — no mobile apenas btnThemeMob é visível
|
||
var _btnThemeTb = engine.getWidgetController('btnTheme');
|
||
if (_btnThemeTb) { _btnThemeTb.getButton().setVisible(false); }
|
||
|
||
// doToggleTheme no ScriptWidget InitScript — atualiza os dois botões:
|
||
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 bT = engine.getWidgetController('btnTheme');
|
||
if (bT) { bT.getButton().setCaption(next === 'dark' ? '☽' : '☀'); }
|
||
var bM = engine.getWidgetController('btnThemeMob');
|
||
if (bM) { bM.getButton().setCaption(next === 'dark' ? '☽' : '☀'); }
|
||
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('meu-painel-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();
|
||
}
|
||
|
||
// No init():
|
||
engine.setGlobalVariable('doToggleTheme', doToggleTheme);
|
||
```
|
||
|
||
**Os campos de filtro dentro da filterBar são os do painel novo** — não copie os campos do `dashboard-contratos`. Leia o painel alvo, identifique seus filtros originais (cmbAno, cmbMes, txtCliente, etc.) e coloque-os dentro do `filterBar` como `VerticalLayout` com `spacing="true" margin="true"`.
|
||
|
||
**`forceFieldsRender` no `<key>-mobile.xml`**: inclua todos os campos de filtro da sidebar. Sem isso, `engine.getField('cmbAno')` falha no WebView mobile.
|
||
|
||
**Campo de pesquisa/busca do desktop**: se o painel desktop tem um campo de texto para pesquisa (ex: filtrar por cliente, número, palavra-chave), ele **obrigatoriamente deve aparecer no mobile também**, dentro da filterBar. Nunca omita a pesquisa no mobile — o usuário mobile precisa dela tanto quanto o desktop. Se a pesquisa estiver inline acima da tabela no desktop, mova-a para dentro da `filterBar` no mobile (ou mantenha nos dois lugares).
|
||
|
||
---
|
||
|
||
### Siga o padrão do repositório
|
||
|
||
Este repositório já tem painéis funcionando. Antes de criar algo novo:
|
||
|
||
- Leia pelo menos um `<key>-desktop.xml` existente deste repo para entender como estão estruturados os componentes, como são chamados os serviços, como é o estilo visual
|
||
- Reutilize classes CSS já definidas (ex: `dash-kpi`, `dash-kpi-sm`, `mov-card`, `mobile-topbar`) em vez de criar novas
|
||
- Reutilize padrões de `run()` / `init()` / `renderHtml()` que já existem no painel alvo
|
||
|
||
### Botões e campos padronizados
|
||
|
||
- **Botões de ação** (`ButtonWidget`): use `style="DEFAULT"` para ações neutras, `style="GREEN"` para confirmar/salvar, `style="RED"` para cancelar/excluir. Nunca crie botões com HTML customizado quando um `ButtonWidget` padrão serve.
|
||
- **Campos de filtro**: `ComboBox` para listas fechadas, `TextField` para texto livre, `DateField` para datas. Sempre com `id` único e `caption` em português.
|
||
- **Ícones**: use os já presentes no painel alvo. Se precisar de novo ícone, use os codepoints já usados no repositório (ex: `📱` para mobile, `✕` para fechar) — não invente.
|
||
- **Labels de valor monetário**: sempre use a função de formatação já existente no painel (ex: `formatBRL(valor)`) — não crie nova.
|
||
|
||
### JavaScript — ES5 Rhino somente
|
||
|
||
Nunca use ES6+. Veja o que é proibido e o que usar no lugar:
|
||
|
||
```javascript
|
||
// PROIBIDO
|
||
const x = 1; // use: var x = 1;
|
||
let y = 2; // use: var y = 2;
|
||
() => {}; // use: function() {}
|
||
`texto ${var}`; // use: 'texto ' + var
|
||
const { a } = obj; // use: var a = obj.a;
|
||
class Foo {} // use: function Foo() {}
|
||
async/await // use: callbacks
|
||
import/export // não disponível
|
||
for (const x of arr) // use: for (var i = 0; i < arr.length; i++)
|
||
```
|
||
|
||
---
|
||
|
||
---
|
||
|
||
## Passo 1 — Confirmar repositório e ler o painel alvo
|
||
|
||
```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.
|
||
|
||
Leia o `<key>-desktop.xml` alvo **completo** antes de escrever qualquer linha. Leia também pelo menos um outro painel do repositório para entender os padrões já adotados. Só depois prossiga.
|
||
|
||
Leia o `<key>-desktop.xml` alvo para entender:
|
||
- O `formKey` do painel
|
||
- Os layouts declarados (`id` de cada `VerticalLayout`, `HorizontalLayout`, `Panel`, etc.)
|
||
- Os campos de filtro (`ComboBox`, `TextField`, etc.)
|
||
- A estrutura do `initScript` / `run()` / `init()` do ScriptWidget
|
||
- O que é renderizado em HTML (ScriptWidget que chama `renderHtml`)
|
||
|
||
Leia o `vitruvio.json` para identificar a entrada do painel (`key`, `forms`).
|
||
|
||
---
|
||
|
||
## Passo 2 — Criar o `<key>-mobile.xml`
|
||
|
||
Crie `panels/<key>/<key>-mobile.xml`. Ele é um shell que delega ao desktop:
|
||
|
||
```xml
|
||
<?xml version="1.0" encoding="UTF-8"?>
|
||
<panel-form xmlns="http://www.davinti.com.br/vitruvio/form/mobile/panel"
|
||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||
xsi:schemaLocation="http://www.davinti.com.br/vitruvio/form/mobile/panel
|
||
https://bitbucket.org/davinTI/vitruvio-xds/raw/master/vitruvio-mobile-panel-form.xsd">
|
||
<form formKey="<key>-mobile">
|
||
<name><NOME DO PAINEL></name>
|
||
<description><DESCRIÇÃO></description>
|
||
|
||
<initScript language="JavaScript">
|
||
<![CDATA[
|
||
function run() {
|
||
// _mobileRender é setado automaticamente pelo DesktopPanel antes
|
||
// do painel desktop inicializar — o desktop detecta e adapta o layout.
|
||
}
|
||
]]>
|
||
</initScript>
|
||
|
||
<components>
|
||
<VerticalLayout width="100%" height="100%">
|
||
<DesktopPanel id="<camelCaseId>" panelKey="<key>"
|
||
layoutId="rootLayout" external="true"
|
||
forceLayoutsRender="<lista de layout ids separados por vírgula>"
|
||
forceFieldsRender="<lista de field ids separados por vírgula>" height="100%" />
|
||
</VerticalLayout>
|
||
</components>
|
||
</form>
|
||
</panel-form>
|
||
```
|
||
|
||
**`forceLayoutsRender`**: liste todos os layouts do desktop que precisam existir no DOM móvel (ex: `topBar, filterBar, mainArea, contentPanel, panelRoot`). Leia o XML do desktop para identificá-los.
|
||
|
||
**`forceFieldsRender`**: liste os campos de filtro que o script usa (ex: `cmbAno, cmbMes, cmbOrdem`). Sem eles, `engine.getField('cmbAno')` falha no mobile.
|
||
|
||
**`height="100%"` no `VerticalLayout` e no `DesktopPanel`**: os dois níveis do shell usam `100%` — a altura real vem do container mobile que envolve este `<key>-mobile.xml`. Um valor fixo grande (ex: `3000px`/`700px`) já foi usado aqui para forçar os layouts filhos a calcularem porcentagem, mas isso quebrava a tela (espaço em branco/corte); com `100%` nos dois níveis do shell a altura se propaga corretamente do container pai, sem precisar desse artifício.
|
||
|
||
---
|
||
|
||
## Passo 3 — Atualizar vitruvio.json
|
||
|
||
No `vitruvio.json`, na entrada do painel:
|
||
- Adicione `"mobile": "panels/<key>/<key>-mobile.xml"` dentro de `"forms"`
|
||
- Defina `"showInMobileList": true`
|
||
|
||
```json
|
||
{
|
||
"key": "<key>",
|
||
"forms": {
|
||
"desktop": "panels/<key>/<key>-desktop.xml",
|
||
"mobile": "panels/<key>/<key>-mobile.xml"
|
||
},
|
||
"showInMobileList": true
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Passo 4 — Adaptar o `<key>-desktop.xml`
|
||
|
||
Esta é a parte principal. O desktop precisa detectar o contexto mobile e adaptar o HTML gerado.
|
||
|
||
### 4.1 — Bloco de inicialização mobile no `run()`
|
||
|
||
Dentro do `run()` (initScript), após o bloco de CSS principal, adicione a detecção mobile:
|
||
|
||
```javascript
|
||
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, ANTES do colapso de renderHtml (ver seção 4.6).
|
||
_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';" +
|
||
"})();"
|
||
);
|
||
}
|
||
// Aplica estilo de topbar mobile ao topBar se existir
|
||
var _topBar = engine.getLayout('topBar');
|
||
if (_topBar) { _topBar.getRootComposition().addStyleName('mobile-topbar'); }
|
||
// Esconde filterBar no mobile (filtros ficam em outro fluxo ou botão)
|
||
var _filterBar = engine.getLayout('filterBar');
|
||
if (_filterBar) { _filterBar.getRootComposition().setVisible(false); }
|
||
// Exibe btnThemeMob (dentro da filterBar) e oculta btnTheme (topBar)
|
||
// Sem isso o usuário vê dois botões de tema simultaneamente no mobile
|
||
var _btnThemeMobR = engine.getWidgetController('btnThemeMob');
|
||
if (_btnThemeMobR) { _btnThemeMobR.getButton().setVisible(true); }
|
||
var _btnThemeTbR = engine.getWidgetController('btnTheme');
|
||
if (_btnThemeTbR) { _btnThemeTbR.getButton().setVisible(false); }
|
||
// CSS específico mobile adicionado via page.getStyles().add()
|
||
page.getStyles().add(
|
||
// Tabelas/grids mobile: altura fixa com scroll interno
|
||
".mobile-view .mov-scroll { height:520px !important; overflow-y:auto !important;" +
|
||
" -webkit-overflow-scrolling:touch !important; overscroll-behavior:contain; touch-action:pan-y; } "
|
||
// Adicione aqui outros CSS mobile específicos deste painel
|
||
);
|
||
}
|
||
```
|
||
|
||
> **Se o painel alvo tem o assistente de IA "Pietro"** (sidebar `chatBar`, veja a seção opcional
|
||
> 7.11 da skill **vitruvio-criar-dashboard-desktop**): no mesmo bloco `_mobileRender`, oculte
|
||
> `btnToggleChat` e mostre `btnChatFabMobile` — mesmo tratamento dado a `btnTheme`/`btnThemeMob`
|
||
> acima. Replique também em `enterMobilePreview`/`exitMobilePreview` (Passo 5.3). Se o painel não
|
||
> tiver esses IDs, ignore — é um add-on opcional, não parte obrigatória do padrão.
|
||
|
||
### 4.2 — Variável `mobileClass` no HTML gerado
|
||
|
||
Em toda função que gera HTML (ex: `desenharDashboard`, `renderView`, etc.), adicione:
|
||
|
||
```javascript
|
||
var mobileClass = (engine.isGlobalVariableSet('isMobile') || engine.isGlobalVariableSet('_mobileRender')) ? ' mobile-view' : '';
|
||
var html = '<div class="dash-wrapper' + mobileClass + '" id="meu-painel-main">';
|
||
```
|
||
|
||
Isso permite que todo o CSS mobile use seletores `.mobile-view .classe` sem afetar o desktop.
|
||
|
||
### 4.3 — Layouts horizontais → cards verticais no mobile
|
||
|
||
**Regra de ouro**: no mobile NUNCA coloque duas informações lado a lado. Tudo em coluna única.
|
||
|
||
**Desktop** (layout horizontal):
|
||
```javascript
|
||
html += '<div style="display:flex; gap:16px;">';
|
||
html += '<div>R$ 100.000</div>';
|
||
html += '<div>R$ 50.000</div>';
|
||
html += '</div>';
|
||
```
|
||
|
||
**Mobile** (detectar e converter para vertical):
|
||
```javascript
|
||
if (mobileClass) {
|
||
html += '<div style="display:flex; flex-direction:column; gap:8px;">';
|
||
html += '<div class="mob-card"><span class="mob-label">Locação</span><span class="mob-value">R$ 100.000</span></div>';
|
||
html += '<div class="mob-card"><span class="mob-label">Serviço</span><span class="mob-value">R$ 50.000</span></div>';
|
||
html += '</div>';
|
||
} else {
|
||
// layout desktop original
|
||
}
|
||
```
|
||
|
||
### 4.4 — Tabelas (grids) no mobile → cards empilhados
|
||
|
||
No desktop: `<table>` com colunas. No mobile: cada linha vira um card com os campos empilhados:
|
||
|
||
```javascript
|
||
if (mobileClass) {
|
||
html += '<div class="mov-scroll"><div class="mov-cards">';
|
||
for (var i = 0; i < linhas.length; i++) {
|
||
var l = linhas[i];
|
||
html += '<div class="mov-card">';
|
||
html += '<div class="mov-card-top">';
|
||
html += ' <span class="mov-card-cliente">' + l.nome + '</span>';
|
||
html += '</div>';
|
||
html += '<div class="mov-card-meta">' + l.codigo + ' · ' + l.tipo + '</div>';
|
||
html += '<div class="mov-card-values">';
|
||
html += ' <div class="mov-card-val"><span class="mov-card-lbl">Valor</span><span>' + formatBRL(l.valor) + '</span></div>';
|
||
html += ' <div class="mov-card-val"><span class="mov-card-lbl">Status</span><span>' + l.status + '</span></div>';
|
||
html += '</div>';
|
||
html += '</div>';
|
||
}
|
||
html += '</div></div>';
|
||
} else {
|
||
// tabela desktop
|
||
}
|
||
```
|
||
|
||
### 4.5 — CSS mobile obrigatório
|
||
|
||
No `addCss()` (ou no CSS principal adicionado em `run()`), inclua os seletores mobile:
|
||
|
||
> **CRÍTICO:** Todo CSS de scroll mobile **deve estar em `addCss()`** — nunca 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
|
||
// ── Scroll containers no mobile — OBRIGATÓRIO em addCss() ──
|
||
".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; } " +
|
||
|
||
// Tamanhos de fonte mobile — NUNCA menor que o desktop, sempre maior
|
||
".mobile-view .dash-kpi-sm .dash-kpi-label { font-size:13px !important; } " +
|
||
".mobile-view .dash-kpi-sm .dash-kpi-value { font-size:17px !important; } " +
|
||
|
||
// Cards de movimentação/dados mobile
|
||
".mov-card { background:#1a2733; border-radius:6px; padding:12px 16px; margin-bottom:8px; } " +
|
||
".mov-card-cliente { font-weight:bold; color:#fff; font-size:13px; } " +
|
||
".mov-card-meta { font-size:11px; color:#8899aa; margin-bottom:8px; } " +
|
||
".mov-card-values { display:flex; flex-wrap:wrap; gap:6px; } " +
|
||
".mov-card-val { flex:1; min-width:80px; background:#0f1923; border-radius:4px; padding:6px 8px; } " +
|
||
".mov-card-lbl { display:block; font-size:10px; color:#8899aa; margin-bottom:2px; } " +
|
||
|
||
// Mobile: fontes maiores nos cards
|
||
".mobile-view .mov-card-cliente { font-size:15px !important; } " +
|
||
".mobile-view .mov-card-meta { font-size:13px !important; } " +
|
||
".mobile-view .mov-card-lbl { font-size:12px !important; } " +
|
||
```
|
||
|
||
**Classes de scroll padrão:**
|
||
|
||
| Classe | Altura | Uso |
|
||
|---|---|---|
|
||
| `.mov-scroll` | 520px | Container da lista de movimentação (cards ou tabela) |
|
||
| `.tec-mob-scroll` | 420px | Container de lista de cards de técnicos/ranking |
|
||
| `.hbar-scroll` | max 280px | Container de gráfico de barras horizontais (svgHBar) |
|
||
|
||
**Padrão de envolvimento obrigatório no mobile:**
|
||
|
||
```javascript
|
||
// ── Movimentação: barra de pesquisa FORA do scroll, cards DENTRO ──
|
||
html += '<div class="mob-search-bar">...</div>'; // fica fixo no topo
|
||
html += '<div class="mov-scroll">'; // scroll interno
|
||
for (var i = 0; i < linhas.length; i++) {
|
||
html += '<div class="mov-card">...</div>';
|
||
}
|
||
if (linhas.length === 0) html += '<div style="padding:28px;text-align:center;color:#556677;">Sem dados</div>';
|
||
html += '</div>'; // fecha mov-scroll
|
||
|
||
// ── Lista de técnicos/ranking: header fora, cards dentro ──
|
||
html += '<div class="dash-chart-box">...header com botões de sort...</div>';
|
||
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>'; // fecha tec-mob-list
|
||
html += '</div>'; // fecha tec-mob-scroll
|
||
|
||
// ── Gráficos svgHBar: sempre dentro de hbar-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>';
|
||
```
|
||
|
||
**Tamanhos mínimos de fonte mobile:**
|
||
|
||
| Tipo de dado | Desktop | Mobile mínimo |
|
||
|---|---|---|
|
||
| Label/rótulo | 10–12px | 12–13px |
|
||
| Valor principal | 13–16px | 16–18px |
|
||
| Título de seção | 14–16px | 18–20px |
|
||
| Texto de detalhe | 11–12px | 13–14px |
|
||
|
||
### 4.6 — renderHtml: tratar ScriptWidget para mobile e preview
|
||
|
||
**Padrão real (confirmado nos dois painéis de referência):** `renderHtml` distingue **preview de
|
||
dev** (`isMobile` setado, `_mobileRender` não) do resto — só o preview usa altura natural via
|
||
`base.setHeight(null)`; mobile real (`_mobileRender`) e desktop normal sempre usam `setSizeFull()`.
|
||
Não existe nenhuma técnica de "colapsar um elemento de `700px`" — isso é uma versão obsoleta. Depois
|
||
do render, o único ajuste feito por JS é liberar `overflow:visible` nos containers pais intermediários
|
||
até encontrar o `.mobile-root-frame` (cuja altura já foi fixada uma única vez no bloco `_mobileRender`
|
||
do `run()`, via `window.screen.height` — ver seção 4.1), para que o scroll externo não "arraste" a
|
||
`topBar` junto:
|
||
|
||
```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 usam setSizeFull() — só o preview precisa de altura
|
||
// natural para o Panel (contentPanel) enxergar o overflow real e rolar.
|
||
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); }
|
||
Packages.com.vaadin.ui.UI.getCurrent().getPage().getJavaScript().execute(
|
||
"setTimeout(function(){" +
|
||
"if(typeof meuPainelApplyTheme==='function')meuPainelApplyTheme();" +
|
||
// NÃO redimensionar .mobile-root-frame para caber o conteúdo aqui — sua altura é fixa
|
||
// (definida uma única vez no _mobileRender via window.screen.height). Só libera
|
||
// overflow:visible nos pais intermediários para o scroll externo funcionar sem
|
||
// "arrastar" a topBar junto.
|
||
"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);"
|
||
);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Passo 5 — Sistema de Preview Mobile (para grupo vi_developer)
|
||
|
||
Adicione ao `<key>-desktop.xml` um botão de preview que simula o mobile diretamente no desktop. Isso permite testar sem abrir o WebView mobile.
|
||
|
||
### 5.1 — Botão no topBar (XML)
|
||
|
||
```xml
|
||
<ButtonWidget id="btnDevPreview" caption="📱"
|
||
description="Preview Mobile (apenas desenvolvedores)" visible="false">
|
||
<onClickScript>
|
||
function run() { var fn = engine.getGlobalVariable('enterMobilePreview'); if (fn) fn(); }
|
||
</onClickScript>
|
||
</ButtonWidget>
|
||
<ButtonWidget id="btnExitPreview" caption="✕ Sair Preview"
|
||
description="Voltar ao modo desktop" visible="false">
|
||
<onClickScript>
|
||
function run() { var fn = engine.getGlobalVariable('exitMobilePreview'); if (fn) fn(); }
|
||
</onClickScript>
|
||
</ButtonWidget>
|
||
```
|
||
|
||
### 5.2 — CSS do preview
|
||
|
||
Adicione ao CSS principal:
|
||
|
||
```javascript
|
||
".preview-mobile-frame { margin:0 auto !important; border:2px solid #4a9edd !important;" +
|
||
" border-radius:12px !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; } "
|
||
```
|
||
|
||
### 5.3 — Funções enterMobilePreview / exitMobilePreview
|
||
|
||
**Padrão real (confirmado nos dois painéis de referência):** o preview usa altura **natural**, não
|
||
fixa — `base.setHeight(null)` faz o `ScriptWidget` crescer ao tamanho do conteúdo, e o `Panel`
|
||
(`contentPanel`) que o envolve enxerga o overflow real e rola sozinho. Não há valor fixo (nem
|
||
`700px` nem `3000px`) nem colapso via JS aqui — essa técnica é só para o `_mobileRender` real
|
||
(placeholder `700px` recalculado por `window.screen.height`, seção 4.1), que existe porque ali sim
|
||
há uma tela de celular real para medir. No preview, que roda no navegador desktop do dev, altura
|
||
natural é suficiente e mais simples.
|
||
|
||
Adicione no ScriptWidget, antes do `init()`:
|
||
|
||
```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');
|
||
}
|
||
var _tBar = engine.getLayout('topBar');
|
||
if (_tBar) _tBar.getRootComposition().addStyleName('mobile-topbar');
|
||
// Salva e esconde filterBar
|
||
var _fBar = engine.getLayout('filterBar');
|
||
if (_fBar) {
|
||
var _fBarComp = _fBar.getRootComposition();
|
||
engine.setGlobalVariable('_prevFilterBarVisible', _fBarComp.isVisible());
|
||
_fBarComp.setVisible(false);
|
||
}
|
||
var _btnDev = engine.getWidgetController('btnDevPreview');
|
||
if (_btnDev) _btnDev.getButton().setVisible(false);
|
||
var _btnExit = engine.getWidgetController('btnExitPreview');
|
||
if (_btnExit) {
|
||
_btnExit.getButton().setVisible(true);
|
||
_btnExit.getButton().addStyleName('preview-exit-btn');
|
||
}
|
||
// Exibe btnThemeMob e oculta btnTheme — igual ao _mobileRender real
|
||
var _btnThMob = engine.getWidgetController('btnThemeMob');
|
||
if (_btnThMob) _btnThMob.getButton().setVisible(true);
|
||
var _btnThTb = engine.getWidgetController('btnTheme');
|
||
if (_btnThTb) _btnThTb.getButton().setVisible(false);
|
||
renderView(); // re-renderiza com mobile-view ativo
|
||
}
|
||
|
||
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');
|
||
}
|
||
var _tBar = engine.getLayout('topBar');
|
||
if (_tBar) _tBar.getRootComposition().removeStyleName('mobile-topbar');
|
||
// Restaura filterBar — usa loose equality (== ) para coerção de Java Boolean
|
||
var _fBar = engine.getLayout('filterBar');
|
||
if (_fBar) {
|
||
var _wasVisible = engine.getGlobalVariable('_prevFilterBarVisible');
|
||
_fBar.getRootComposition().setVisible(_wasVisible == true);
|
||
engine.unsetGlobalVariable('_prevFilterBarVisible');
|
||
}
|
||
var _btnExit = engine.getWidgetController('btnExitPreview');
|
||
if (_btnExit) {
|
||
_btnExit.getButton().removeStyleName('preview-exit-btn');
|
||
_btnExit.getButton().setVisible(false);
|
||
}
|
||
// Restaura btnTheme do topBar e oculta btnThemeMob ao sair do preview
|
||
var _btnThMobExit = engine.getWidgetController('btnThemeMob');
|
||
if (_btnThMobExit) _btnThMobExit.getButton().setVisible(false);
|
||
var _btnThTbExit = engine.getWidgetController('btnTheme');
|
||
if (_btnThTbExit) _btnThTbExit.getButton().setVisible(true);
|
||
// Reexibe botão preview se o usuário for developer
|
||
var _isDev = engine.getGlobalVariable('isDeveloper');
|
||
var _btnDev = engine.getWidgetController('btnDevPreview');
|
||
if (_btnDev) _btnDev.getButton().setVisible(!!_isDev);
|
||
renderView();
|
||
}
|
||
```
|
||
|
||
### 5.4 — Checar grupo vi_developer e exibir botão no init()
|
||
|
||
No `init(mapa)` ou no `run()`, após a inicialização principal:
|
||
|
||
```javascript
|
||
// Verifica acesso de desenvolvedor (vi_developer)
|
||
try {
|
||
var _dbLib = libService.loadScript('db');
|
||
var _banco = new _dbLib(_dbLib.VITRUVIO_DATASOURCE);
|
||
var _devRow = _banco.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 ug.grupo_fk = g.grupo_id " +
|
||
"WHERE u.login = '" + engine.getLoggedUser().getLogin() + "' " +
|
||
"AND g.sigla = 'vi_developer'"
|
||
);
|
||
if (_devRow && String(_devRow.CNT) !== '0') {
|
||
engine.setGlobalVariable('isDeveloper', true);
|
||
var _btnDev = engine.getWidgetController('btnDevPreview');
|
||
if (_btnDev) _btnDev.getButton().setVisible(true);
|
||
}
|
||
} catch(e) {}
|
||
// Registra funções de preview como variáveis globais (acessíveis pelo XML dos botões)
|
||
engine.setGlobalVariable('enterMobilePreview', enterMobilePreview);
|
||
engine.setGlobalVariable('exitMobilePreview', exitMobilePreview);
|
||
```
|
||
|
||
---
|
||
|
||
## Passo 6 — Armadilhas conhecidas (não repita estes erros)
|
||
|
||
### Java Boolean vs JS boolean
|
||
`engine.getGlobalVariable()` retorna `Java Boolean`, não JS primitive:
|
||
```javascript
|
||
// ERRADO — Java Boolean.FALSE !== JS false (tipos diferentes, strict equality falha)
|
||
if (_wasVisible !== false) { ... }
|
||
|
||
// CERTO — loose equality funciona com Java Boolean
|
||
if (_wasVisible == true) { ... }
|
||
// OU
|
||
if (!!_wasVisible) { ... }
|
||
```
|
||
|
||
### setExpandRatio antes de addComponent
|
||
```javascript
|
||
// ERRADO — comp não é filho de base ainda, Vaadin lança IllegalArgumentException
|
||
comp.setSizeFull();
|
||
base.setExpandRatio(comp, 1.0); // ← ERRO: comp não está em base
|
||
base.addComponent(comp);
|
||
|
||
// CERTO — setExpandRatio sempre APÓS addComponent
|
||
comp.setSizeFull();
|
||
base.removeAllComponents();
|
||
base.addComponent(comp);
|
||
base.setExpandRatio(comp, 1.0); // ← OK: comp já é filho
|
||
```
|
||
|
||
### base.setHeight(null) no preview — por que funciona
|
||
`enterMobilePreview` põe `base` (o container do `ScriptWidget`) em altura natural com `base.setHeight(null)` — sem isso, o container mantém a altura da viewport inteira e o `Panel` (`contentPanel`) não enxerga o conteúdo real para rolar. No `_mobileRender` real esse mesmo `base.setHeight(null)` **não** é usado — lá o `renderHtml` sempre chama `comp.setSizeFull()`, porque quem controla a altura do frame é o cálculo de `window.screen.height` no `rootLayout` (seção 4.1), não o `base`.
|
||
|
||
### inline style sobrescreve class CSS
|
||
Se o HTML gerado tem `style="font-size:12px"` no elemento, CSS de classe não sobrescreve (exceto com `!important`). Mude o valor diretamente na geração do HTML para contexto mobile:
|
||
```javascript
|
||
var fontSize = mobileClass ? '14px' : '12px';
|
||
html += '<div style="font-size:' + fontSize + ';color:#8899aa;">Texto</div>';
|
||
```
|
||
|
||
### Redimensionar o frame mobile a cada render — não faça isso
|
||
Uma versão antiga recalculava a altura do `.mobile-root-frame` para `el.getBoundingClientRect().bottom` a cada `renderHtml`. Isso fazia o frame crescer para caber TODO o conteúdo (não só o viewport), transformando `topBar` + conteúdo num único bloco comprido que a página externa rolava inteiro — "arrastando" a `topBar` junto. A altura do `.mobile-root-frame` é definida **uma única vez** no bloco `_mobileRender` do `run()` (via `window.screen.height`, seção 4.1) e deve permanecer fixa; `renderHtml` só libera `overflow:visible` nos pais intermediários (seção 4.6), nunca redimensiona o frame em si.
|
||
|
||
---
|
||
|
||
## Passo 7 — Checklist de entrega
|
||
|
||
Antes de declarar pronto, confirme:
|
||
|
||
- [ ] Leu o `<key>-desktop.xml` alvo e ao menos um outro painel do repositório antes de escrever qualquer coisa
|
||
- [ ] Tudo que foi feito tem referência no código existente do repositório — nada inventado
|
||
- [ ] Não usou nenhuma feature ES6+ (sem `const`, `let`, arrow functions, template literals, destructuring, `class`, `async/await`)
|
||
- [ ] Botões usam `style` padrão (`DEFAULT`, `GREEN`, `RED`) — sem HTML customizado para botões
|
||
- [ ] Reutilizou funções de formatação já existentes no painel (ex: `formatBRL`) — sem duplicar
|
||
- [ ] Reutilizou classes CSS já existentes — sem criar novas desnecessariamente
|
||
- [ ] `<key>-mobile.xml` criado com `DesktopPanel` apontando para o `panelKey` correto
|
||
- [ ] `forceLayoutsRender` lista todos os layouts usados pelo desktop
|
||
- [ ] `forceFieldsRender` lista todos os campos de filtro acessados via `engine.getField()`
|
||
- [ ] `vitruvio.json`: `"mobile"` adicionado em `"forms"` e `"showInMobileList": true`
|
||
- [ ] `run()` do desktop detecta `_mobileRender` e adapta rootLayout + filterBar + CSS
|
||
- [ ] No `_mobileRender` real: `rootLayout` recebe `setHeight("700px")` + `addStyleName('mobile-root-frame')`, seguido de `page.getJavaScript().execute(...)` calculando a altura final a partir de `window.screen.height` (clamp entre 320px e `min(screen.height-220, screen.height*0.80)`) — o `700px` é só o placeholder inicial, não a altura final do mobile real
|
||
- [ ] HTML gerado usa `mobileClass` e layouts verticais no mobile
|
||
- [ ] Tabelas/grids do desktop viram cards empilhados no mobile
|
||
- [ ] Fontes mobile respeitam os tamanhos mínimos (labels ≥ 12px, valores ≥ 16px)
|
||
- [ ] `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)`
|
||
- [ ] `enterMobilePreview` usa `base.setHeight(null)` (altura natural) — não copia o `700px` fixo do `_mobileRender` real, que só faz sentido para tela de celular
|
||
- [ ] `exitMobilePreview` restaura `base.setHeight("100%")`
|
||
- [ ] JS pós-render (setTimeout 400ms mínimo) só libera `overflow:visible` nos pais até `.mobile-root-frame` — nunca redimensiona o frame em si
|
||
- [ ] `setExpandRatio` é chamado APÓS `addComponent`
|
||
- [ ] Funções `enterMobilePreview` / `exitMobilePreview` registradas como global vars
|
||
- [ ] Botão `btnDevPreview` visível somente para `vi_developer`
|
||
- [ ] `btnThemeMob` adicionado como **primeiro filho** do `filterBar`, `visible="false"` no XML, visível no bloco `_mobileRender`
|
||
- [ ] No bloco `_mobileRender` e em `enterMobilePreview`: `btnTheme` (topBar) oculto via `setVisible(false)` — evita dois botões de tema simultâneos
|
||
- [ ] No `exitMobilePreview`: `btnThemeMob` oculto e `btnTheme` restaurado via `setVisible(true)`
|
||
- [ ] `doToggleTheme` definido no ScriptWidget, registrado no `init()`, chamado por `btnTheme` e `btnThemeMob`
|
||
- [ ] `doToggleTheme` atualiza caption de ambos os botões de tema
|
||
- [ ] Comparação de Java Boolean usa `==` (loose equality), nunca `===`
|
||
- [ ] CSS de scroll mobile (`.mov-scroll`, `.tec-mob-scroll`, `.hbar-scroll`) está em `addCss()` — **não apenas** no bloco `_mobileRender`
|
||
- [ ] Lista de cards de movimentação mobile envolvida em `<div class="mov-scroll">`
|
||
- [ ] Lista de técnicos/ranking mobile envolvida em `<div class="tec-mob-scroll">`
|
||
- [ ] Gráficos svgHBar no mobile envolvidos em `<div class="hbar-scroll">`
|
||
|
||
---
|
||
|
||
## Referência rápida de classes CSS mobile
|
||
|
||
| Classe | Uso |
|
||
|---|---|
|
||
| `.mobile-view` | Aplicada ao `id` raiz do HTML gerado quando em modo mobile |
|
||
| `.mobile-topbar` | Estilo do topBar no mobile (botões menores, compactos) |
|
||
| `.preview-mobile-frame` | Borda azul que indica o frame de preview 375px |
|
||
| `.preview-exit-btn` | Botão "✕ Sair Preview" flutuante fixed no canto superior direito |
|
||
| `.mov-scroll` | Container da lista de movimentação (cresce com o conteúdo, scroll acima de 520px) |
|
||
| `.tec-mob-scroll` | Container de lista de técnicos/ranking (cresce com o conteúdo, scroll acima de 420px) |
|
||
| `.hbar-scroll` | Container de gráfico svgHBar (max 280px + scroll) |
|
||
| `.mov-cards` | Container de cards empilhados (substitui a table no mobile) |
|
||
| `.mov-card` | Card individual de cada linha da tabela |
|
||
| `.mov-card-cliente` | Nome/título principal do card (font-size ≥ 15px mobile) |
|
||
| `.mov-card-meta` | Informações secundárias (contrato, tipo, período) |
|
||
| `.mov-card-values` | Flex wrap com os valores numéricos do card |
|
||
| `.mov-card-val` | Célula individual de valor dentro do card |
|
||
| `.mov-card-lbl` | Rótulo do valor (font-size ≥ 12px mobile) |
|