feat: implement advanced filtering and reporting features for pedidos, including status selection, date range filtering, and export options for PDF and XLSX formats

This commit is contained in:
2025-12-15 15:37:57 -03:00
parent c7b4ea15bd
commit fd2669aa4f
5 changed files with 1342 additions and 44 deletions

View File

@@ -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<typeof api.pedidos.gerarRelatorio>;
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<ArrayBuffer | null> {
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<void>((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<Blob>((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<void> {
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);
}

View File

@@ -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<typeof api.pedidos.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 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);
}

View File

@@ -1,12 +1,119 @@
<script lang="ts">
import { api } from '@sgse-app/backend/convex/_generated/api';
import { useQuery } from 'convex-svelte';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { exportarRelatorioPedidosXLSX } from '$lib/utils/pedidos/relatorioPedidosExcel';
import { gerarRelatorioPedidosPDF } from '$lib/utils/pedidos/relatorioPedidosPDF';
import { useConvexClient, useQuery } from 'convex-svelte';
import { endOfDay, startOfDay } from 'date-fns';
import { Eye, Plus } from 'lucide-svelte';
import { resolve } from '$app/paths';
const client = useConvexClient();
const statusOptions = [
{ value: 'em_rascunho', label: 'Rascunho' },
{ value: 'aguardando_aceite', label: 'Aguardando Aceite' },
{ value: 'em_analise', label: 'Em Análise' },
{ value: 'precisa_ajustes', label: 'Precisa de Ajustes' },
{ value: 'concluido', label: 'Concluído' },
{ value: 'cancelado', label: 'Cancelado' }
] as const;
type PedidoStatus = (typeof statusOptions)[number]['value'];
// Filtros (cumulativos / backend)
let filtroNumeroSei = $state('');
let filtroCriadoPor = $state<Id<'usuarios'> | ''>('');
let filtroAceitoPor = $state<Id<'funcionarios'> | ''>('');
let filtroInicio = $state(''); // yyyy-MM-dd
let filtroFim = $state(''); // yyyy-MM-dd
let statusSelected = $state<Record<PedidoStatus, boolean>>({
em_rascunho: false,
aguardando_aceite: false,
em_analise: false,
precisa_ajustes: false,
concluido: false,
cancelado: false
});
function getSelectedStatuses(): PedidoStatus[] | undefined {
const selected = (Object.entries(statusSelected) as Array<[PedidoStatus, 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() {
filtroNumeroSei = '';
filtroCriadoPor = '';
filtroAceitoPor = '';
filtroInicio = '';
filtroFim = '';
(Object.keys(statusSelected) as PedidoStatus[]).forEach((k) => (statusSelected[k] = false));
}
const usuariosQuery = useQuery(api.usuarios.listar, { ativo: true });
const funcionariosQuery = useQuery(api.funcionarios.getAll, {});
const filtroArgs = () => ({
statuses: getSelectedStatuses(),
numeroSei: filtroNumeroSei.trim() || undefined,
criadoPor: filtroCriadoPor ? filtroCriadoPor : undefined,
aceitoPor: filtroAceitoPor ? filtroAceitoPor : undefined,
periodoInicio: getPeriodoInicio(),
periodoFim: getPeriodoFim()
});
let generatingPDF = $state(false);
let generatingXLSX = $state(false);
async function gerarPDF() {
if (!filtroInicio && !filtroFim) {
alert('Informe um período (início e/ou fim) para gerar o relatório.');
return;
}
try {
generatingPDF = true;
const relatorio = await client.query(api.pedidos.gerarRelatorio, filtroArgs());
gerarRelatorioPedidosPDF(relatorio);
} catch (e) {
console.error('Erro ao gerar relatório PDF:', e);
alert('Erro ao gerar relatório PDF. Tente novamente.');
} finally {
generatingPDF = false;
}
}
async function exportarXLSX() {
if (!filtroInicio && !filtroFim) {
alert('Informe um período (início e/ou fim) para exportar o relatório.');
return;
}
try {
generatingXLSX = true;
const relatorio = await client.query(api.pedidos.gerarRelatorio, filtroArgs());
await exportarRelatorioPedidosXLSX(relatorio);
} catch (e) {
console.error('Erro ao exportar relatório XLSX:', e);
alert('Erro ao exportar relatório Excel. Tente novamente.');
} finally {
generatingXLSX = false;
}
}
// Reactive queries
const pedidosQuery = useQuery(api.pedidos.list, {});
const myItemsQuery = useQuery(api.pedidos.listByItemCreator, {});
const pedidosQuery = useQuery(api.pedidos.list, filtroArgs);
const myItemsQuery = useQuery(api.pedidos.listByItemCreator, filtroArgs);
const acoesQuery = useQuery(api.acoes.list, {});
let activeTab = $state<'all' | 'my_items'>('all');
@@ -69,13 +176,144 @@
<div class="container mx-auto p-6">
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-bold">Pedidos</h1>
<a
href={resolve('/pedidos/novo')}
class="flex items-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
>
<Plus size={20} />
Novo Pedido
</a>
<div class="flex items-center gap-2">
<button
type="button"
class="rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-60"
onclick={gerarPDF}
disabled={generatingPDF || generatingXLSX}
title="Gera relatório completo (PDF) no padrão do sistema"
>
{#if generatingPDF}
Gerando PDF...
{:else}
Relatório (PDF)
{/if}
</button>
<button
type="button"
class="rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-60"
onclick={exportarXLSX}
disabled={generatingPDF || generatingXLSX}
title="Exporta relatório completo em Excel (XLSX)"
>
{#if generatingXLSX}
Exportando...
{:else}
Excel (XLSX)
{/if}
</button>
<a
href={resolve('/pedidos/novo')}
class="flex items-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
>
<Plus size={20} />
Novo Pedido
</a>
</div>
</div>
<div class="mb-6 rounded-lg bg-white p-4 shadow-md">
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<div>
<label class="mb-2 block text-sm font-medium text-gray-700" for="filtro_numeroSei"
>Número SEI</label
>
<input
id="filtro_numeroSei"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
type="text"
placeholder="Buscar..."
bind:value={filtroNumeroSei}
/>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-700" for="filtro_criadoPor"
>Criado por</label
>
<select
id="filtro_criadoPor"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
bind:value={filtroCriadoPor}
>
<option value="">Todos</option>
{#each usuariosQuery.data || [] as u (u._id)}
<option value={u._id}>{u.nome}</option>
{/each}
</select>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-700" for="filtro_aceitoPor"
>Aceito por</label
>
<select
id="filtro_aceitoPor"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
bind:value={filtroAceitoPor}
>
<option value="">Todos</option>
{#each funcionariosQuery.data || [] as f (f._id)}
<option value={f._id}>{f.nome}</option>
{/each}
</select>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-700" for="filtro_inicio"
>Período (início)</label
>
<input
id="filtro_inicio"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
type="date"
bind:value={filtroInicio}
/>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-700" for="filtro_fim"
>Período (fim)</label
>
<input
id="filtro_fim"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
type="date"
bind:value={filtroFim}
/>
</div>
<div class="lg:col-span-3">
<div class="mb-2 text-sm font-medium text-gray-700">Status</div>
<div class="flex flex-wrap gap-3">
{#each statusOptions as s (s.value)}
<label class="flex items-center gap-2 text-sm">
<input
type="checkbox"
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
checked={statusSelected[s.value]}
onchange={(e) =>
(statusSelected[s.value] = (e.currentTarget as HTMLInputElement).checked)}
/>
<span>{s.label}</span>
</label>
{/each}
</div>
</div>
</div>
<div class="mt-4 flex justify-end gap-2">
<button
type="button"
class="rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
onclick={limparFiltros}
>
Limpar filtros
</button>
</div>
</div>
<div role="tablist" class="tabs tabs-bordered mb-6">