From 0c7412c7640b75c8c7f7b7a0940c441d31d04b0b Mon Sep 17 00:00:00 2001 From: killer-cf Date: Thu, 18 Dec 2025 17:31:55 -0300 Subject: [PATCH] feat: implement filtering and PDF/Excel report generation for planejamentos. --- .agent/rules/{svelte-rules.md => svelte.md} | 4 +- .../relatorioPlanejamentosExcel.ts | 302 ++++++++++++++++++ .../relatorioPlanejamentosPDF.ts | 140 ++++++++ .../pedidos/planejamento/+page.svelte | 239 +++++++++++++- packages/backend/convex/planejamentos.ts | 155 ++++++++- 5 files changed, 812 insertions(+), 28 deletions(-) rename .agent/rules/{svelte-rules.md => svelte.md} (92%) create mode 100644 apps/web/src/lib/utils/planejamentos/relatorioPlanejamentosExcel.ts create mode 100644 apps/web/src/lib/utils/planejamentos/relatorioPlanejamentosPDF.ts diff --git a/.agent/rules/svelte-rules.md b/.agent/rules/svelte.md similarity index 92% rename from .agent/rules/svelte-rules.md rename to .agent/rules/svelte.md index 1737d67..0d1ee91 100644 --- a/.agent/rules/svelte-rules.md +++ b/.agent/rules/svelte.md @@ -1,7 +1,5 @@ --- -trigger: model_decision -description: whenever you're working with Svelte files -globs: **/*.svelte.ts,**/*.svelte +trigger: always_on --- You are able to use the Svelte MCP server, where you have access to comprehensive Svelte 5 and SvelteKit documentation. Here's how to use the available tools effectively: diff --git a/apps/web/src/lib/utils/planejamentos/relatorioPlanejamentosExcel.ts b/apps/web/src/lib/utils/planejamentos/relatorioPlanejamentosExcel.ts new file mode 100644 index 0000000..57310b3 --- /dev/null +++ b/apps/web/src/lib/utils/planejamentos/relatorioPlanejamentosExcel.ts @@ -0,0 +1,302 @@ +import { api } from '@sgse-app/backend/convex/_generated/api'; +import type { FunctionReturnType } from 'convex/server'; +import ExcelJS from 'exceljs'; +import logoGovPE from '$lib/assets/logo_governo_PE.png'; + +export type RelatorioPlanejamentosData = FunctionReturnType< + typeof api.planejamentos.gerarRelatorio +>; + +function formatDateTime(ts: number | undefined): string { + if (!ts) return ''; + return new Date(ts).toLocaleString('pt-BR'); +} + +function formatDateYMD(ymd: string): string { + const [y, m, d] = ymd.split('-'); + if (!y || !m || !d) return ymd; + return `${d}/${m}/${y}`; +} + +function argb(hex: string): { argb: string } { + return { argb: hex.replace('#', '').toUpperCase().padStart(8, 'FF') }; +} + +function statusLabel(status: string): string { + switch (status) { + case 'rascunho': + return 'Rascunho'; + case 'gerado': + return 'Gerado'; + case 'cancelado': + return 'Cancelado'; + default: + return status; + } +} + +function applyHeaderRowStyle(row: ExcelJS.Row) { + row.height = 22; + row.eachCell((cell) => { + cell.font = { bold: true, size: 11, color: argb('#FFFFFF') }; + cell.fill = { type: 'pattern', pattern: 'solid', fgColor: argb('#2980B9') }; + cell.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true }; + cell.border = { + top: { style: 'thin', color: argb('#000000') }, + bottom: { style: 'thin', color: argb('#000000') }, + left: { style: 'thin', color: argb('#000000') }, + right: { style: 'thin', color: argb('#000000') } + }; + }); +} + +function applyZebraRowStyle(row: ExcelJS.Row, isEven: boolean) { + row.eachCell((cell) => { + cell.font = { size: 10, color: argb('#000000') }; + cell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: argb(isEven ? '#F8F9FA' : '#FFFFFF') + }; + cell.border = { + top: { style: 'thin', color: argb('#E0E0E0') }, + bottom: { style: 'thin', color: argb('#E0E0E0') }, + left: { style: 'thin', color: argb('#E0E0E0') }, + right: { style: 'thin', color: argb('#E0E0E0') } + }; + }); +} + +async function tryLoadLogoBuffer(): Promise { + try { + const response = await fetch(logoGovPE); + if (response.ok) return await response.arrayBuffer(); + } catch { + // ignore + } + + try { + const logoImg = new Image(); + logoImg.crossOrigin = 'anonymous'; + logoImg.src = logoGovPE; + await new Promise((resolve, reject) => { + logoImg.onload = () => resolve(); + logoImg.onerror = () => reject(); + setTimeout(() => reject(), 3000); + }); + + const canvas = document.createElement('canvas'); + canvas.width = logoImg.width; + canvas.height = logoImg.height; + const ctx = canvas.getContext('2d'); + if (!ctx) return null; + ctx.drawImage(logoImg, 0, 0); + const blob = await new Promise((resolve, reject) => { + canvas.toBlob( + (b) => (b ? resolve(b) : reject(new Error('Falha ao converter imagem'))), + 'image/png' + ); + }); + return await blob.arrayBuffer(); + } catch { + return null; + } +} + +function addTitleRow( + worksheet: ExcelJS.Worksheet, + title: string, + columnsCount: number, + workbook: ExcelJS.Workbook, + logoBuffer: ArrayBuffer | null +) { + if (columnsCount <= 0) return; + + worksheet.getRow(1).height = 60; + + if (columnsCount >= 3) { + worksheet.mergeCells(1, 1, 1, 2); + } + + const logoCell = worksheet.getCell(1, 1); + logoCell.alignment = { vertical: 'middle', horizontal: 'left' }; + if (columnsCount >= 3) { + logoCell.border = { right: { style: 'thin', color: argb('#E0E0E0') } }; + } + + if (logoBuffer) { + type WorkbookWithImage = { addImage(image: { buffer: Uint8Array; extension: string }): number }; + const workbookWithImage = workbook as unknown as WorkbookWithImage; + const logoId = workbookWithImage.addImage({ + buffer: new Uint8Array(logoBuffer), + extension: 'png' + }); + worksheet.addImage(logoId, { tl: { col: 0, row: 0 }, ext: { width: 140, height: 55 } }); + } + + if (columnsCount === 1) { + const titleCell = worksheet.getCell(1, 1); + titleCell.value = title; + titleCell.font = { bold: true, size: 18, color: argb('#2980B9') }; + titleCell.alignment = { vertical: 'middle', horizontal: 'center' }; + return; + } + + if (columnsCount === 2) { + const titleCell = worksheet.getCell(1, 2); + titleCell.value = title; + titleCell.font = { bold: true, size: 18, color: argb('#2980B9') }; + titleCell.alignment = { vertical: 'middle', horizontal: 'center' }; + return; + } + + worksheet.mergeCells(1, 3, 1, columnsCount); + const titleCell = worksheet.getCell(1, 3); + titleCell.value = title; + titleCell.font = { bold: true, size: 18, color: argb('#2980B9') }; + titleCell.alignment = { vertical: 'middle', horizontal: 'center' }; +} + +function downloadExcel(buffer: ArrayBuffer, fileName: string) { + const blob = new Blob([buffer], { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + }); + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = fileName; + link.click(); + window.URL.revokeObjectURL(url); +} + +export async function exportarRelatorioPlanejamentosXLSX( + relatorio: RelatorioPlanejamentosData +): Promise { + const workbook = new ExcelJS.Workbook(); + workbook.creator = 'SGSE - Sistema de Gerenciamento'; + workbook.created = new Date(); + + const logoBuffer = await tryLoadLogoBuffer(); + + // ===== Aba: Resumo ===== + { + const ws = workbook.addWorksheet('Resumo'); + ws.columns = [ + { header: 'Campo', key: 'campo', width: 35 }, + { header: 'Valor', key: 'valor', width: 30 } + ]; + + addTitleRow(ws, 'RELATÓRIO DE PLANEJAMENTOS', 2, workbook, logoBuffer); + + const headerRow = ws.getRow(2); + headerRow.values = ['Campo', 'Valor']; + applyHeaderRowStyle(headerRow); + + const rows: Array<{ campo: string; valor: string | number }> = [ + { + campo: 'Período início', + valor: relatorio.filtros.periodoInicio + ? formatDateTime(relatorio.filtros.periodoInicio) + : '' + }, + { + campo: 'Período fim', + valor: relatorio.filtros.periodoFim ? formatDateTime(relatorio.filtros.periodoFim) : '' + }, + { campo: 'Texto (filtro)', valor: relatorio.filtros.texto ?? '' }, + { + campo: 'Status (filtro)', + valor: relatorio.filtros.statuses?.map(statusLabel).join(', ') ?? '' + }, + { campo: 'Total de planejamentos', valor: relatorio.resumo.totalPlanejamentos }, + { campo: 'Total valor estimado', valor: relatorio.resumo.totalValorEstimado } + ]; + + rows.forEach((r, idx) => { + const row = ws.addRow({ campo: r.campo, valor: r.valor }); + applyZebraRowStyle(row, idx % 2 === 1); + row.getCell(2).alignment = { + vertical: 'middle', + horizontal: typeof r.valor === 'number' ? 'right' : 'left' + }; + }); + + ws.addRow({ campo: '', valor: '' }); + const sectionRow = ws.addRow({ campo: 'Totais por status', valor: '' }); + sectionRow.font = { bold: true, size: 11, color: argb('#2980B9') }; + sectionRow.alignment = { vertical: 'middle', horizontal: 'left' }; + + relatorio.resumo.totalPorStatus.forEach((s, idx) => { + const row = ws.addRow({ campo: statusLabel(s.status), valor: s.count }); + applyZebraRowStyle(row, idx % 2 === 1); + row.getCell(2).alignment = { vertical: 'middle', horizontal: 'right' }; + }); + + ws.getColumn(2).numFmt = '#,##0.00'; + ws.views = [{ state: 'frozen', ySplit: 2, topLeftCell: 'A3', activeCell: 'A3' }]; + } + + // ===== Aba: Planejamentos ===== + { + const ws = workbook.addWorksheet('Planejamentos'); + ws.columns = [ + { header: 'Título', key: 'titulo', width: 40 }, + { header: 'Status', key: 'status', width: 15 }, + { header: 'Responsável', key: 'responsavel', width: 25 }, + { header: 'Data', key: 'data', width: 15 }, + { header: 'Ação', key: 'acao', width: 25 }, + { header: 'Itens', key: 'itens', width: 10 }, + { header: 'Estimado (R$)', key: 'estimado', width: 18 }, + { header: 'Criado em', key: 'criadoEm', width: 20 } + ]; + + addTitleRow(ws, 'RELATÓRIO DE PLANEJAMENTOS — LISTA', ws.columns.length, workbook, logoBuffer); + + const headerRow = ws.getRow(2); + headerRow.values = ws.columns.map((c) => c.header as string); + applyHeaderRowStyle(headerRow); + + relatorio.planejamentos.forEach((p, idx) => { + const row = ws.addRow({ + titulo: p.titulo, + status: statusLabel(p.status), + responsavel: p.responsavelNome, + data: formatDateYMD(p.data), + acao: p.acaoNome ?? '', + itens: p.itensCount, + estimado: p.valorEstimadoTotal, + criadoEm: new Date(p.criadoEm) + }); + + applyZebraRowStyle(row, idx % 2 === 1); + + row.getCell(2).alignment = { vertical: 'middle', horizontal: 'center' }; + row.getCell(4).alignment = { vertical: 'middle', horizontal: 'center' }; + row.getCell(6).alignment = { vertical: 'middle', horizontal: 'center' }; + row.getCell(7).alignment = { vertical: 'middle', horizontal: 'right' }; + row.getCell(8).alignment = { vertical: 'middle', horizontal: 'center' }; + + const statusCell = row.getCell(2); + if (p.status === 'gerado') { + statusCell.fill = { type: 'pattern', pattern: 'solid', fgColor: argb('#D4EDDA') }; + statusCell.font = { size: 10, color: argb('#155724') }; + } else if (p.status === 'cancelado') { + statusCell.fill = { type: 'pattern', pattern: 'solid', fgColor: argb('#F8D7DA') }; + statusCell.font = { size: 10, color: argb('#721C24') }; + } + }); + + ws.getColumn(7).numFmt = '"R$" #,##0.00'; + ws.getColumn(8).numFmt = 'dd/mm/yyyy hh:mm'; + + ws.views = [{ state: 'frozen', ySplit: 2, topLeftCell: 'A3', activeCell: 'A3' }]; + ws.autoFilter = { + from: { row: 2, column: 1 }, + to: { row: 2, column: ws.columns.length } + }; + } + + const nomeArquivo = `relatorio-planejamentos-${new Date().toISOString().slice(0, 10)}.xlsx`; + const buffer = await workbook.xlsx.writeBuffer(); + downloadExcel(buffer, nomeArquivo); +} diff --git a/apps/web/src/lib/utils/planejamentos/relatorioPlanejamentosPDF.ts b/apps/web/src/lib/utils/planejamentos/relatorioPlanejamentosPDF.ts new file mode 100644 index 0000000..b19c31a --- /dev/null +++ b/apps/web/src/lib/utils/planejamentos/relatorioPlanejamentosPDF.ts @@ -0,0 +1,140 @@ +import { api } from '@sgse-app/backend/convex/_generated/api'; +import type { FunctionReturnType } from 'convex/server'; +import jsPDF from 'jspdf'; +import autoTable from 'jspdf-autotable'; + +export type RelatorioPlanejamentosData = FunctionReturnType< + typeof api.planejamentos.gerarRelatorio +>; + +function formatCurrencyBRL(n: number): string { + return n.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }); +} + +function formatDateTime(ts: number | undefined): string { + if (!ts) return '-'; + return new Date(ts).toLocaleString('pt-BR'); +} + +function formatDateYMD(ymd: string): string { + const [y, m, d] = ymd.split('-'); + if (!y || !m || !d) return ymd; + return `${d}/${m}/${y}`; +} + +function getPeriodoLabel(filtros: RelatorioPlanejamentosData['filtros']): string { + const inicio = filtros.periodoInicio ? formatDateTime(filtros.periodoInicio) : '—'; + const fim = filtros.periodoFim ? formatDateTime(filtros.periodoFim) : '—'; + return `${inicio} até ${fim}`; +} + +export function gerarRelatorioPlanejamentosPDF(relatorio: RelatorioPlanejamentosData): void { + const doc = new jsPDF({ orientation: 'landscape' }); + + // Título + doc.setFontSize(18); + doc.setTextColor(102, 126, 234); + doc.text('Relatório de Planejamentos', 14, 18); + + // Subtítulo + doc.setFontSize(11); + doc.setTextColor(0, 0, 0); + doc.text(`Período: ${getPeriodoLabel(relatorio.filtros)}`, 14, 26); + doc.setFontSize(9); + doc.text(`Gerado em: ${new Date().toLocaleString('pt-BR')}`, 14, 32); + + let yPos = 40; + + // Resumo + doc.setFontSize(12); + doc.setTextColor(102, 126, 234); + doc.text('Resumo', 14, yPos); + yPos += 6; + + autoTable(doc, { + startY: yPos, + head: [['Planejamentos', 'Total Estimado']], + body: [ + [ + String(relatorio.resumo.totalPlanejamentos), + formatCurrencyBRL(relatorio.resumo.totalValorEstimado) + ] + ], + theme: 'striped', + headStyles: { fillColor: [102, 126, 234] }, + styles: { fontSize: 9 } + }); + + type JsPDFWithAutoTable = jsPDF & { lastAutoTable?: { finalY: number } }; + yPos = ((doc as JsPDFWithAutoTable).lastAutoTable?.finalY ?? yPos) + 10; + + // Totais por status + doc.setFontSize(12); + doc.setTextColor(102, 126, 234); + doc.text('Totais por status', 14, yPos); + yPos += 6; + + autoTable(doc, { + startY: yPos, + head: [['Status', 'Quantidade']], + body: relatorio.resumo.totalPorStatus.map((s) => [s.status, String(s.count)]), + theme: 'grid', + headStyles: { fillColor: [102, 126, 234] }, + styles: { fontSize: 9 } + }); + + yPos = ((doc as JsPDFWithAutoTable).lastAutoTable?.finalY ?? yPos) + 12; + + // Planejamentos + doc.setFontSize(12); + doc.setTextColor(102, 126, 234); + doc.text('Planejamentos', 14, yPos); + yPos += 6; + + const planejamentosBody = relatorio.planejamentos.map((p) => [ + p.titulo, + p.status, + p.responsavelNome, + p.acaoNome ?? '—', + formatDateYMD(p.data), + String(p.itensCount), + formatCurrencyBRL(p.valorEstimadoTotal), + formatDateTime(p.criadoEm) + ]); + + autoTable(doc, { + startY: yPos, + head: [['Título', 'Status', 'Responsável', 'Ação', 'Data', 'Itens', 'Estimado', 'Criado em']], + body: planejamentosBody, + theme: 'grid', + headStyles: { fillColor: [102, 126, 234] }, + styles: { fontSize: 8 }, + columnStyles: { + 0: { cellWidth: 60 }, + 1: { cellWidth: 20 }, + 2: { cellWidth: 40 }, + 3: { cellWidth: 40 }, + 4: { cellWidth: 25 }, + 5: { cellWidth: 15 }, + 6: { cellWidth: 30 }, + 7: { cellWidth: 30 } + } + }); + + // Footer com paginação + const pageCount = doc.getNumberOfPages(); + for (let i = 1; i <= pageCount; i++) { + doc.setPage(i); + doc.setFontSize(8); + doc.setTextColor(128, 128, 128); + doc.text( + `SGSE - Sistema de Gerenciamento de Secretaria | Página ${i} de ${pageCount}`, + doc.internal.pageSize.getWidth() / 2, + doc.internal.pageSize.getHeight() - 8, + { align: 'center' } + ); + } + + const fileName = `relatorio-planejamentos-${new Date().toISOString().slice(0, 10)}.pdf`; + doc.save(fileName); +} diff --git a/apps/web/src/routes/(dashboard)/pedidos/planejamento/+page.svelte b/apps/web/src/routes/(dashboard)/pedidos/planejamento/+page.svelte index 60af1f3..04f1a82 100644 --- a/apps/web/src/routes/(dashboard)/pedidos/planejamento/+page.svelte +++ b/apps/web/src/routes/(dashboard)/pedidos/planejamento/+page.svelte @@ -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 | ''>(''); + let filtroAcao = $state | ''>(''); + 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>({ + 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()} + + + + + + + + {#if planejamentosQuery.isLoading}
@@ -164,23 +385,23 @@ - - - - - @@ -191,7 +412,7 @@ diff --git a/packages/backend/convex/planejamentos.ts b/packages/backend/convex/planejamentos.ts index 4eb0c1c..62fe780 100644 --- a/packages/backend/convex/planejamentos.ts +++ b/packages/backend/convex/planejamentos.ts @@ -19,26 +19,47 @@ function normalizeOptionalString(value: string | undefined): string | undefined export const list = query({ args: { status: v.optional(v.union(v.literal('rascunho'), v.literal('gerado'), v.literal('cancelado'))), - responsavelId: v.optional(v.id('funcionarios')) + statuses: v.optional( + v.array(v.union(v.literal('rascunho'), v.literal('gerado'), v.literal('cancelado'))) + ), + responsavelId: v.optional(v.id('funcionarios')), + acaoId: v.optional(v.id('acoes')), + texto: v.optional(v.string()), + periodoInicio: v.optional(v.number()), + periodoFim: v.optional(v.number()) }, handler: async (ctx, args) => { - const status = args.status; - const responsavelId = args.responsavelId; + const { periodoInicio, periodoFim, texto } = args; - let base: Doc<'planejamentosPedidos'>[] = []; + let base = await ctx.db.query('planejamentosPedidos').collect(); - if (responsavelId) { - base = await ctx.db - .query('planejamentosPedidos') - .withIndex('by_responsavelId', (q) => q.eq('responsavelId', responsavelId)) - .collect(); - } else if (status) { - base = await ctx.db - .query('planejamentosPedidos') - .withIndex('by_status', (q) => q.eq('status', status)) - .collect(); - } else { - base = await ctx.db.query('planejamentosPedidos').collect(); + // Filtros em memória (devido à complexidade de múltiplos índices) + if (args.responsavelId) { + base = base.filter((p) => p.responsavelId === args.responsavelId); + } + if (args.acaoId) { + base = base.filter((p) => p.acaoId === args.acaoId); + } + + // Status simples ou múltiplo + if (args.statuses && args.statuses.length > 0) { + base = base.filter((p) => args.statuses!.includes(p.status)); + } else if (args.status) { + base = base.filter((p) => p.status === args.status); + } + + if (periodoInicio) { + base = base.filter((p) => p.data >= new Date(periodoInicio).toISOString().split('T')[0]); + } + if (periodoFim) { + base = base.filter((p) => p.data <= new Date(periodoFim).toISOString().split('T')[0]); + } + + if (texto) { + const t = texto.toLowerCase(); + base = base.filter( + (p) => p.titulo.toLowerCase().includes(t) || p.descricao.toLowerCase().includes(t) + ); } base.sort((a, b) => b.criadoEm - a.criadoEm); @@ -59,6 +80,108 @@ export const list = query({ } }); +export const gerarRelatorio = query({ + args: { + statuses: v.optional( + v.array(v.union(v.literal('rascunho'), v.literal('gerado'), v.literal('cancelado'))) + ), + responsavelId: v.optional(v.id('funcionarios')), + acaoId: v.optional(v.id('acoes')), + texto: v.optional(v.string()), + periodoInicio: v.optional(v.number()), + periodoFim: v.optional(v.number()) + }, + handler: async (ctx, args) => { + // Reutilizar lógica de filtro + let base = await ctx.db.query('planejamentosPedidos').collect(); + + if (args.responsavelId) { + base = base.filter((p) => p.responsavelId === args.responsavelId); + } + if (args.acaoId) { + base = base.filter((p) => p.acaoId === args.acaoId); + } + if (args.statuses && args.statuses.length > 0) { + base = base.filter((p) => args.statuses!.includes(p.status)); + } + if (args.periodoInicio) { + base = base.filter( + (p) => p.data >= new Date(args.periodoInicio!).toISOString().split('T')[0] + ); + } + if (args.periodoFim) { + base = base.filter((p) => p.data <= new Date(args.periodoFim!).toISOString().split('T')[0]); + } + if (args.texto) { + const t = args.texto.toLowerCase(); + base = base.filter( + (p) => p.titulo.toLowerCase().includes(t) || p.descricao.toLowerCase().includes(t) + ); + } + + base.sort((a, b) => b.criadoEm - a.criadoEm); + + // Enriquecer dados + const planejamentosEnriquecidos = await Promise.all( + base.map(async (p) => { + const [responsavel, acao, itens] = await Promise.all([ + ctx.db.get(p.responsavelId), + p.acaoId ? ctx.db.get(p.acaoId) : Promise.resolve(null), + ctx.db + .query('planejamentoItens') + .withIndex('by_planejamentoId', (q) => q.eq('planejamentoId', p._id)) + .collect() + ]); + + let valorEstimadoTotal = 0; + for (const item of itens) { + // Corrigir string '1.000,00' -> number + const val = parseFloat( + item.valorEstimado.replace(/\./g, '').replace(',', '.').replace('R$', '').trim() + ); + if (!isNaN(val)) valorEstimadoTotal += val * item.quantidade; + } + + return { + ...p, + responsavelNome: responsavel?.nome ?? 'Desconhecido', + acaoNome: acao?.nome ?? undefined, + itensCount: itens.length, + valorEstimadoTotal + }; + }) + ); + + // Calcular resumo + const totalPlanejamentos = base.length; + const totalValorEstimado = planejamentosEnriquecidos.reduce( + (acc, curr) => acc + curr.valorEstimadoTotal, + 0 + ); + + const totalPorStatus = [ + { status: 'rascunho', count: 0 }, + { status: 'gerado', count: 0 }, + { status: 'cancelado', count: 0 } + ]; + + base.forEach((p) => { + const st = totalPorStatus.find((s) => s.status === p.status); + if (st) st.count++; + }); + + return { + filtros: args, + resumo: { + totalPlanejamentos, + totalValorEstimado, + totalPorStatus + }, + planejamentos: planejamentosEnriquecidos + }; + } +}); + export const get = query({ args: { id: v.id('planejamentosPedidos') }, handler: async (ctx, args) => {
TítuloDataResponsávelAçãoStatus Ações

Nenhum planejamento encontrado

-

Clique em “Novo planejamento” para criar o primeiro.

+

Tente ajustar os filtros ou crie um novo planejamento.