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:
377
apps/web/src/lib/utils/pedidos/relatorioPedidosExcel.ts
Normal file
377
apps/web/src/lib/utils/pedidos/relatorioPedidosExcel.ts
Normal 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);
|
||||||
|
}
|
||||||
211
apps/web/src/lib/utils/pedidos/relatorioPedidosPDF.ts
Normal file
211
apps/web/src/lib/utils/pedidos/relatorioPedidosPDF.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -1,12 +1,119 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
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 { Eye, Plus } from 'lucide-svelte';
|
||||||
import { resolve } from '$app/paths';
|
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
|
// Reactive queries
|
||||||
const pedidosQuery = useQuery(api.pedidos.list, {});
|
const pedidosQuery = useQuery(api.pedidos.list, filtroArgs);
|
||||||
const myItemsQuery = useQuery(api.pedidos.listByItemCreator, {});
|
const myItemsQuery = useQuery(api.pedidos.listByItemCreator, filtroArgs);
|
||||||
const acoesQuery = useQuery(api.acoes.list, {});
|
const acoesQuery = useQuery(api.acoes.list, {});
|
||||||
|
|
||||||
let activeTab = $state<'all' | 'my_items'>('all');
|
let activeTab = $state<'all' | 'my_items'>('all');
|
||||||
@@ -69,6 +176,35 @@
|
|||||||
<div class="container mx-auto p-6">
|
<div class="container mx-auto p-6">
|
||||||
<div class="mb-6 flex items-center justify-between">
|
<div class="mb-6 flex items-center justify-between">
|
||||||
<h1 class="text-2xl font-bold">Pedidos</h1>
|
<h1 class="text-2xl font-bold">Pedidos</h1>
|
||||||
|
<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
|
<a
|
||||||
href={resolve('/pedidos/novo')}
|
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"
|
class="flex items-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
|
||||||
@@ -77,6 +213,108 @@
|
|||||||
Novo Pedido
|
Novo Pedido
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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">
|
<div role="tablist" class="tabs tabs-bordered mb-6">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -7,6 +7,151 @@ import { getCurrentUserFunction } from './auth';
|
|||||||
|
|
||||||
// ========== HELPERS ==========
|
// ========== HELPERS ==========
|
||||||
|
|
||||||
|
const pedidoStatusValidator = v.union(
|
||||||
|
v.literal('em_rascunho'),
|
||||||
|
v.literal('aguardando_aceite'),
|
||||||
|
v.literal('em_analise'),
|
||||||
|
v.literal('precisa_ajustes'),
|
||||||
|
v.literal('cancelado'),
|
||||||
|
v.literal('concluido')
|
||||||
|
);
|
||||||
|
|
||||||
|
type PedidoStatus = Doc<'pedidos'>['status'];
|
||||||
|
|
||||||
|
type PedidoListFilters = {
|
||||||
|
statuses?: PedidoStatus[];
|
||||||
|
numeroSei?: string;
|
||||||
|
criadoPor?: Id<'usuarios'>;
|
||||||
|
aceitoPor?: Id<'funcionarios'>;
|
||||||
|
periodoInicio?: number;
|
||||||
|
periodoFim?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function inRange(ts: number, inicio?: number, fim?: number): boolean {
|
||||||
|
if (inicio !== undefined && ts < inicio) return false;
|
||||||
|
if (fim !== undefined && ts > fim) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesPeriodo(p: Doc<'pedidos'>, inicio?: number, fim?: number): boolean {
|
||||||
|
if (inicio === undefined && fim === undefined) return true;
|
||||||
|
if (inRange(p.criadoEm, inicio, fim)) return true;
|
||||||
|
if (p.concluidoEm !== undefined && inRange(p.concluidoEm, inicio, fim)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNumeroSeiQuery(q: string | undefined): string | undefined {
|
||||||
|
const trimmed = q?.trim();
|
||||||
|
return trimmed ? trimmed.toLowerCase() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPedidoFilters(pedidos: Doc<'pedidos'>[], args: PedidoListFilters): Doc<'pedidos'>[] {
|
||||||
|
const numeroSeiQuery = normalizeNumeroSeiQuery(args.numeroSei);
|
||||||
|
const statusesSet =
|
||||||
|
args.statuses && args.statuses.length > 0 ? new Set<PedidoStatus>(args.statuses) : null;
|
||||||
|
|
||||||
|
const filtered = pedidos.filter((p) => {
|
||||||
|
if (statusesSet && !statusesSet.has(p.status)) return false;
|
||||||
|
if (args.criadoPor && p.criadoPor !== args.criadoPor) return false;
|
||||||
|
if (args.aceitoPor && p.aceitoPor !== args.aceitoPor) return false;
|
||||||
|
|
||||||
|
if (!matchesPeriodo(p, args.periodoInicio, args.periodoFim)) return false;
|
||||||
|
|
||||||
|
if (numeroSeiQuery) {
|
||||||
|
const sei = (p.numeroSei ?? '').toLowerCase();
|
||||||
|
if (!sei.includes(numeroSeiQuery)) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ordem mais útil para listagem: mais recentes primeiro
|
||||||
|
filtered.sort((a, b) => b.criadoEm - a.criadoEm);
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupePedidos(pedidos: Doc<'pedidos'>[]): Doc<'pedidos'>[] {
|
||||||
|
const map = new Map<string, Doc<'pedidos'>>();
|
||||||
|
for (const p of pedidos) map.set(String(p._id), p);
|
||||||
|
return [...map.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPedidosBase(ctx: QueryCtx, args: PedidoListFilters): Promise<Doc<'pedidos'>[]> {
|
||||||
|
// 1) Se há período, buscamos por índices de criadoEm e concluidoEm e unimos (OR)
|
||||||
|
if (args.periodoInicio !== undefined || args.periodoFim !== undefined) {
|
||||||
|
const inicio = args.periodoInicio;
|
||||||
|
const fim = args.periodoFim;
|
||||||
|
|
||||||
|
const byCriado =
|
||||||
|
inicio !== undefined && fim !== undefined
|
||||||
|
? await ctx.db
|
||||||
|
.query('pedidos')
|
||||||
|
.withIndex('by_criadoEm', (q) => q.gte('criadoEm', inicio).lte('criadoEm', fim))
|
||||||
|
.collect()
|
||||||
|
: inicio !== undefined
|
||||||
|
? await ctx.db
|
||||||
|
.query('pedidos')
|
||||||
|
.withIndex('by_criadoEm', (q) => q.gte('criadoEm', inicio))
|
||||||
|
.collect()
|
||||||
|
: await ctx.db
|
||||||
|
.query('pedidos')
|
||||||
|
.withIndex('by_criadoEm', (q) => q.lte('criadoEm', fim as number))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
const byConcluido =
|
||||||
|
inicio !== undefined && fim !== undefined
|
||||||
|
? await ctx.db
|
||||||
|
.query('pedidos')
|
||||||
|
.withIndex('by_concluidoEm', (q) =>
|
||||||
|
q.gte('concluidoEm', inicio).lte('concluidoEm', fim)
|
||||||
|
)
|
||||||
|
.collect()
|
||||||
|
: inicio !== undefined
|
||||||
|
? await ctx.db
|
||||||
|
.query('pedidos')
|
||||||
|
.withIndex('by_concluidoEm', (q) => q.gte('concluidoEm', inicio))
|
||||||
|
.collect()
|
||||||
|
: await ctx.db
|
||||||
|
.query('pedidos')
|
||||||
|
.withIndex('by_concluidoEm', (q) => q.lte('concluidoEm', fim as number))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
return dedupePedidos(byCriado.concat(byConcluido));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Se há statuses selecionados, usar o índice by_status
|
||||||
|
if (args.statuses && args.statuses.length > 0) {
|
||||||
|
let out: Doc<'pedidos'>[] = [];
|
||||||
|
for (const status of args.statuses) {
|
||||||
|
const part = await ctx.db
|
||||||
|
.query('pedidos')
|
||||||
|
.withIndex('by_status', (q) => q.eq('status', status))
|
||||||
|
.collect();
|
||||||
|
out = out.concat(part);
|
||||||
|
}
|
||||||
|
return dedupePedidos(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Se há criador, usar by_criadoPor
|
||||||
|
if (args.criadoPor) {
|
||||||
|
return await ctx.db
|
||||||
|
.query('pedidos')
|
||||||
|
.withIndex('by_criadoPor', (q) => q.eq('criadoPor', args.criadoPor!))
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) Se há aceitoPor, usar by_aceitoPor
|
||||||
|
if (args.aceitoPor) {
|
||||||
|
return await ctx.db
|
||||||
|
.query('pedidos')
|
||||||
|
.withIndex('by_aceitoPor', (q) => q.eq('aceitoPor', args.aceitoPor!))
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5) fallback: varrer (caso sem índices úteis)
|
||||||
|
return await ctx.db.query('pedidos').collect();
|
||||||
|
}
|
||||||
|
|
||||||
async function getUsuarioAutenticado(ctx: QueryCtx | MutationCtx) {
|
async function getUsuarioAutenticado(ctx: QueryCtx | MutationCtx) {
|
||||||
const user = await getCurrentUserFunction(ctx);
|
const user = await getCurrentUserFunction(ctx);
|
||||||
if (!user) throw new Error('Unauthorized');
|
if (!user) throw new Error('Unauthorized');
|
||||||
@@ -105,30 +250,55 @@ async function assertPodeGerenciarDocumentosDoPedido(
|
|||||||
// ========== QUERIES ==========
|
// ========== QUERIES ==========
|
||||||
|
|
||||||
export const list = query({
|
export const list = query({
|
||||||
args: {},
|
args: {
|
||||||
|
statuses: v.optional(v.array(pedidoStatusValidator)),
|
||||||
|
numeroSei: v.optional(v.string()),
|
||||||
|
criadoPor: v.optional(v.id('usuarios')),
|
||||||
|
aceitoPor: v.optional(v.id('funcionarios')),
|
||||||
|
periodoInicio: v.optional(v.number()),
|
||||||
|
periodoFim: v.optional(v.number())
|
||||||
|
},
|
||||||
returns: v.array(
|
returns: v.array(
|
||||||
v.object({
|
v.object({
|
||||||
_id: v.id('pedidos'),
|
_id: v.id('pedidos'),
|
||||||
_creationTime: v.number(),
|
_creationTime: v.number(),
|
||||||
numeroSei: v.optional(v.string()),
|
numeroSei: v.optional(v.string()),
|
||||||
status: v.union(
|
status: pedidoStatusValidator,
|
||||||
v.literal('em_rascunho'),
|
|
||||||
v.literal('aguardando_aceite'),
|
|
||||||
v.literal('em_analise'),
|
|
||||||
v.literal('precisa_ajustes'),
|
|
||||||
v.literal('cancelado'),
|
|
||||||
v.literal('concluido')
|
|
||||||
),
|
|
||||||
// acaoId removed from return
|
// acaoId removed from return
|
||||||
criadoPor: v.id('usuarios'),
|
criadoPor: v.id('usuarios'),
|
||||||
|
criadoPorNome: v.string(),
|
||||||
aceitoPor: v.optional(v.id('funcionarios')),
|
aceitoPor: v.optional(v.id('funcionarios')),
|
||||||
|
aceitoPorNome: v.optional(v.string()),
|
||||||
descricaoAjuste: v.optional(v.string()),
|
descricaoAjuste: v.optional(v.string()),
|
||||||
|
concluidoEm: v.optional(v.number()),
|
||||||
criadoEm: v.number(),
|
criadoEm: v.number(),
|
||||||
atualizadoEm: v.number()
|
atualizadoEm: v.number()
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
handler: async (ctx) => {
|
handler: async (ctx, args) => {
|
||||||
return await ctx.db.query('pedidos').collect();
|
const base = await fetchPedidosBase(ctx, args);
|
||||||
|
const pedidos = applyPedidoFilters(base, args);
|
||||||
|
|
||||||
|
return await Promise.all(
|
||||||
|
pedidos.map(async (p) => {
|
||||||
|
const creator = await ctx.db.get(p.criadoPor);
|
||||||
|
const aceito = p.aceitoPor ? await ctx.db.get(p.aceitoPor) : null;
|
||||||
|
return {
|
||||||
|
_id: p._id,
|
||||||
|
_creationTime: p._creationTime,
|
||||||
|
numeroSei: p.numeroSei,
|
||||||
|
status: p.status,
|
||||||
|
criadoPor: p.criadoPor,
|
||||||
|
criadoPorNome: creator?.nome || 'Desconhecido',
|
||||||
|
aceitoPor: p.aceitoPor,
|
||||||
|
aceitoPorNome: aceito?.nome || undefined,
|
||||||
|
descricaoAjuste: p.descricaoAjuste,
|
||||||
|
concluidoEm: p.concluidoEm,
|
||||||
|
criadoEm: p.criadoEm,
|
||||||
|
atualizadoEm: p.atualizadoEm
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -139,18 +309,12 @@ export const get = query({
|
|||||||
_id: v.id('pedidos'),
|
_id: v.id('pedidos'),
|
||||||
_creationTime: v.number(),
|
_creationTime: v.number(),
|
||||||
numeroSei: v.optional(v.string()),
|
numeroSei: v.optional(v.string()),
|
||||||
status: v.union(
|
status: pedidoStatusValidator,
|
||||||
v.literal('em_rascunho'),
|
|
||||||
v.literal('aguardando_aceite'),
|
|
||||||
v.literal('em_analise'),
|
|
||||||
v.literal('precisa_ajustes'),
|
|
||||||
v.literal('cancelado'),
|
|
||||||
v.literal('concluido')
|
|
||||||
),
|
|
||||||
acaoId: v.optional(v.id('acoes')),
|
acaoId: v.optional(v.id('acoes')),
|
||||||
criadoPor: v.id('usuarios'),
|
criadoPor: v.id('usuarios'),
|
||||||
aceitoPor: v.optional(v.id('funcionarios')),
|
aceitoPor: v.optional(v.id('funcionarios')),
|
||||||
descricaoAjuste: v.optional(v.string()),
|
descricaoAjuste: v.optional(v.string()),
|
||||||
|
concluidoEm: v.optional(v.number()),
|
||||||
criadoEm: v.number(),
|
criadoEm: v.number(),
|
||||||
atualizadoEm: v.number()
|
atualizadoEm: v.number()
|
||||||
}),
|
}),
|
||||||
@@ -493,21 +657,31 @@ export const listMyAnalysis = query({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const listByItemCreator = query({
|
export const listByItemCreator = query({
|
||||||
args: {},
|
args: {
|
||||||
|
statuses: v.optional(v.array(pedidoStatusValidator)),
|
||||||
|
numeroSei: v.optional(v.string()),
|
||||||
|
criadoPor: v.optional(v.id('usuarios')),
|
||||||
|
aceitoPor: v.optional(v.id('funcionarios')),
|
||||||
|
periodoInicio: v.optional(v.number()),
|
||||||
|
periodoFim: v.optional(v.number())
|
||||||
|
},
|
||||||
returns: v.array(
|
returns: v.array(
|
||||||
v.object({
|
v.object({
|
||||||
_id: v.id('pedidos'),
|
_id: v.id('pedidos'),
|
||||||
_creationTime: v.number(),
|
_creationTime: v.number(),
|
||||||
numeroSei: v.optional(v.string()),
|
numeroSei: v.optional(v.string()),
|
||||||
status: v.string(),
|
status: pedidoStatusValidator,
|
||||||
aceitoPor: v.optional(v.id('funcionarios')),
|
aceitoPor: v.optional(v.id('funcionarios')),
|
||||||
criadoPor: v.id('usuarios'),
|
criadoPor: v.id('usuarios'),
|
||||||
criadoPorNome: v.string(),
|
criadoPorNome: v.string(),
|
||||||
|
aceitoPorNome: v.optional(v.string()),
|
||||||
|
descricaoAjuste: v.optional(v.string()),
|
||||||
|
concluidoEm: v.optional(v.number()),
|
||||||
criadoEm: v.number(),
|
criadoEm: v.number(),
|
||||||
atualizadoEm: v.number()
|
atualizadoEm: v.number()
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
handler: async (ctx) => {
|
handler: async (ctx, args) => {
|
||||||
const user = await getUsuarioAutenticado(ctx);
|
const user = await getUsuarioAutenticado(ctx);
|
||||||
if (!user.funcionarioId) return [];
|
if (!user.funcionarioId) return [];
|
||||||
|
|
||||||
@@ -525,27 +699,320 @@ export const listByItemCreator = query({
|
|||||||
|
|
||||||
// Filter out nulls and enrich
|
// Filter out nulls and enrich
|
||||||
const validOrders = orders.filter((o) => o !== null);
|
const validOrders = orders.filter((o) => o !== null);
|
||||||
|
const filtered = applyPedidoFilters(validOrders as Doc<'pedidos'>[], args);
|
||||||
|
|
||||||
return await Promise.all(
|
return await Promise.all(
|
||||||
validOrders.map(async (o) => {
|
filtered.map(async (o) => {
|
||||||
const creator = await ctx.db.get(o!.criadoPor);
|
const creator = await ctx.db.get(o.criadoPor);
|
||||||
|
const aceito = o.aceitoPor ? await ctx.db.get(o.aceitoPor) : null;
|
||||||
return {
|
return {
|
||||||
_id: o!._id,
|
_id: o._id,
|
||||||
_creationTime: o!._creationTime,
|
_creationTime: o._creationTime,
|
||||||
numeroSei: o!.numeroSei,
|
numeroSei: o.numeroSei,
|
||||||
status: o!.status,
|
status: o.status,
|
||||||
criadoPor: o!.criadoPor,
|
criadoPor: o.criadoPor,
|
||||||
criadoPorNome: creator?.nome || 'Desconhecido',
|
criadoPorNome: creator?.nome || 'Desconhecido',
|
||||||
aceitoPor: o!.aceitoPor,
|
aceitoPor: o.aceitoPor,
|
||||||
descricaoAjuste: o!.descricaoAjuste,
|
aceitoPorNome: aceito?.nome || undefined,
|
||||||
criadoEm: o!.criadoEm,
|
descricaoAjuste: o.descricaoAjuste,
|
||||||
atualizadoEm: o!.atualizadoEm
|
concluidoEm: o.concluidoEm,
|
||||||
|
criadoEm: o.criadoEm,
|
||||||
|
atualizadoEm: o.atualizadoEm
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const gerarRelatorio = query({
|
||||||
|
args: {
|
||||||
|
statuses: v.optional(v.array(pedidoStatusValidator)),
|
||||||
|
numeroSei: v.optional(v.string()),
|
||||||
|
criadoPor: v.optional(v.id('usuarios')),
|
||||||
|
aceitoPor: v.optional(v.id('funcionarios')),
|
||||||
|
periodoInicio: v.optional(v.number()),
|
||||||
|
periodoFim: v.optional(v.number())
|
||||||
|
},
|
||||||
|
returns: v.object({
|
||||||
|
filtros: v.object({
|
||||||
|
statuses: v.optional(v.array(pedidoStatusValidator)),
|
||||||
|
numeroSei: v.optional(v.string()),
|
||||||
|
criadoPor: v.optional(v.id('usuarios')),
|
||||||
|
aceitoPor: v.optional(v.id('funcionarios')),
|
||||||
|
periodoInicio: v.optional(v.number()),
|
||||||
|
periodoFim: v.optional(v.number())
|
||||||
|
}),
|
||||||
|
resumo: v.object({
|
||||||
|
totalPedidos: v.number(),
|
||||||
|
totalItens: v.number(),
|
||||||
|
totalDocumentos: v.number(),
|
||||||
|
totalPorStatus: v.array(v.object({ status: pedidoStatusValidator, count: v.number() })),
|
||||||
|
totalValorEstimado: v.number(),
|
||||||
|
totalValorReal: v.number()
|
||||||
|
}),
|
||||||
|
pedidos: v.array(
|
||||||
|
v.object({
|
||||||
|
_id: v.id('pedidos'),
|
||||||
|
_creationTime: v.number(),
|
||||||
|
numeroSei: v.optional(v.string()),
|
||||||
|
status: pedidoStatusValidator,
|
||||||
|
criadoPor: v.id('usuarios'),
|
||||||
|
criadoPorNome: v.string(),
|
||||||
|
aceitoPor: v.optional(v.id('funcionarios')),
|
||||||
|
aceitoPorNome: v.optional(v.string()),
|
||||||
|
descricaoAjuste: v.optional(v.string()),
|
||||||
|
criadoEm: v.number(),
|
||||||
|
concluidoEm: v.optional(v.number()),
|
||||||
|
atualizadoEm: v.number(),
|
||||||
|
itensCount: v.number(),
|
||||||
|
documentosCount: v.number(),
|
||||||
|
valorEstimadoTotal: v.number(),
|
||||||
|
valorRealTotal: v.number()
|
||||||
|
})
|
||||||
|
),
|
||||||
|
itens: v.array(
|
||||||
|
v.object({
|
||||||
|
_id: v.id('objetoItems'),
|
||||||
|
pedidoId: v.id('pedidos'),
|
||||||
|
pedidoNumeroSei: v.optional(v.string()),
|
||||||
|
pedidoStatus: pedidoStatusValidator,
|
||||||
|
objetoId: v.id('objetos'),
|
||||||
|
objetoNome: v.optional(v.string()),
|
||||||
|
ataId: v.optional(v.id('atas')),
|
||||||
|
ataNumero: v.optional(v.string()),
|
||||||
|
acaoId: v.optional(v.id('acoes')),
|
||||||
|
acaoNome: v.optional(v.string()),
|
||||||
|
modalidade: v.union(
|
||||||
|
v.literal('dispensa'),
|
||||||
|
v.literal('inexgibilidade'),
|
||||||
|
v.literal('adesao'),
|
||||||
|
v.literal('consumo')
|
||||||
|
),
|
||||||
|
quantidade: v.number(),
|
||||||
|
valorEstimado: v.string(),
|
||||||
|
valorReal: v.optional(v.string()),
|
||||||
|
adicionadoPor: v.id('funcionarios'),
|
||||||
|
adicionadoPorNome: v.string(),
|
||||||
|
criadoEm: v.number()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
// Para relatório "por período", exigimos pelo menos um limite (início ou fim)
|
||||||
|
if (args.periodoInicio === undefined && args.periodoFim === undefined) {
|
||||||
|
throw new Error('Informe um período (início e/ou fim) para gerar o relatório.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = await fetchPedidosBase(ctx, args);
|
||||||
|
const pedidosFiltrados = applyPedidoFilters(base, args);
|
||||||
|
|
||||||
|
// Guardrail para evitar timeouts/relatórios gigantescos
|
||||||
|
if (pedidosFiltrados.length > 500) {
|
||||||
|
throw new Error(
|
||||||
|
`Relatório muito grande (${pedidosFiltrados.length} pedidos). Reduza o período/filtros e tente novamente.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache para evitar múltiplos gets repetidos
|
||||||
|
const cacheUsuarios = new Map<string, Doc<'usuarios'> | null>();
|
||||||
|
const cacheFuncionarios = new Map<string, Doc<'funcionarios'> | null>();
|
||||||
|
const cacheObjetos = new Map<string, Doc<'objetos'> | null>();
|
||||||
|
const cacheAtas = new Map<string, Doc<'atas'> | null>();
|
||||||
|
const cacheAcoes = new Map<string, Doc<'acoes'> | null>();
|
||||||
|
|
||||||
|
async function getUsuario(id: Id<'usuarios'>): Promise<Doc<'usuarios'> | null> {
|
||||||
|
const k = String(id);
|
||||||
|
if (cacheUsuarios.has(k)) return cacheUsuarios.get(k) ?? null;
|
||||||
|
const doc = await ctx.db.get(id);
|
||||||
|
cacheUsuarios.set(k, doc);
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
async function getFuncionario(id: Id<'funcionarios'>): Promise<Doc<'funcionarios'> | null> {
|
||||||
|
const k = String(id);
|
||||||
|
if (cacheFuncionarios.has(k)) return cacheFuncionarios.get(k) ?? null;
|
||||||
|
const doc = await ctx.db.get(id);
|
||||||
|
cacheFuncionarios.set(k, doc);
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
async function getObjeto(id: Id<'objetos'>): Promise<Doc<'objetos'> | null> {
|
||||||
|
const k = String(id);
|
||||||
|
if (cacheObjetos.has(k)) return cacheObjetos.get(k) ?? null;
|
||||||
|
const doc = await ctx.db.get(id);
|
||||||
|
cacheObjetos.set(k, doc);
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
async function getAta(id: Id<'atas'>): Promise<Doc<'atas'> | null> {
|
||||||
|
const k = String(id);
|
||||||
|
if (cacheAtas.has(k)) return cacheAtas.get(k) ?? null;
|
||||||
|
const doc = await ctx.db.get(id);
|
||||||
|
cacheAtas.set(k, doc);
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
async function getAcao(id: Id<'acoes'>): Promise<Doc<'acoes'> | null> {
|
||||||
|
const k = String(id);
|
||||||
|
if (cacheAcoes.has(k)) return cacheAcoes.get(k) ?? null;
|
||||||
|
const doc = await ctx.db.get(id);
|
||||||
|
cacheAcoes.set(k, doc);
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseValorMoeda(input: string | undefined): number {
|
||||||
|
if (!input) return 0;
|
||||||
|
const s = input
|
||||||
|
.replace(/\s/g, '')
|
||||||
|
.replace(/[Rr]\$?/g, '')
|
||||||
|
.replace(/\./g, '')
|
||||||
|
.replace(',', '.')
|
||||||
|
.replace(/[^0-9.-]/g, '');
|
||||||
|
const n = Number(s);
|
||||||
|
return Number.isFinite(n) ? n : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const itensOut: Array<{
|
||||||
|
_id: Id<'objetoItems'>;
|
||||||
|
pedidoId: Id<'pedidos'>;
|
||||||
|
pedidoNumeroSei?: string;
|
||||||
|
pedidoStatus: PedidoStatus;
|
||||||
|
objetoId: Id<'objetos'>;
|
||||||
|
objetoNome?: string;
|
||||||
|
ataId?: Id<'atas'>;
|
||||||
|
ataNumero?: string;
|
||||||
|
acaoId?: Id<'acoes'>;
|
||||||
|
acaoNome?: string;
|
||||||
|
modalidade: Doc<'objetoItems'>['modalidade'];
|
||||||
|
quantidade: number;
|
||||||
|
valorEstimado: string;
|
||||||
|
valorReal?: string;
|
||||||
|
adicionadoPor: Id<'funcionarios'>;
|
||||||
|
adicionadoPorNome: string;
|
||||||
|
criadoEm: number;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
let totalItens = 0;
|
||||||
|
let totalDocumentos = 0;
|
||||||
|
let totalValorEstimado = 0;
|
||||||
|
let totalValorReal = 0;
|
||||||
|
|
||||||
|
const statusCounts = new Map<PedidoStatus, number>();
|
||||||
|
for (const s of [
|
||||||
|
'em_rascunho',
|
||||||
|
'aguardando_aceite',
|
||||||
|
'em_analise',
|
||||||
|
'precisa_ajustes',
|
||||||
|
'cancelado',
|
||||||
|
'concluido'
|
||||||
|
] as PedidoStatus[]) {
|
||||||
|
statusCounts.set(s, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pedidosOut = await Promise.all(
|
||||||
|
pedidosFiltrados.map(async (p) => {
|
||||||
|
statusCounts.set(p.status, (statusCounts.get(p.status) ?? 0) + 1);
|
||||||
|
|
||||||
|
const creator = await getUsuario(p.criadoPor);
|
||||||
|
const aceito = p.aceitoPor ? await getFuncionario(p.aceitoPor) : null;
|
||||||
|
|
||||||
|
const itens = await ctx.db
|
||||||
|
.query('objetoItems')
|
||||||
|
.withIndex('by_pedidoId', (q) => q.eq('pedidoId', p._id))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
const docs = await ctx.db
|
||||||
|
.query('pedidoDocumentos')
|
||||||
|
.withIndex('by_pedidoId', (q) => q.eq('pedidoId', p._id))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
totalItens += itens.length;
|
||||||
|
totalDocumentos += docs.length;
|
||||||
|
|
||||||
|
let valorEstimadoTotal = 0;
|
||||||
|
let valorRealTotal = 0;
|
||||||
|
|
||||||
|
for (const it of itens) {
|
||||||
|
valorEstimadoTotal += parseValorMoeda(it.valorEstimado);
|
||||||
|
valorRealTotal += parseValorMoeda(it.valorReal);
|
||||||
|
|
||||||
|
const funcionario = await getFuncionario(it.adicionadoPor);
|
||||||
|
const objeto = await getObjeto(it.objetoId);
|
||||||
|
const ata = it.ataId ? await getAta(it.ataId) : null;
|
||||||
|
const acao = it.acaoId ? await getAcao(it.acaoId) : null;
|
||||||
|
|
||||||
|
itensOut.push({
|
||||||
|
_id: it._id,
|
||||||
|
pedidoId: it.pedidoId,
|
||||||
|
pedidoNumeroSei: p.numeroSei,
|
||||||
|
pedidoStatus: p.status,
|
||||||
|
objetoId: it.objetoId,
|
||||||
|
objetoNome: objeto?.nome ?? undefined,
|
||||||
|
ataId: it.ataId,
|
||||||
|
ataNumero: ata?.numero ?? undefined,
|
||||||
|
acaoId: it.acaoId,
|
||||||
|
acaoNome: acao?.nome ?? undefined,
|
||||||
|
modalidade: it.modalidade,
|
||||||
|
quantidade: it.quantidade,
|
||||||
|
valorEstimado: it.valorEstimado,
|
||||||
|
valorReal: it.valorReal,
|
||||||
|
adicionadoPor: it.adicionadoPor,
|
||||||
|
adicionadoPorNome: funcionario?.nome || 'Desconhecido',
|
||||||
|
criadoEm: it.criadoEm
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
totalValorEstimado += valorEstimadoTotal;
|
||||||
|
totalValorReal += valorRealTotal;
|
||||||
|
|
||||||
|
return {
|
||||||
|
_id: p._id,
|
||||||
|
_creationTime: p._creationTime,
|
||||||
|
numeroSei: p.numeroSei,
|
||||||
|
status: p.status,
|
||||||
|
criadoPor: p.criadoPor,
|
||||||
|
criadoPorNome: creator?.nome || 'Desconhecido',
|
||||||
|
aceitoPor: p.aceitoPor,
|
||||||
|
aceitoPorNome: aceito?.nome || undefined,
|
||||||
|
descricaoAjuste: p.descricaoAjuste,
|
||||||
|
criadoEm: p.criadoEm,
|
||||||
|
concluidoEm: p.concluidoEm,
|
||||||
|
atualizadoEm: p.atualizadoEm,
|
||||||
|
itensCount: itens.length,
|
||||||
|
documentosCount: docs.length,
|
||||||
|
valorEstimadoTotal,
|
||||||
|
valorRealTotal
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ordenar itens por data (mais recente primeiro)
|
||||||
|
itensOut.sort((a, b) => b.criadoEm - a.criadoEm);
|
||||||
|
|
||||||
|
const totalPorStatus = [...statusCounts.entries()].map(([status, count]) => ({
|
||||||
|
status,
|
||||||
|
count
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
filtros: {
|
||||||
|
statuses: args.statuses,
|
||||||
|
numeroSei: args.numeroSei,
|
||||||
|
criadoPor: args.criadoPor,
|
||||||
|
aceitoPor: args.aceitoPor,
|
||||||
|
periodoInicio: args.periodoInicio,
|
||||||
|
periodoFim: args.periodoFim
|
||||||
|
},
|
||||||
|
resumo: {
|
||||||
|
totalPedidos: pedidosFiltrados.length,
|
||||||
|
totalItens,
|
||||||
|
totalDocumentos,
|
||||||
|
totalPorStatus,
|
||||||
|
totalValorEstimado,
|
||||||
|
totalValorReal
|
||||||
|
},
|
||||||
|
pedidos: pedidosOut,
|
||||||
|
itens: itensOut
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export const acceptOrder = mutation({
|
export const acceptOrder = mutation({
|
||||||
args: {
|
args: {
|
||||||
pedidoId: v.id('pedidos')
|
pedidoId: v.id('pedidos')
|
||||||
@@ -1380,6 +1847,7 @@ export const concluirPedido = mutation({
|
|||||||
|
|
||||||
await ctx.db.patch(args.pedidoId, {
|
await ctx.db.patch(args.pedidoId, {
|
||||||
status: newStatus,
|
status: newStatus,
|
||||||
|
concluidoEm: Date.now(),
|
||||||
atualizadoEm: Date.now()
|
atualizadoEm: Date.now()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -16,12 +16,16 @@ export const pedidosTables = {
|
|||||||
criadoPor: v.id('usuarios'),
|
criadoPor: v.id('usuarios'),
|
||||||
aceitoPor: v.optional(v.id('funcionarios')),
|
aceitoPor: v.optional(v.id('funcionarios')),
|
||||||
descricaoAjuste: v.optional(v.string()), // Required when status is 'precisa_ajustes'
|
descricaoAjuste: v.optional(v.string()), // Required when status is 'precisa_ajustes'
|
||||||
|
concluidoEm: v.optional(v.number()),
|
||||||
criadoEm: v.number(),
|
criadoEm: v.number(),
|
||||||
atualizadoEm: v.number()
|
atualizadoEm: v.number()
|
||||||
})
|
})
|
||||||
.index('by_numeroSei', ['numeroSei'])
|
.index('by_numeroSei', ['numeroSei'])
|
||||||
.index('by_status', ['status'])
|
.index('by_status', ['status'])
|
||||||
.index('by_criadoPor', ['criadoPor']),
|
.index('by_criadoPor', ['criadoPor'])
|
||||||
|
.index('by_aceitoPor', ['aceitoPor'])
|
||||||
|
.index('by_criadoEm', ['criadoEm'])
|
||||||
|
.index('by_concluidoEm', ['concluidoEm']),
|
||||||
|
|
||||||
objetoItems: defineTable({
|
objetoItems: defineTable({
|
||||||
pedidoId: v.id('pedidos'),
|
pedidoId: v.id('pedidos'),
|
||||||
|
|||||||
Reference in New Issue
Block a user