feat: implement filtering and PDF/Excel report generation for planejamentos.

This commit is contained in:
2025-12-18 17:31:55 -03:00
parent 0a4be24655
commit 0c7412c764
5 changed files with 812 additions and 28 deletions

View File

@@ -4,21 +4,112 @@
import Breadcrumbs from '$lib/components/layout/Breadcrumbs.svelte';
import PageHeader from '$lib/components/layout/PageHeader.svelte';
import PageShell from '$lib/components/layout/PageShell.svelte';
import GlassCard from '$lib/components/ui/GlassCard.svelte';
import TableCard from '$lib/components/ui/TableCard.svelte';
import { useConvexClient, useQuery } from 'convex-svelte';
import { ClipboardList, Eye, Plus, X, Copy } from 'lucide-svelte';
import { ClipboardList, Eye, Plus, X, Copy, FileText, FileSpreadsheet } from 'lucide-svelte';
import { resolve } from '$app/paths';
import { goto } from '$app/navigation';
import { toast } from 'svelte-sonner';
import { exportarRelatorioPlanejamentosXLSX } from '$lib/utils/planejamentos/relatorioPlanejamentosExcel';
import { gerarRelatorioPlanejamentosPDF } from '$lib/utils/planejamentos/relatorioPlanejamentosPDF';
import { endOfDay, startOfDay } from 'date-fns';
const client = useConvexClient();
const planejamentosQuery = useQuery(api.planejamentos.list, {});
// Filtros
let filtroTexto = $state('');
let filtroResponsavel = $state<Id<'funcionarios'> | ''>('');
let filtroAcao = $state<Id<'acoes'> | ''>('');
let filtroInicio = $state('');
let filtroFim = $state('');
const statusOptions = [
{ value: 'rascunho', label: 'Rascunho' },
{ value: 'gerado', label: 'Gerado' },
{ value: 'cancelado', label: 'Cancelado' }
] as const;
type PlanejamentoStatus = (typeof statusOptions)[number]['value'];
let statusSelected = $state<Record<PlanejamentoStatus, boolean>>({
rascunho: false,
gerado: false,
cancelado: false
});
function getSelectedStatuses(): PlanejamentoStatus[] | undefined {
const selected = (Object.entries(statusSelected) as Array<[PlanejamentoStatus, boolean]>)
.filter(([, v]) => v)
.map(([k]) => k);
return selected.length > 0 ? selected : undefined;
}
function getPeriodoInicio(): number | undefined {
if (!filtroInicio) return undefined;
return startOfDay(new Date(`${filtroInicio}T00:00:00`)).getTime();
}
function getPeriodoFim(): number | undefined {
if (!filtroFim) return undefined;
return endOfDay(new Date(`${filtroFim}T23:59:59`)).getTime();
}
function limparFiltros() {
filtroTexto = '';
filtroResponsavel = '';
filtroAcao = '';
filtroInicio = '';
filtroFim = '';
(Object.keys(statusSelected) as PlanejamentoStatus[]).forEach(
(k) => (statusSelected[k] = false)
);
}
const filtroArgs = () => ({
statuses: getSelectedStatuses(),
texto: filtroTexto.trim() || undefined,
responsavelId: filtroResponsavel ? filtroResponsavel : undefined,
acaoId: filtroAcao ? filtroAcao : undefined,
periodoInicio: getPeriodoInicio(),
periodoFim: getPeriodoFim()
});
const planejamentosQuery = useQuery(api.planejamentos.list, filtroArgs);
const funcionariosQuery = useQuery(api.funcionarios.getAll, {});
const acoesQuery = useQuery(api.acoes.list, {});
let planejamentos = $derived(planejamentosQuery.data || []);
// Relatórios
let generatingPDF = $state(false);
let generatingXLSX = $state(false);
async function gerarPDF() {
try {
generatingPDF = true;
// Passa os mesmos filtros da lista (que é uma função)
const dados = await client.query(api.planejamentos.gerarRelatorio, filtroArgs());
gerarRelatorioPlanejamentosPDF(dados);
} catch (e) {
console.error(e);
toast.error('Erro ao gerar PDF.');
} finally {
generatingPDF = false;
}
}
async function exportarXLSX() {
try {
generatingXLSX = true;
const dados = await client.query(api.planejamentos.gerarRelatorio, filtroArgs());
await exportarRelatorioPlanejamentosXLSX(dados);
} catch (e) {
console.error(e);
toast.error('Erro ao gerar Excel.');
} finally {
generatingXLSX = false;
}
}
function formatStatus(status: string) {
switch (status) {
case 'rascunho':
@@ -140,6 +231,36 @@
{/snippet}
{#snippet actions()}
<button
type="button"
class="btn btn-ghost gap-2"
onclick={gerarPDF}
disabled={generatingPDF || generatingXLSX}
title="Gerar PDF"
>
{#if generatingPDF}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<FileText class="h-5 w-5" />
{/if}
{generatingPDF ? 'Gerando...' : 'PDF'}
</button>
<button
type="button"
class="btn btn-ghost gap-2"
onclick={exportarXLSX}
disabled={generatingPDF || generatingXLSX}
title="Exportar Excel"
>
{#if generatingXLSX}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<FileSpreadsheet class="h-5 w-5" />
{/if}
{generatingXLSX ? 'Exportando...' : 'Excel'}
</button>
<button
type="button"
class="btn btn-primary gap-2 shadow-md transition-all hover:shadow-lg"
@@ -151,6 +272,106 @@
{/snippet}
</PageHeader>
<GlassCard class="mb-6">
<div class="grid gap-4 sm:grid-cols-[1fr_auto] sm:items-end">
<div class="grid grid-cols-1 gap-4 md:grid-cols-3 lg:grid-cols-5">
<div class="form-control w-full">
<label class="label" for="filtro_texto">
<span class="label-text font-semibold">Busca (título/descrição)</span>
</label>
<input
id="filtro_texto"
class="input input-bordered focus:input-primary w-full"
type="text"
placeholder="Digite para pesquisar..."
bind:value={filtroTexto}
/>
</div>
<div class="form-control w-full">
<label class="label" for="filtro_responsavel">
<span class="label-text font-semibold">Responsável</span>
</label>
<select
id="filtro_responsavel"
class="select select-bordered focus:select-primary w-full"
bind:value={filtroResponsavel}
>
<option value="">Todos</option>
{#each funcionariosQuery.data || [] as f (f._id)}
<option value={f._id}>{f.nome}</option>
{/each}
</select>
</div>
<div class="form-control w-full">
<label class="label" for="filtro_acao">
<span class="label-text font-semibold">Ação</span>
</label>
<select
id="filtro_acao"
class="select select-bordered focus:select-primary w-full"
bind:value={filtroAcao}
>
<option value="">Todas</option>
{#each acoesQuery.data || [] as a (a._id)}
<option value={a._id}>{a.nome}</option>
{/each}
</select>
</div>
<div class="form-control w-full">
<label class="label" for="filtro_inicio">
<span class="label-text font-semibold">Período (início)</span>
</label>
<input
id="filtro_inicio"
class="input input-bordered focus:input-primary w-full"
type="date"
bind:value={filtroInicio}
/>
</div>
<div class="form-control w-full">
<label class="label" for="filtro_fim">
<span class="label-text font-semibold">Período (fim)</span>
</label>
<input
id="filtro_fim"
class="input input-bordered focus:input-primary w-full"
type="date"
bind:value={filtroFim}
/>
</div>
<div class="form-control w-full md:col-span-3 lg:col-span-5">
<div class="label">
<span class="label-text font-semibold">Status</span>
</div>
<div class="flex flex-wrap gap-3">
{#each statusOptions as s (s.value)}
<label class="label cursor-pointer justify-start gap-2 py-1">
<input
type="checkbox"
class="checkbox checkbox-primary checkbox-sm"
checked={statusSelected[s.value]}
onchange={(e) =>
(statusSelected[s.value] = (e.currentTarget as HTMLInputElement).checked)}
/>
<span class="label-text">{s.label}</span>
</label>
{/each}
</div>
</div>
</div>
<div class="flex items-center justify-between gap-3 sm:min-w-max sm:justify-end">
<div class="text-base-content/70 text-sm">{planejamentos.length} resultado(s)</div>
<button type="button" class="btn btn-ghost" onclick={limparFiltros}>Limpar</button>
</div>
</div>
</GlassCard>
{#if planejamentosQuery.isLoading}
<div class="flex items-center justify-center py-10">
<span class="loading loading-spinner loading-lg"></span>
@@ -164,23 +385,23 @@
<table class="table-zebra table w-full">
<thead>
<tr>
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
<th class="text-base-content border-base-300 border-b font-bold whitespace-nowrap"
>Título</th
>
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
<th class="text-base-content border-base-300 border-b font-bold whitespace-nowrap"
>Data</th
>
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
<th class="text-base-content border-base-300 border-b font-bold whitespace-nowrap"
>Responsável</th
>
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
<th class="text-base-content border-base-300 border-b font-bold whitespace-nowrap"
>Ação</th
>
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
<th class="text-base-content border-base-300 border-b font-bold whitespace-nowrap"
>Status</th
>
<th
class="text-base-content border-base-400 border-b text-right font-bold whitespace-nowrap"
class="text-base-content border-base-300 border-b text-right font-bold whitespace-nowrap"
>Ações</th
>
</tr>
@@ -191,7 +412,7 @@
<td colspan="6" class="py-12 text-center">
<div class="text-base-content/60 flex flex-col items-center gap-2">
<p class="text-lg font-semibold">Nenhum planejamento encontrado</p>
<p class="text-sm">Clique em “Novo planejamento” para criar o primeiro.</p>
<p class="text-sm">Tente ajustar os filtros ou crie um novo planejamento.</p>
</div>
</td>
</tr>