Buscar K
Aparência
Aparência
Cada receita é um Main.svelte completo para um Widget Svelte: cole no editor, troque os nomes de tabela e coluna pelos da sua Aplicação e clique em Testar. Os exemplos usam uma tabela fictícia Vendas com as colunas DATA, VENDEDOR, CLIENTE e VALOR.
Os nomes das colunas e das tabelas da sua Aplicação aparecem no editor de Aplicação e, pelo Lumo CLI, em lumo info app:<id> --full. Toda referência de coluna leva sufixo (:AGP, :SUM, :DATE); a lista completa está em Objeto app.
Highcharts com as cores do tema, que trocam sozinhas quando a pessoa alterna claro e escuro. Um clique na barra filtra o Dashboard; outro clique na mesma barra tira o filtro.
<script>
import { onMount } from 'svelte';
import Highcharts from 'highcharts';
import { Chart } from '@highcharts/svelte';
export let app;
const VENDEDOR = '[Vendas]."VENDEDOR":AGP';
const VALOR = '[Vendas]."VALOR":SUM';
let dados = null;
let erro = null;
// $app é reativo: muda quando o tema ou as cores do aplicativo móvel mudam.
$: escuro = $app?.theme === 'dark';
$: marca = $app?.moldura?.ativa ? $app.moldura.cores : null;
$: cores = {
texto: marca?.texto ?? (escuro ? '#C2C9D6' : '#1E293B'),
suave: marca?.textoSecundario ?? (escuro ? '#697A9B' : '#64748B'),
linha: marca?.linha ?? (escuro ? '#313E59' : '#E2E8F0'),
fundo: marca?.cartao ?? (escuro ? '#202B40' : '#FFFFFF'),
};
$: opcoes = dados && montar(dados, cores);
onMount(async () => {
try {
dados = await app.fetchMatrix({
columns: [VENDEDOR, VALOR],
orderBy: [`${VALOR}:DESC`],
limit: 15,
});
} catch (e) {
erro = e?.message ?? String(e);
}
});
function alternar(nome) {
const coluna = app.columnFromString(VENDEDOR);
if (!coluna) return;
const atual = app.getFilter(coluna);
app.addFilter(coluna, '=', atual && atual.value === nome ? false : nome);
}
function montar(d, c) {
return {
chart: { type: 'bar', backgroundColor: 'transparent', style: { fontFamily: 'inherit' } },
title: { text: null },
credits: { enabled: false },
legend: { enabled: false },
xAxis: {
categories: d.Result.map((r) => r[0]),
lineColor: c.linha,
labels: { style: { color: c.suave } },
},
yAxis: {
title: { text: null },
gridLineColor: c.linha,
labels: {
style: { color: c.suave },
formatter() { return app.formatValue(this.value, d.Columns[1]); },
},
},
tooltip: {
backgroundColor: c.fundo,
borderColor: c.linha,
style: { color: c.texto },
formatter() { return `<b>${this.x}</b><br/>${app.formatValue(this.y, d.Columns[1])}`; },
},
plotOptions: {
bar: {
borderRadius: 4,
color: '#6366F1',
cursor: 'pointer',
point: { events: { click() { alternar(this.category); } } },
},
},
series: [{ name: d.Columns[1]?.label, data: d.Result.map((r) => Number(r[1]) || 0) }],
};
}
</script>
{#if erro}
<p class="p-4 text-sm text-red-600 dark:text-red-400">{erro}</p>
{:else if dados && dados.Result.length === 0}
<p class="p-4 text-sm text-slate-500 dark:text-navy-300">Sem dados no período</p>
{:else if opcoes}
<div class="h-full w-full">
<Chart {Highcharts} options={opcoes} />
</div>
{/if}O Highcharts não lê classe do Tailwind nem variável CSS, então as cores saem em hexadecimal, calculadas no script. $app.moldura só fica ativo dentro do aplicativo móvel da Horus, que recolore a página com a marca do aplicativo; fora dele, valem as cores do tema. O valor de um filtro com vírgula, barra vertical, ponto e vírgula, dois-pontos, & ou + é guardado escapado, e a comparação do clique deixa de reconhecer o filtro já posto.
Um clique na linha abre o relatório do Explorer com as vendas daquele cliente, dia a dia.
<script>
import { onMount } from 'svelte';
export let app;
const CLIENTE = '[Vendas]."CLIENTE":AGP';
const VALOR = '[Vendas]."VALOR":SUM';
let res = null;
let erro = null;
onMount(async () => {
try {
res = await app.fetchMatrix({ columns: [CLIENTE, VALOR], orderBy: [`${VALOR}:DESC`], limit: 50 });
} catch (e) {
erro = e?.message ?? String(e);
}
});
function abrir(cliente) {
const col = (nome) => app.columnFromString(nome);
const url = app.getReportUrl({
columns: [col('[Vendas]."DATA":DATE'), col(CLIENTE), col(VALOR)],
orderBy: [{ column: col('[Vendas]."DATA":DATE'), direction: 'DESC' }],
filters: [{ column: col(CLIENTE), operator: '=', value: cliente }],
});
window.location.href = url;
}
</script>
{#if erro}
<p class="p-4 text-sm text-red-600 dark:text-red-400">{erro}</p>
{:else if res}
<table class="w-full text-sm text-slate-800 dark:text-navy-100">
<thead class="text-left text-slate-500 dark:text-navy-300">
<tr><th class="px-3 py-2">Cliente</th><th class="px-3 py-2 text-right">Valor</th></tr>
</thead>
<tbody>
{#each res.Result as [cliente, valor]}
<tr class="cursor-pointer border-t border-slate-200 hover:bg-slate-100 dark:border-navy-600 dark:hover:bg-navy-700"
on:click={() => abrir(cliente)}>
<td class="px-3 py-2">{cliente}</td>
<td class="px-3 py-2 text-right font-mono">{app.formatValue(valor, res.Columns[1])}</td>
</tr>
{/each}
</tbody>
</table>
{/if}app.getReportUrl só devolve o endereço, no formato /app/report/<id da Aplicação>?..., e é o window.location.href que navega. As chaves de entrada são columns, orderBy, filters e matrixAxis. Os filtros vivos do Dashboard entram por baixo dos seus: um filtro seu na mesma coluna e com o mesmo sufixo ganha. Os Filtros Pré Aplicados do Widget não vão para o relatório.
Um KPI com o valor do período filtrado e a variação contra o período de mesmo tamanho imediatamente antes. Com o filtro de 01/11/2026 a 30/11/2026, o anterior é de 02/10/2026 a 31/10/2026.
<script>
import { onMount } from 'svelte';
import { differenceInCalendarDays, format, subDays } from 'date-fns';
export let app;
const DATA = '[Vendas]."DATA":DATE';
const VALOR = '[Vendas]."VALOR":SUM';
let atual = null;
let variacao = null;
let coluna = null;
let erro = null;
onMount(async () => {
try {
const colData = app.columnFromString(DATA);
if (!colData) throw new Error('Coluna de data não encontrada na Aplicação');
const { start, end } = app.getDateFilterReference(colData.column_id);
const dias = differenceInCalendarDays(end, start) + 1;
const fimAnterior = subDays(start, 1);
const inicioAnterior = subDays(start, dias);
const iso = (d) => format(d, 'yyyy-MM-dd');
const [agora, antes] = await Promise.all([
app.fetchMatrix({ columns: [VALOR] }),
app.fetchMatrix({
columns: [VALOR],
filters: [{ column: DATA, operator: 'between', value: `${iso(inicioAnterior)},${iso(fimAnterior)}` }],
}),
]);
coluna = agora.Columns[0];
atual = Number(agora.Result?.[0]?.[0]) || 0;
const anterior = Number(antes.Result?.[0]?.[0]) || 0;
variacao = anterior ? (atual - anterior) / anterior : null;
} catch (e) {
erro = e?.message ?? String(e);
}
});
</script>
{#if erro}
<p class="p-4 text-sm text-red-600 dark:text-red-400">{erro}</p>
{:else if atual !== null}
<div class="p-4 text-slate-800 dark:text-navy-100">
<div class="text-3xl font-bold">{app.formatValue(atual, coluna)}</div>
{#if variacao !== null}
<div class={variacao >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}>
{(variacao * 100).toLocaleString(undefined, { maximumFractionDigits: 1 })}% contra o período anterior
</div>
{/if}
</div>
{/if}app.getDateFilterReference lê o filtro do Dashboard que está na coluna com sufixo DATE e devolve { start, end } como Date. Sem filtro nessa coluna, devolve hoje nos dois. Use a coluna de data em que o Dashboard filtra o período: quando a Aplicação tem uma Tabela Calendário, o filtro costuma estar nela, e não na data da tabela de fatos. O filtro que a segunda consulta passa substitui, só nela, o do Dashboard na mesma coluna. Um Filtro Pré Aplicado do Widget nessa coluna ganharia dos dois.
A participação do vendedor filtrado no total precisa de uma consulta que respeita o filtro de vendedor e de outra que o ignora.
const VENDEDOR = '[Vendas]."VENDEDOR":AGP';
const VALOR = '[Vendas]."VALOR":SUM';
const id = app.columnFromString(VENDEDOR).column_id;
const doFiltro = await app.fetchMatrix({ columns: [VALOR] });
const doTotal = await app.fetchMatrix({
columns: [VALOR],
ignoreCrossfilters: [id, String(id)],
});
const participacao = (Number(doFiltro.Result?.[0]?.[0]) || 0) / (Number(doTotal.Result?.[0]?.[0]) || 1);ignoreCrossfilters recebe uma lista de column_id, sempre tirados de app.columnFromString na hora, nas duas formas (número e texto); o motivo está em Ignorar um filtro numa consulta.
Para tirar só o filtro de período desta consulta, passe um filtro com valor false na coluna de data:
const historico = await app.fetchMatrix({
columns: ['[Vendas]."DATA":YEARMONTH', VALOR],
filters: [{ column: '[Vendas]."DATA":DATE', operator: '=', value: false }],
});O filtro do código toma o lugar do filtro do Dashboard na mesma coluna, e o filtro com false não chega à consulta. A coluna precisa ser a mesma que o filtro de período usa; se o período está na Tabela Calendário, o filtro com false vai nela.
Um botão que baixa as linhas consultadas num .xlsx. O arquivo leva o valor cru, para a planilha poder somar; a máscara fica só na tela.
<script>
import * as XLSX from 'xlsx';
export let app;
let baixando = false;
let erro = null;
async function baixar() {
baixando = true;
erro = null;
try {
const res = await app.fetchMatrix({
columns: ['[Vendas]."DATA":DATE', '[Vendas]."CLIENTE":AGP', '[Vendas]."VALOR":SUM'],
orderBy: ['[Vendas]."DATA":DATE:ASC'],
limit: 5000,
});
const cabecalho = res.Columns.map((c) => c?.label ?? '');
const planilha = XLSX.utils.aoa_to_sheet([cabecalho, ...res.Result]);
const livro = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(livro, planilha, 'Vendas');
XLSX.writeFile(livro, 'vendas.xlsx');
} catch (e) {
erro = e?.message ?? String(e);
} finally {
baixando = false;
}
}
</script>
<div class="p-4">
<button class="rounded bg-indigo-600 px-3 py-1.5 text-sm text-white disabled:opacity-50"
disabled={baixando} on:click={baixar}>
{baixando ? 'Gerando…' : 'Baixar Excel'}
</button>
{#if erro}<p class="mt-2 text-sm text-red-600 dark:text-red-400">{erro}</p>{/if}
</div>Sem limit, a consulta traz até 500 linhas.
Para aprovar um pedido no seu ERP a partir do Dashboard, o Widget pede um token ao servidor e manda junto. A sua API valida o token com a chave JWT do Tenant, que o código nunca vê.
<script>
export let app;
const pedidoId = 1234; // no Widget real, vem da linha clicada
let estado = '';
async function aprovar() {
estado = 'Enviando…';
const jwt = await app.generateJWTToken();
if (!jwt?.token) {
estado = 'Não foi possível gerar o token. Veja o console do navegador.';
return;
}
try {
const r = await fetch('https://api.exemplo.com.br/pedidos/aprovar', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${jwt.token}` },
body: JSON.stringify({ pedidoId }),
});
estado = r.ok ? 'Pedido aprovado' : `A API respondeu ${r.status}`;
} catch (e) {
estado = `Falha de rede: ${e?.message ?? e}`;
}
}
</script>
<button class="m-4 rounded bg-indigo-600 px-3 py-1.5 text-sm text-white" on:click={aprovar}>Aprovar pedido</button>
<p class="px-4 text-sm text-slate-600 dark:text-navy-300">{estado}</p>app.generateJWTToken() devolve { token, payload }, ou null quando o Tenant não tem o gerador de JWT ligado ou não tem chave; nesse caso o motivo vai para o console. A API precisa aceitar chamada do navegador (CORS) vinda do endereço do Lumo. Como ligar o gerador e validar o token do outro lado está em Objeto app.
<script>
export let app;
$: saudacao = app.user?.nome ? `Bom dia, ${app.user.nome}` : 'Bom dia';
</script>
<h2 class="mb-0 p-4 text-xl text-slate-800 dark:text-navy-100">{saudacao}</h2>app.user é { id, nome } ou null. Num link público não há sessão; na exportação para PDF ou imagem, é quem disparou a exportação. Por isso o texto sempre tem um caminho sem nome.
| Sintoma | Causa e conserto |
|---|---|
| O Widget fica em branco, sem erro | Uma exceção escapou do onMount. Ponha a consulta em try/catch e mostre a mensagem, como nas receitas acima |
| A consulta não traz nada em outro Tenant ou depois de clonar a Aplicação | O código usa um número de coluna ('7905:SUM'). Troque pelo nome ('[Vendas]."VALOR":SUM') |
app.columnFromString devolve null | O nome não existe na Aplicação, ou falta o sufixo. O console mostra "Tentativa de buscar coluna inexistente" com o texto procurado |
| O clique não filtra os outros Widgets | A coluna não está entre os filtros permitidos do Dashboard, e o addFilter é ignorado sem aviso. Veja Filtros |
on:click não dispara | O código usa onclick={...}, de Svelte 5. O compilador é Svelte 4 e trata isso como atributo comum |