diff --git a/apps/web/src/lib/utils/pedidos/relatorioPedidosExcel.ts b/apps/web/src/lib/utils/pedidos/relatorioPedidosExcel.ts new file mode 100644 index 0000000..0ca2371 --- /dev/null +++ b/apps/web/src/lib/utils/pedidos/relatorioPedidosExcel.ts @@ -0,0 +1,377 @@ +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 RelatorioPedidosData = FunctionReturnType; + +function formatDateTime(ts: number | undefined): string { + if (!ts) return ''; + return new Date(ts).toLocaleString('pt-BR'); +} + +function argb(hex: string): { argb: string } { + return { argb: hex.replace('#', '').toUpperCase().padStart(8, 'FF') }; +} + +function statusLabel(status: string): string { + switch (status) { + case 'em_rascunho': + return 'Rascunho'; + case 'aguardando_aceite': + return 'Aguardando Aceite'; + case 'em_analise': + return 'Em Análise'; + case 'precisa_ajustes': + return 'Precisa de Ajustes'; + case 'concluido': + return 'Concluído'; + 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 + } + + // Fallback via canvas (mesmo padrão usado em outras telas) + 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 +) { + // Evitar merge sobreposto quando há poucas colunas (ex.: Resumo tem 2 colunas) + if (columnsCount <= 0) return; + + worksheet.getRow(1).height = 60; + + // Reservar espaço para logo apenas quando há 3+ colunas (A1:B1) + 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 } }); + } + + // Título + 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) { + // Sem merge para não colidir com A1:B1 + 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; + } + + // 3+ colunas: mescla C1 até última coluna para o título + 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 exportarRelatorioPedidosXLSX(relatorio: RelatorioPedidosData): 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 PEDIDOS', 2, workbook, logoBuffer); + + // Cabeçalho da tabela em linha 2 + 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: 'Número SEI (filtro)', valor: relatorio.filtros.numeroSei ?? '' }, + { + campo: 'Status (filtro)', + valor: relatorio.filtros.statuses?.map(statusLabel).join(', ') ?? '' + }, + { campo: 'Total de pedidos', valor: relatorio.resumo.totalPedidos }, + { campo: 'Total de itens', valor: relatorio.resumo.totalItens }, + { campo: 'Total de documentos', valor: relatorio.resumo.totalDocumentos }, + { campo: 'Total valor estimado', valor: relatorio.resumo.totalValorEstimado }, + { campo: 'Total valor real', valor: relatorio.resumo.totalValorReal } + ]; + + 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' + }; + }); + + // Seção: Totais por status + 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' }; + }); + + // Formatos numéricos + ws.getColumn(2).numFmt = '#,##0.00'; + ws.views = [{ state: 'frozen', ySplit: 2, topLeftCell: 'A3', activeCell: 'A3' }]; + } + + // ===== Aba: Pedidos ===== + { + const ws = workbook.addWorksheet('Pedidos'); + ws.columns = [ + { header: 'Nº SEI', key: 'numeroSei', width: 28 }, + { header: 'Status', key: 'status', width: 18 }, + { header: 'Criado por', key: 'criadoPor', width: 28 }, + { header: 'Aceito por', key: 'aceitoPor', width: 28 }, + { header: 'Criado em', key: 'criadoEm', width: 20 }, + { header: 'Concluído em', key: 'concluidoEm', width: 20 }, + { header: 'Itens', key: 'itens', width: 10 }, + { header: 'Docs', key: 'docs', width: 10 }, + { header: 'Estimado (R$)', key: 'estimado', width: 16 }, + { header: 'Real (R$)', key: 'real', width: 16 } + ]; + + addTitleRow(ws, 'RELATÓRIO DE PEDIDOS — LISTA', ws.columns.length, workbook, logoBuffer); + + const headerRow = ws.getRow(2); + headerRow.values = ws.columns.map((c) => c.header as string); + applyHeaderRowStyle(headerRow); + + relatorio.pedidos.forEach((p, idx) => { + const row = ws.addRow({ + numeroSei: p.numeroSei ?? '', + status: statusLabel(p.status), + criadoPor: p.criadoPorNome, + aceitoPor: p.aceitoPorNome ?? '', + criadoEm: p.criadoEm ? new Date(p.criadoEm) : null, + concluidoEm: p.concluidoEm ? new Date(p.concluidoEm) : null, + itens: p.itensCount, + docs: p.documentosCount, + estimado: p.valorEstimadoTotal, + real: p.valorRealTotal + }); + + applyZebraRowStyle(row, idx % 2 === 1); + + // Alinhamentos + row.getCell(2).alignment = { vertical: 'middle', horizontal: 'center' }; + row.getCell(5).alignment = { vertical: 'middle', horizontal: 'center' }; + row.getCell(6).alignment = { vertical: 'middle', horizontal: 'center' }; + row.getCell(7).alignment = { vertical: 'middle', horizontal: 'center' }; + row.getCell(8).alignment = { vertical: 'middle', horizontal: 'center' }; + row.getCell(9).alignment = { vertical: 'middle', horizontal: 'right' }; + row.getCell(10).alignment = { vertical: 'middle', horizontal: 'right' }; + + // Destaque por status + const statusCell = row.getCell(2); + if (p.status === 'concluido') { + 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') }; + } else if (p.status === 'aguardando_aceite') { + statusCell.fill = { type: 'pattern', pattern: 'solid', fgColor: argb('#FFF3CD') }; + statusCell.font = { size: 10, color: argb('#856404') }; + } + }); + + // Formatos + ws.getColumn(5).numFmt = 'dd/mm/yyyy hh:mm'; + ws.getColumn(6).numFmt = 'dd/mm/yyyy hh:mm'; + ws.getColumn(9).numFmt = '"R$" #,##0.00'; + ws.getColumn(10).numFmt = '"R$" #,##0.00'; + + ws.views = [{ state: 'frozen', ySplit: 2, topLeftCell: 'A3', activeCell: 'A3' }]; + ws.autoFilter = { + from: { row: 2, column: 1 }, + to: { row: 2, column: ws.columns.length } + }; + } + + // ===== Aba: Itens ===== + { + const ws = workbook.addWorksheet('Itens'); + ws.columns = [ + { header: 'Pedido Nº SEI', key: 'pedidoNumeroSei', width: 28 }, + { header: 'Pedido Status', key: 'pedidoStatus', width: 18 }, + { header: 'Objeto', key: 'objeto', width: 45 }, + { header: 'Modalidade', key: 'modalidade', width: 16 }, + { header: 'Qtd', key: 'qtd', width: 10 }, + { header: 'Estimado (texto)', key: 'estimadoTxt', width: 18 }, + { header: 'Real (texto)', key: 'realTxt', width: 18 }, + { header: 'Adicionado por', key: 'adicionadoPor', width: 28 }, + { header: 'Ação', key: 'acao', width: 22 }, + { header: 'Ata', key: 'ata', width: 16 }, + { header: 'Criado em', key: 'criadoEm', width: 20 } + ]; + + addTitleRow(ws, 'RELATÓRIO DE PEDIDOS — ITENS', ws.columns.length, workbook, logoBuffer); + + const headerRow = ws.getRow(2); + headerRow.values = ws.columns.map((c) => c.header as string); + applyHeaderRowStyle(headerRow); + + relatorio.itens.forEach((i, idx) => { + const row = ws.addRow({ + pedidoNumeroSei: i.pedidoNumeroSei ?? '', + pedidoStatus: statusLabel(i.pedidoStatus), + objeto: i.objetoNome ?? String(i.objetoId), + modalidade: i.modalidade, + qtd: i.quantidade, + estimadoTxt: i.valorEstimado, + realTxt: i.valorReal ?? '', + adicionadoPor: i.adicionadoPorNome, + acao: i.acaoNome ?? '', + ata: i.ataNumero ?? '', + criadoEm: i.criadoEm ? new Date(i.criadoEm) : null + }); + + applyZebraRowStyle(row, idx % 2 === 1); + row.getCell(2).alignment = { vertical: 'middle', horizontal: 'center' }; + row.getCell(4).alignment = { vertical: 'middle', horizontal: 'center' }; + row.getCell(5).alignment = { vertical: 'middle', horizontal: 'center' }; + row.getCell(11).alignment = { vertical: 'middle', horizontal: 'center' }; + }); + + ws.getColumn(11).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-pedidos-${new Date().toISOString().slice(0, 10)}.xlsx`; + const buffer = await workbook.xlsx.writeBuffer(); + downloadExcel(buffer, nomeArquivo); +} diff --git a/apps/web/src/lib/utils/pedidos/relatorioPedidosPDF.ts b/apps/web/src/lib/utils/pedidos/relatorioPedidosPDF.ts new file mode 100644 index 0000000..d46ea6d --- /dev/null +++ b/apps/web/src/lib/utils/pedidos/relatorioPedidosPDF.ts @@ -0,0 +1,211 @@ +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 RelatorioPedidosData = FunctionReturnType; + +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 getPeriodoLabel(filtros: RelatorioPedidosData['filtros']): string { + const inicio = filtros.periodoInicio ? formatDateTime(filtros.periodoInicio) : '—'; + const fim = filtros.periodoFim ? formatDateTime(filtros.periodoFim) : '—'; + return `${inicio} até ${fim}`; +} + +export function gerarRelatorioPedidosPDF(relatorio: RelatorioPedidosData): void { + const doc = new jsPDF({ orientation: 'landscape' }); + + // Título + doc.setFontSize(18); + doc.setTextColor(102, 126, 234); + doc.text('Relatório de Pedidos', 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: [['Pedidos', 'Itens', 'Documentos', 'Total Estimado', 'Total Real']], + body: [ + [ + String(relatorio.resumo.totalPedidos), + String(relatorio.resumo.totalItens), + String(relatorio.resumo.totalDocumentos), + formatCurrencyBRL(relatorio.resumo.totalValorEstimado), + formatCurrencyBRL(relatorio.resumo.totalValorReal) + ] + ], + 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; + + // Pedidos + doc.setFontSize(12); + doc.setTextColor(102, 126, 234); + doc.text('Pedidos', 14, yPos); + yPos += 6; + + const pedidosBody = relatorio.pedidos.map((p) => [ + p.numeroSei ?? '—', + p.status, + p.criadoPorNome, + p.aceitoPorNome ?? '—', + formatDateTime(p.criadoEm), + formatDateTime(p.concluidoEm), + String(p.itensCount), + String(p.documentosCount), + formatCurrencyBRL(p.valorEstimadoTotal), + formatCurrencyBRL(p.valorRealTotal) + ]); + + autoTable(doc, { + startY: yPos, + head: [ + [ + 'Nº SEI', + 'Status', + 'Criado por', + 'Aceito por', + 'Criado em', + 'Concluído em', + 'Itens', + 'Docs', + 'Estimado', + 'Real' + ] + ], + body: pedidosBody, + theme: 'grid', + headStyles: { fillColor: [102, 126, 234] }, + styles: { fontSize: 7 }, + columnStyles: { + 0: { cellWidth: 36 }, + 1: { cellWidth: 26 }, + 2: { cellWidth: 32 }, + 3: { cellWidth: 32 }, + 4: { cellWidth: 30 }, + 5: { cellWidth: 30 }, + 6: { cellWidth: 12 }, + 7: { cellWidth: 12 }, + 8: { cellWidth: 22 }, + 9: { cellWidth: 22 } + } + }); + + yPos = ((doc as JsPDFWithAutoTable).lastAutoTable?.finalY ?? yPos) + 12; + + // Itens (limitado para evitar PDF gigantesco) + const itensMax = 1000; + const itensToPrint = relatorio.itens.slice(0, itensMax); + + doc.setFontSize(12); + doc.setTextColor(102, 126, 234); + doc.text( + `Itens (${itensToPrint.length}${relatorio.itens.length > itensMax ? ` de ${relatorio.itens.length}` : ''})`, + 14, + yPos + ); + yPos += 6; + + const itensBody = itensToPrint.map((i) => [ + i.pedidoNumeroSei ?? '—', + i.pedidoStatus, + i.objetoNome ?? String(i.objetoId), + i.modalidade, + String(i.quantidade), + i.valorEstimado, + i.valorReal ?? '—', + i.adicionadoPorNome, + formatDateTime(i.criadoEm) + ]); + + autoTable(doc, { + startY: yPos, + head: [ + [ + 'Nº SEI', + 'Status', + 'Objeto', + 'Modalidade', + 'Qtd', + 'Estimado', + 'Real', + 'Adicionado por', + 'Criado em' + ] + ], + body: itensBody, + theme: 'grid', + headStyles: { fillColor: [102, 126, 234] }, + styles: { fontSize: 7 }, + columnStyles: { + 0: { cellWidth: 34 }, + 1: { cellWidth: 22 }, + 2: { cellWidth: 55 }, + 3: { cellWidth: 24 }, + 4: { cellWidth: 10 }, + 5: { cellWidth: 22 }, + 6: { cellWidth: 22 }, + 7: { cellWidth: 32 }, + 8: { cellWidth: 28 } + } + }); + + // 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-pedidos-${new Date().toISOString().slice(0, 10)}.pdf`; + doc.save(fileName); +} diff --git a/apps/web/src/routes/(dashboard)/pedidos/+page.svelte b/apps/web/src/routes/(dashboard)/pedidos/+page.svelte index a0018bc..0352f70 100644 --- a/apps/web/src/routes/(dashboard)/pedidos/+page.svelte +++ b/apps/web/src/routes/(dashboard)/pedidos/+page.svelte @@ -1,12 +1,119 @@