Skip to content

Exemplos de Widget Svelte ​

Tipo
Como fazer
Para
Desenvolvedor
Na tela
DataViz › Aplicação › Editar › Dashboards › Editando Widget › Svelte › Editar Código

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.

Gráfico de barras que filtra o Dashboard ​

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.

svelte
<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.

Tabela com drilldown para o Explorer ​

Um clique na linha abre o relatório do Explorer com as vendas daquele cliente, dia a dia.

svelte
<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.

Comparar com o período anterior ​

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.

svelte
<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.

Ignorar um filtro do Dashboard numa consulta ​

A participação do vendedor filtrado no total precisa de uma consulta que respeita o filtro de vendedor e de outra que o ignora.

javascript
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:

javascript
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.

Exportar para Excel ​

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.

svelte
<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.

Chamar uma API sua ​

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ê.

svelte
<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.

Saudar quem está vendo ​

svelte
<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.

Se der errado ​

SintomaCausa e conserto
O Widget fica em branco, sem erroUma 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çãoO código usa um número de coluna ('7905:SUM'). Troque pelo nome ('[Vendas]."VALOR":SUM')
app.columnFromString devolve nullO 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 WidgetsA coluna não está entre os filtros permitidos do Dashboard, e o addFilter é ignorado sem aviso. Veja Filtros
on:click não disparaO código usa onclick={...}, de Svelte 5. O compilador é Svelte 4 e trata isso como atributo comum

Relacionado ​