feat: implement filtering and PDF/Excel report generation for planejamentos.
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user