Feat pedidos #70
@@ -1,7 +1,5 @@
|
|||||||
---
|
---
|
||||||
trigger: model_decision
|
trigger: always_on
|
||||||
description: whenever you're working with Svelte files
|
|
||||||
globs: **/*.svelte.ts,**/*.svelte
|
|
||||||
---
|
---
|
||||||
|
|
||||||
You are able to use the Svelte MCP server, where you have access to comprehensive Svelte 5 and SvelteKit documentation. Here's how to use the available tools effectively:
|
You are able to use the Svelte MCP server, where you have access to comprehensive Svelte 5 and SvelteKit documentation. Here's how to use the available tools effectively:
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||||
|
import type { FunctionReturnType } from 'convex/server';
|
||||||
|
import ExcelJS from 'exceljs';
|
||||||
|
import logoGovPE from '$lib/assets/logo_governo_PE.png';
|
||||||
|
|
||||||
|
export type RelatorioPlanejamentosData = FunctionReturnType<
|
||||||
|
typeof api.planejamentos.gerarRelatorio
|
||||||
|
>;
|
||||||
|
|
||||||
|
function formatDateTime(ts: number | undefined): string {
|
||||||
|
if (!ts) return '';
|
||||||
|
return new Date(ts).toLocaleString('pt-BR');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateYMD(ymd: string): string {
|
||||||
|
const [y, m, d] = ymd.split('-');
|
||||||
|
if (!y || !m || !d) return ymd;
|
||||||
|
return `${d}/${m}/${y}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function argb(hex: string): { argb: string } {
|
||||||
|
return { argb: hex.replace('#', '').toUpperCase().padStart(8, 'FF') };
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'rascunho':
|
||||||
|
return 'Rascunho';
|
||||||
|
case 'gerado':
|
||||||
|
return 'Gerado';
|
||||||
|
case 'cancelado':
|
||||||
|
return 'Cancelado';
|
||||||
|
default:
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyHeaderRowStyle(row: ExcelJS.Row) {
|
||||||
|
row.height = 22;
|
||||||
|
row.eachCell((cell) => {
|
||||||
|
cell.font = { bold: true, size: 11, color: argb('#FFFFFF') };
|
||||||
|
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: argb('#2980B9') };
|
||||||
|
cell.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true };
|
||||||
|
cell.border = {
|
||||||
|
top: { style: 'thin', color: argb('#000000') },
|
||||||
|
bottom: { style: 'thin', color: argb('#000000') },
|
||||||
|
left: { style: 'thin', color: argb('#000000') },
|
||||||
|
right: { style: 'thin', color: argb('#000000') }
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyZebraRowStyle(row: ExcelJS.Row, isEven: boolean) {
|
||||||
|
row.eachCell((cell) => {
|
||||||
|
cell.font = { size: 10, color: argb('#000000') };
|
||||||
|
cell.fill = {
|
||||||
|
type: 'pattern',
|
||||||
|
pattern: 'solid',
|
||||||
|
fgColor: argb(isEven ? '#F8F9FA' : '#FFFFFF')
|
||||||
|
};
|
||||||
|
cell.border = {
|
||||||
|
top: { style: 'thin', color: argb('#E0E0E0') },
|
||||||
|
bottom: { style: 'thin', color: argb('#E0E0E0') },
|
||||||
|
left: { style: 'thin', color: argb('#E0E0E0') },
|
||||||
|
right: { style: 'thin', color: argb('#E0E0E0') }
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tryLoadLogoBuffer(): Promise<ArrayBuffer | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(logoGovPE);
|
||||||
|
if (response.ok) return await response.arrayBuffer();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const logoImg = new Image();
|
||||||
|
logoImg.crossOrigin = 'anonymous';
|
||||||
|
logoImg.src = logoGovPE;
|
||||||
|
await new Promise<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
|
||||||
|
) {
|
||||||
|
if (columnsCount <= 0) return;
|
||||||
|
|
||||||
|
worksheet.getRow(1).height = 60;
|
||||||
|
|
||||||
|
if (columnsCount >= 3) {
|
||||||
|
worksheet.mergeCells(1, 1, 1, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const logoCell = worksheet.getCell(1, 1);
|
||||||
|
logoCell.alignment = { vertical: 'middle', horizontal: 'left' };
|
||||||
|
if (columnsCount >= 3) {
|
||||||
|
logoCell.border = { right: { style: 'thin', color: argb('#E0E0E0') } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (logoBuffer) {
|
||||||
|
type WorkbookWithImage = { addImage(image: { buffer: Uint8Array; extension: string }): number };
|
||||||
|
const workbookWithImage = workbook as unknown as WorkbookWithImage;
|
||||||
|
const logoId = workbookWithImage.addImage({
|
||||||
|
buffer: new Uint8Array(logoBuffer),
|
||||||
|
extension: 'png'
|
||||||
|
});
|
||||||
|
worksheet.addImage(logoId, { tl: { col: 0, row: 0 }, ext: { width: 140, height: 55 } });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (columnsCount === 1) {
|
||||||
|
const titleCell = worksheet.getCell(1, 1);
|
||||||
|
titleCell.value = title;
|
||||||
|
titleCell.font = { bold: true, size: 18, color: argb('#2980B9') };
|
||||||
|
titleCell.alignment = { vertical: 'middle', horizontal: 'center' };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (columnsCount === 2) {
|
||||||
|
const titleCell = worksheet.getCell(1, 2);
|
||||||
|
titleCell.value = title;
|
||||||
|
titleCell.font = { bold: true, size: 18, color: argb('#2980B9') };
|
||||||
|
titleCell.alignment = { vertical: 'middle', horizontal: 'center' };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
worksheet.mergeCells(1, 3, 1, columnsCount);
|
||||||
|
const titleCell = worksheet.getCell(1, 3);
|
||||||
|
titleCell.value = title;
|
||||||
|
titleCell.font = { bold: true, size: 18, color: argb('#2980B9') };
|
||||||
|
titleCell.alignment = { vertical: 'middle', horizontal: 'center' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadExcel(buffer: ArrayBuffer, fileName: string) {
|
||||||
|
const blob = new Blob([buffer], {
|
||||||
|
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||||
|
});
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = fileName;
|
||||||
|
link.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportarRelatorioPlanejamentosXLSX(
|
||||||
|
relatorio: RelatorioPlanejamentosData
|
||||||
|
): Promise<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 PLANEJAMENTOS', 2, workbook, logoBuffer);
|
||||||
|
|
||||||
|
const headerRow = ws.getRow(2);
|
||||||
|
headerRow.values = ['Campo', 'Valor'];
|
||||||
|
applyHeaderRowStyle(headerRow);
|
||||||
|
|
||||||
|
const rows: Array<{ campo: string; valor: string | number }> = [
|
||||||
|
{
|
||||||
|
campo: 'Período início',
|
||||||
|
valor: relatorio.filtros.periodoInicio
|
||||||
|
? formatDateTime(relatorio.filtros.periodoInicio)
|
||||||
|
: ''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
campo: 'Período fim',
|
||||||
|
valor: relatorio.filtros.periodoFim ? formatDateTime(relatorio.filtros.periodoFim) : ''
|
||||||
|
},
|
||||||
|
{ campo: 'Texto (filtro)', valor: relatorio.filtros.texto ?? '' },
|
||||||
|
{
|
||||||
|
campo: 'Status (filtro)',
|
||||||
|
valor: relatorio.filtros.statuses?.map(statusLabel).join(', ') ?? ''
|
||||||
|
},
|
||||||
|
{ campo: 'Total de planejamentos', valor: relatorio.resumo.totalPlanejamentos },
|
||||||
|
{ campo: 'Total valor estimado', valor: relatorio.resumo.totalValorEstimado }
|
||||||
|
];
|
||||||
|
|
||||||
|
rows.forEach((r, idx) => {
|
||||||
|
const row = ws.addRow({ campo: r.campo, valor: r.valor });
|
||||||
|
applyZebraRowStyle(row, idx % 2 === 1);
|
||||||
|
row.getCell(2).alignment = {
|
||||||
|
vertical: 'middle',
|
||||||
|
horizontal: typeof r.valor === 'number' ? 'right' : 'left'
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.addRow({ campo: '', valor: '' });
|
||||||
|
const sectionRow = ws.addRow({ campo: 'Totais por status', valor: '' });
|
||||||
|
sectionRow.font = { bold: true, size: 11, color: argb('#2980B9') };
|
||||||
|
sectionRow.alignment = { vertical: 'middle', horizontal: 'left' };
|
||||||
|
|
||||||
|
relatorio.resumo.totalPorStatus.forEach((s, idx) => {
|
||||||
|
const row = ws.addRow({ campo: statusLabel(s.status), valor: s.count });
|
||||||
|
applyZebraRowStyle(row, idx % 2 === 1);
|
||||||
|
row.getCell(2).alignment = { vertical: 'middle', horizontal: 'right' };
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.getColumn(2).numFmt = '#,##0.00';
|
||||||
|
ws.views = [{ state: 'frozen', ySplit: 2, topLeftCell: 'A3', activeCell: 'A3' }];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Aba: Planejamentos =====
|
||||||
|
{
|
||||||
|
const ws = workbook.addWorksheet('Planejamentos');
|
||||||
|
ws.columns = [
|
||||||
|
{ header: 'Título', key: 'titulo', width: 40 },
|
||||||
|
{ header: 'Status', key: 'status', width: 15 },
|
||||||
|
{ header: 'Responsável', key: 'responsavel', width: 25 },
|
||||||
|
{ header: 'Data', key: 'data', width: 15 },
|
||||||
|
{ header: 'Ação', key: 'acao', width: 25 },
|
||||||
|
{ header: 'Itens', key: 'itens', width: 10 },
|
||||||
|
{ header: 'Estimado (R$)', key: 'estimado', width: 18 },
|
||||||
|
{ header: 'Criado em', key: 'criadoEm', width: 20 }
|
||||||
|
];
|
||||||
|
|
||||||
|
addTitleRow(ws, 'RELATÓRIO DE PLANEJAMENTOS — LISTA', ws.columns.length, workbook, logoBuffer);
|
||||||
|
|
||||||
|
const headerRow = ws.getRow(2);
|
||||||
|
headerRow.values = ws.columns.map((c) => c.header as string);
|
||||||
|
applyHeaderRowStyle(headerRow);
|
||||||
|
|
||||||
|
relatorio.planejamentos.forEach((p, idx) => {
|
||||||
|
const row = ws.addRow({
|
||||||
|
titulo: p.titulo,
|
||||||
|
status: statusLabel(p.status),
|
||||||
|
responsavel: p.responsavelNome,
|
||||||
|
data: formatDateYMD(p.data),
|
||||||
|
acao: p.acaoNome ?? '',
|
||||||
|
itens: p.itensCount,
|
||||||
|
estimado: p.valorEstimadoTotal,
|
||||||
|
criadoEm: new Date(p.criadoEm)
|
||||||
|
});
|
||||||
|
|
||||||
|
applyZebraRowStyle(row, idx % 2 === 1);
|
||||||
|
|
||||||
|
row.getCell(2).alignment = { vertical: 'middle', horizontal: 'center' };
|
||||||
|
row.getCell(4).alignment = { vertical: 'middle', horizontal: 'center' };
|
||||||
|
row.getCell(6).alignment = { vertical: 'middle', horizontal: 'center' };
|
||||||
|
row.getCell(7).alignment = { vertical: 'middle', horizontal: 'right' };
|
||||||
|
row.getCell(8).alignment = { vertical: 'middle', horizontal: 'center' };
|
||||||
|
|
||||||
|
const statusCell = row.getCell(2);
|
||||||
|
if (p.status === 'gerado') {
|
||||||
|
statusCell.fill = { type: 'pattern', pattern: 'solid', fgColor: argb('#D4EDDA') };
|
||||||
|
statusCell.font = { size: 10, color: argb('#155724') };
|
||||||
|
} else if (p.status === 'cancelado') {
|
||||||
|
statusCell.fill = { type: 'pattern', pattern: 'solid', fgColor: argb('#F8D7DA') };
|
||||||
|
statusCell.font = { size: 10, color: argb('#721C24') };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.getColumn(7).numFmt = '"R$" #,##0.00';
|
||||||
|
ws.getColumn(8).numFmt = 'dd/mm/yyyy hh:mm';
|
||||||
|
|
||||||
|
ws.views = [{ state: 'frozen', ySplit: 2, topLeftCell: 'A3', activeCell: 'A3' }];
|
||||||
|
ws.autoFilter = {
|
||||||
|
from: { row: 2, column: 1 },
|
||||||
|
to: { row: 2, column: ws.columns.length }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const nomeArquivo = `relatorio-planejamentos-${new Date().toISOString().slice(0, 10)}.xlsx`;
|
||||||
|
const buffer = await workbook.xlsx.writeBuffer();
|
||||||
|
downloadExcel(buffer, nomeArquivo);
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||||
|
import type { FunctionReturnType } from 'convex/server';
|
||||||
|
import jsPDF from 'jspdf';
|
||||||
|
import autoTable from 'jspdf-autotable';
|
||||||
|
|
||||||
|
export type RelatorioPlanejamentosData = FunctionReturnType<
|
||||||
|
typeof api.planejamentos.gerarRelatorio
|
||||||
|
>;
|
||||||
|
|
||||||
|
function formatCurrencyBRL(n: number): string {
|
||||||
|
return n.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(ts: number | undefined): string {
|
||||||
|
if (!ts) return '-';
|
||||||
|
return new Date(ts).toLocaleString('pt-BR');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateYMD(ymd: string): string {
|
||||||
|
const [y, m, d] = ymd.split('-');
|
||||||
|
if (!y || !m || !d) return ymd;
|
||||||
|
return `${d}/${m}/${y}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPeriodoLabel(filtros: RelatorioPlanejamentosData['filtros']): string {
|
||||||
|
const inicio = filtros.periodoInicio ? formatDateTime(filtros.periodoInicio) : '—';
|
||||||
|
const fim = filtros.periodoFim ? formatDateTime(filtros.periodoFim) : '—';
|
||||||
|
return `${inicio} até ${fim}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function gerarRelatorioPlanejamentosPDF(relatorio: RelatorioPlanejamentosData): void {
|
||||||
|
const doc = new jsPDF({ orientation: 'landscape' });
|
||||||
|
|
||||||
|
// Título
|
||||||
|
doc.setFontSize(18);
|
||||||
|
doc.setTextColor(102, 126, 234);
|
||||||
|
doc.text('Relatório de Planejamentos', 14, 18);
|
||||||
|
|
||||||
|
// Subtítulo
|
||||||
|
doc.setFontSize(11);
|
||||||
|
doc.setTextColor(0, 0, 0);
|
||||||
|
doc.text(`Período: ${getPeriodoLabel(relatorio.filtros)}`, 14, 26);
|
||||||
|
doc.setFontSize(9);
|
||||||
|
doc.text(`Gerado em: ${new Date().toLocaleString('pt-BR')}`, 14, 32);
|
||||||
|
|
||||||
|
let yPos = 40;
|
||||||
|
|
||||||
|
// Resumo
|
||||||
|
doc.setFontSize(12);
|
||||||
|
doc.setTextColor(102, 126, 234);
|
||||||
|
doc.text('Resumo', 14, yPos);
|
||||||
|
yPos += 6;
|
||||||
|
|
||||||
|
autoTable(doc, {
|
||||||
|
startY: yPos,
|
||||||
|
head: [['Planejamentos', 'Total Estimado']],
|
||||||
|
body: [
|
||||||
|
[
|
||||||
|
String(relatorio.resumo.totalPlanejamentos),
|
||||||
|
formatCurrencyBRL(relatorio.resumo.totalValorEstimado)
|
||||||
|
]
|
||||||
|
],
|
||||||
|
theme: 'striped',
|
||||||
|
headStyles: { fillColor: [102, 126, 234] },
|
||||||
|
styles: { fontSize: 9 }
|
||||||
|
});
|
||||||
|
|
||||||
|
type JsPDFWithAutoTable = jsPDF & { lastAutoTable?: { finalY: number } };
|
||||||
|
yPos = ((doc as JsPDFWithAutoTable).lastAutoTable?.finalY ?? yPos) + 10;
|
||||||
|
|
||||||
|
// Totais por status
|
||||||
|
doc.setFontSize(12);
|
||||||
|
doc.setTextColor(102, 126, 234);
|
||||||
|
doc.text('Totais por status', 14, yPos);
|
||||||
|
yPos += 6;
|
||||||
|
|
||||||
|
autoTable(doc, {
|
||||||
|
startY: yPos,
|
||||||
|
head: [['Status', 'Quantidade']],
|
||||||
|
body: relatorio.resumo.totalPorStatus.map((s) => [s.status, String(s.count)]),
|
||||||
|
theme: 'grid',
|
||||||
|
headStyles: { fillColor: [102, 126, 234] },
|
||||||
|
styles: { fontSize: 9 }
|
||||||
|
});
|
||||||
|
|
||||||
|
yPos = ((doc as JsPDFWithAutoTable).lastAutoTable?.finalY ?? yPos) + 12;
|
||||||
|
|
||||||
|
// Planejamentos
|
||||||
|
doc.setFontSize(12);
|
||||||
|
doc.setTextColor(102, 126, 234);
|
||||||
|
doc.text('Planejamentos', 14, yPos);
|
||||||
|
yPos += 6;
|
||||||
|
|
||||||
|
const planejamentosBody = relatorio.planejamentos.map((p) => [
|
||||||
|
p.titulo,
|
||||||
|
p.status,
|
||||||
|
p.responsavelNome,
|
||||||
|
p.acaoNome ?? '—',
|
||||||
|
formatDateYMD(p.data),
|
||||||
|
String(p.itensCount),
|
||||||
|
formatCurrencyBRL(p.valorEstimadoTotal),
|
||||||
|
formatDateTime(p.criadoEm)
|
||||||
|
]);
|
||||||
|
|
||||||
|
autoTable(doc, {
|
||||||
|
startY: yPos,
|
||||||
|
head: [['Título', 'Status', 'Responsável', 'Ação', 'Data', 'Itens', 'Estimado', 'Criado em']],
|
||||||
|
body: planejamentosBody,
|
||||||
|
theme: 'grid',
|
||||||
|
headStyles: { fillColor: [102, 126, 234] },
|
||||||
|
styles: { fontSize: 8 },
|
||||||
|
columnStyles: {
|
||||||
|
0: { cellWidth: 60 },
|
||||||
|
1: { cellWidth: 20 },
|
||||||
|
2: { cellWidth: 40 },
|
||||||
|
3: { cellWidth: 40 },
|
||||||
|
4: { cellWidth: 25 },
|
||||||
|
5: { cellWidth: 15 },
|
||||||
|
6: { cellWidth: 30 },
|
||||||
|
7: { cellWidth: 30 }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Footer com paginação
|
||||||
|
const pageCount = doc.getNumberOfPages();
|
||||||
|
for (let i = 1; i <= pageCount; i++) {
|
||||||
|
doc.setPage(i);
|
||||||
|
doc.setFontSize(8);
|
||||||
|
doc.setTextColor(128, 128, 128);
|
||||||
|
doc.text(
|
||||||
|
`SGSE - Sistema de Gerenciamento de Secretaria | Página ${i} de ${pageCount}`,
|
||||||
|
doc.internal.pageSize.getWidth() / 2,
|
||||||
|
doc.internal.pageSize.getHeight() - 8,
|
||||||
|
{ align: 'center' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = `relatorio-planejamentos-${new Date().toISOString().slice(0, 10)}.pdf`;
|
||||||
|
doc.save(fileName);
|
||||||
|
}
|
||||||
@@ -4,21 +4,112 @@
|
|||||||
import Breadcrumbs from '$lib/components/layout/Breadcrumbs.svelte';
|
import Breadcrumbs from '$lib/components/layout/Breadcrumbs.svelte';
|
||||||
import PageHeader from '$lib/components/layout/PageHeader.svelte';
|
import PageHeader from '$lib/components/layout/PageHeader.svelte';
|
||||||
import PageShell from '$lib/components/layout/PageShell.svelte';
|
import PageShell from '$lib/components/layout/PageShell.svelte';
|
||||||
|
import GlassCard from '$lib/components/ui/GlassCard.svelte';
|
||||||
import TableCard from '$lib/components/ui/TableCard.svelte';
|
import TableCard from '$lib/components/ui/TableCard.svelte';
|
||||||
import { useConvexClient, useQuery } from 'convex-svelte';
|
import { useConvexClient, useQuery } from 'convex-svelte';
|
||||||
import { ClipboardList, Eye, Plus, X, Copy } from 'lucide-svelte';
|
import { ClipboardList, Eye, Plus, X, Copy, FileText, FileSpreadsheet } from 'lucide-svelte';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
|
import { exportarRelatorioPlanejamentosXLSX } from '$lib/utils/planejamentos/relatorioPlanejamentosExcel';
|
||||||
|
import { gerarRelatorioPlanejamentosPDF } from '$lib/utils/planejamentos/relatorioPlanejamentosPDF';
|
||||||
|
import { endOfDay, startOfDay } from 'date-fns';
|
||||||
|
|
||||||
const client = useConvexClient();
|
const client = useConvexClient();
|
||||||
|
|
||||||
const planejamentosQuery = useQuery(api.planejamentos.list, {});
|
// Filtros
|
||||||
|
let filtroTexto = $state('');
|
||||||
|
let filtroResponsavel = $state<Id<'funcionarios'> | ''>('');
|
||||||
|
let filtroAcao = $state<Id<'acoes'> | ''>('');
|
||||||
|
let filtroInicio = $state('');
|
||||||
|
let filtroFim = $state('');
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ value: 'rascunho', label: 'Rascunho' },
|
||||||
|
{ value: 'gerado', label: 'Gerado' },
|
||||||
|
{ value: 'cancelado', label: 'Cancelado' }
|
||||||
|
] as const;
|
||||||
|
type PlanejamentoStatus = (typeof statusOptions)[number]['value'];
|
||||||
|
|
||||||
|
let statusSelected = $state<Record<PlanejamentoStatus, boolean>>({
|
||||||
|
rascunho: false,
|
||||||
|
gerado: false,
|
||||||
|
cancelado: false
|
||||||
|
});
|
||||||
|
|
||||||
|
function getSelectedStatuses(): PlanejamentoStatus[] | undefined {
|
||||||
|
const selected = (Object.entries(statusSelected) as Array<[PlanejamentoStatus, boolean]>)
|
||||||
|
.filter(([, v]) => v)
|
||||||
|
.map(([k]) => k);
|
||||||
|
return selected.length > 0 ? selected : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPeriodoInicio(): number | undefined {
|
||||||
|
if (!filtroInicio) return undefined;
|
||||||
|
return startOfDay(new Date(`${filtroInicio}T00:00:00`)).getTime();
|
||||||
|
}
|
||||||
|
function getPeriodoFim(): number | undefined {
|
||||||
|
if (!filtroFim) return undefined;
|
||||||
|
return endOfDay(new Date(`${filtroFim}T23:59:59`)).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
function limparFiltros() {
|
||||||
|
filtroTexto = '';
|
||||||
|
filtroResponsavel = '';
|
||||||
|
filtroAcao = '';
|
||||||
|
filtroInicio = '';
|
||||||
|
filtroFim = '';
|
||||||
|
(Object.keys(statusSelected) as PlanejamentoStatus[]).forEach(
|
||||||
|
(k) => (statusSelected[k] = false)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtroArgs = () => ({
|
||||||
|
statuses: getSelectedStatuses(),
|
||||||
|
texto: filtroTexto.trim() || undefined,
|
||||||
|
responsavelId: filtroResponsavel ? filtroResponsavel : undefined,
|
||||||
|
acaoId: filtroAcao ? filtroAcao : undefined,
|
||||||
|
periodoInicio: getPeriodoInicio(),
|
||||||
|
periodoFim: getPeriodoFim()
|
||||||
|
});
|
||||||
|
|
||||||
|
const planejamentosQuery = useQuery(api.planejamentos.list, filtroArgs);
|
||||||
const funcionariosQuery = useQuery(api.funcionarios.getAll, {});
|
const funcionariosQuery = useQuery(api.funcionarios.getAll, {});
|
||||||
const acoesQuery = useQuery(api.acoes.list, {});
|
const acoesQuery = useQuery(api.acoes.list, {});
|
||||||
|
|
||||||
let planejamentos = $derived(planejamentosQuery.data || []);
|
let planejamentos = $derived(planejamentosQuery.data || []);
|
||||||
|
|
||||||
|
// Relatórios
|
||||||
|
let generatingPDF = $state(false);
|
||||||
|
let generatingXLSX = $state(false);
|
||||||
|
|
||||||
|
async function gerarPDF() {
|
||||||
|
try {
|
||||||
|
generatingPDF = true;
|
||||||
|
// Passa os mesmos filtros da lista (que é uma função)
|
||||||
|
const dados = await client.query(api.planejamentos.gerarRelatorio, filtroArgs());
|
||||||
|
gerarRelatorioPlanejamentosPDF(dados);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
toast.error('Erro ao gerar PDF.');
|
||||||
|
} finally {
|
||||||
|
generatingPDF = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportarXLSX() {
|
||||||
|
try {
|
||||||
|
generatingXLSX = true;
|
||||||
|
const dados = await client.query(api.planejamentos.gerarRelatorio, filtroArgs());
|
||||||
|
await exportarRelatorioPlanejamentosXLSX(dados);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
toast.error('Erro ao gerar Excel.');
|
||||||
|
} finally {
|
||||||
|
generatingXLSX = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function formatStatus(status: string) {
|
function formatStatus(status: string) {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'rascunho':
|
case 'rascunho':
|
||||||
@@ -140,6 +231,36 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost gap-2"
|
||||||
|
onclick={gerarPDF}
|
||||||
|
disabled={generatingPDF || generatingXLSX}
|
||||||
|
title="Gerar PDF"
|
||||||
|
>
|
||||||
|
{#if generatingPDF}
|
||||||
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
|
{:else}
|
||||||
|
<FileText class="h-5 w-5" />
|
||||||
|
{/if}
|
||||||
|
{generatingPDF ? 'Gerando...' : 'PDF'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost gap-2"
|
||||||
|
onclick={exportarXLSX}
|
||||||
|
disabled={generatingPDF || generatingXLSX}
|
||||||
|
title="Exportar Excel"
|
||||||
|
>
|
||||||
|
{#if generatingXLSX}
|
||||||
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
|
{:else}
|
||||||
|
<FileSpreadsheet class="h-5 w-5" />
|
||||||
|
{/if}
|
||||||
|
{generatingXLSX ? 'Exportando...' : 'Excel'}
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-primary gap-2 shadow-md transition-all hover:shadow-lg"
|
class="btn btn-primary gap-2 shadow-md transition-all hover:shadow-lg"
|
||||||
@@ -151,6 +272,106 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
|
<GlassCard class="mb-6">
|
||||||
|
<div class="grid gap-4 sm:grid-cols-[1fr_auto] sm:items-end">
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-3 lg:grid-cols-5">
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label class="label" for="filtro_texto">
|
||||||
|
<span class="label-text font-semibold">Busca (título/descrição)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="filtro_texto"
|
||||||
|
class="input input-bordered focus:input-primary w-full"
|
||||||
|
type="text"
|
||||||
|
placeholder="Digite para pesquisar..."
|
||||||
|
bind:value={filtroTexto}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label class="label" for="filtro_responsavel">
|
||||||
|
<span class="label-text font-semibold">Responsável</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="filtro_responsavel"
|
||||||
|
class="select select-bordered focus:select-primary w-full"
|
||||||
|
bind:value={filtroResponsavel}
|
||||||
|
>
|
||||||
|
<option value="">Todos</option>
|
||||||
|
{#each funcionariosQuery.data || [] as f (f._id)}
|
||||||
|
<option value={f._id}>{f.nome}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label class="label" for="filtro_acao">
|
||||||
|
<span class="label-text font-semibold">Ação</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="filtro_acao"
|
||||||
|
class="select select-bordered focus:select-primary w-full"
|
||||||
|
bind:value={filtroAcao}
|
||||||
|
>
|
||||||
|
<option value="">Todas</option>
|
||||||
|
{#each acoesQuery.data || [] as a (a._id)}
|
||||||
|
<option value={a._id}>{a.nome}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label class="label" for="filtro_inicio">
|
||||||
|
<span class="label-text font-semibold">Período (início)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="filtro_inicio"
|
||||||
|
class="input input-bordered focus:input-primary w-full"
|
||||||
|
type="date"
|
||||||
|
bind:value={filtroInicio}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label class="label" for="filtro_fim">
|
||||||
|
<span class="label-text font-semibold">Período (fim)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="filtro_fim"
|
||||||
|
class="input input-bordered focus:input-primary w-full"
|
||||||
|
type="date"
|
||||||
|
bind:value={filtroFim}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control w-full md:col-span-3 lg:col-span-5">
|
||||||
|
<div class="label">
|
||||||
|
<span class="label-text font-semibold">Status</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
{#each statusOptions as s (s.value)}
|
||||||
|
<label class="label cursor-pointer justify-start gap-2 py-1">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="checkbox checkbox-primary checkbox-sm"
|
||||||
|
checked={statusSelected[s.value]}
|
||||||
|
onchange={(e) =>
|
||||||
|
(statusSelected[s.value] = (e.currentTarget as HTMLInputElement).checked)}
|
||||||
|
/>
|
||||||
|
<span class="label-text">{s.label}</span>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between gap-3 sm:min-w-max sm:justify-end">
|
||||||
|
<div class="text-base-content/70 text-sm">{planejamentos.length} resultado(s)</div>
|
||||||
|
<button type="button" class="btn btn-ghost" onclick={limparFiltros}>Limpar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
|
||||||
{#if planejamentosQuery.isLoading}
|
{#if planejamentosQuery.isLoading}
|
||||||
<div class="flex items-center justify-center py-10">
|
<div class="flex items-center justify-center py-10">
|
||||||
<span class="loading loading-spinner loading-lg"></span>
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
@@ -164,23 +385,23 @@
|
|||||||
<table class="table-zebra table w-full">
|
<table class="table-zebra table w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
|
<th class="text-base-content border-base-300 border-b font-bold whitespace-nowrap"
|
||||||
>Título</th
|
>Título</th
|
||||||
>
|
>
|
||||||
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
|
<th class="text-base-content border-base-300 border-b font-bold whitespace-nowrap"
|
||||||
>Data</th
|
>Data</th
|
||||||
>
|
>
|
||||||
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
|
<th class="text-base-content border-base-300 border-b font-bold whitespace-nowrap"
|
||||||
>Responsável</th
|
>Responsável</th
|
||||||
>
|
>
|
||||||
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
|
<th class="text-base-content border-base-300 border-b font-bold whitespace-nowrap"
|
||||||
>Ação</th
|
>Ação</th
|
||||||
>
|
>
|
||||||
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
|
<th class="text-base-content border-base-300 border-b font-bold whitespace-nowrap"
|
||||||
>Status</th
|
>Status</th
|
||||||
>
|
>
|
||||||
<th
|
<th
|
||||||
class="text-base-content border-base-400 border-b text-right font-bold whitespace-nowrap"
|
class="text-base-content border-base-300 border-b text-right font-bold whitespace-nowrap"
|
||||||
>Ações</th
|
>Ações</th
|
||||||
>
|
>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -191,7 +412,7 @@
|
|||||||
<td colspan="6" class="py-12 text-center">
|
<td colspan="6" class="py-12 text-center">
|
||||||
<div class="text-base-content/60 flex flex-col items-center gap-2">
|
<div class="text-base-content/60 flex flex-col items-center gap-2">
|
||||||
<p class="text-lg font-semibold">Nenhum planejamento encontrado</p>
|
<p class="text-lg font-semibold">Nenhum planejamento encontrado</p>
|
||||||
<p class="text-sm">Clique em “Novo planejamento” para criar o primeiro.</p>
|
<p class="text-sm">Tente ajustar os filtros ou crie um novo planejamento.</p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -19,26 +19,47 @@ function normalizeOptionalString(value: string | undefined): string | undefined
|
|||||||
export const list = query({
|
export const list = query({
|
||||||
args: {
|
args: {
|
||||||
status: v.optional(v.union(v.literal('rascunho'), v.literal('gerado'), v.literal('cancelado'))),
|
status: v.optional(v.union(v.literal('rascunho'), v.literal('gerado'), v.literal('cancelado'))),
|
||||||
responsavelId: v.optional(v.id('funcionarios'))
|
statuses: v.optional(
|
||||||
|
v.array(v.union(v.literal('rascunho'), v.literal('gerado'), v.literal('cancelado')))
|
||||||
|
),
|
||||||
|
responsavelId: v.optional(v.id('funcionarios')),
|
||||||
|
acaoId: v.optional(v.id('acoes')),
|
||||||
|
texto: v.optional(v.string()),
|
||||||
|
periodoInicio: v.optional(v.number()),
|
||||||
|
periodoFim: v.optional(v.number())
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const status = args.status;
|
const { periodoInicio, periodoFim, texto } = args;
|
||||||
const responsavelId = args.responsavelId;
|
|
||||||
|
|
||||||
let base: Doc<'planejamentosPedidos'>[] = [];
|
let base = await ctx.db.query('planejamentosPedidos').collect();
|
||||||
|
|
||||||
if (responsavelId) {
|
// Filtros em memória (devido à complexidade de múltiplos índices)
|
||||||
base = await ctx.db
|
if (args.responsavelId) {
|
||||||
.query('planejamentosPedidos')
|
base = base.filter((p) => p.responsavelId === args.responsavelId);
|
||||||
.withIndex('by_responsavelId', (q) => q.eq('responsavelId', responsavelId))
|
}
|
||||||
.collect();
|
if (args.acaoId) {
|
||||||
} else if (status) {
|
base = base.filter((p) => p.acaoId === args.acaoId);
|
||||||
base = await ctx.db
|
}
|
||||||
.query('planejamentosPedidos')
|
|
||||||
.withIndex('by_status', (q) => q.eq('status', status))
|
// Status simples ou múltiplo
|
||||||
.collect();
|
if (args.statuses && args.statuses.length > 0) {
|
||||||
} else {
|
base = base.filter((p) => args.statuses!.includes(p.status));
|
||||||
base = await ctx.db.query('planejamentosPedidos').collect();
|
} else if (args.status) {
|
||||||
|
base = base.filter((p) => p.status === args.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (periodoInicio) {
|
||||||
|
base = base.filter((p) => p.data >= new Date(periodoInicio).toISOString().split('T')[0]);
|
||||||
|
}
|
||||||
|
if (periodoFim) {
|
||||||
|
base = base.filter((p) => p.data <= new Date(periodoFim).toISOString().split('T')[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (texto) {
|
||||||
|
const t = texto.toLowerCase();
|
||||||
|
base = base.filter(
|
||||||
|
(p) => p.titulo.toLowerCase().includes(t) || p.descricao.toLowerCase().includes(t)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
base.sort((a, b) => b.criadoEm - a.criadoEm);
|
base.sort((a, b) => b.criadoEm - a.criadoEm);
|
||||||
@@ -59,6 +80,108 @@ export const list = query({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const gerarRelatorio = query({
|
||||||
|
args: {
|
||||||
|
statuses: v.optional(
|
||||||
|
v.array(v.union(v.literal('rascunho'), v.literal('gerado'), v.literal('cancelado')))
|
||||||
|
),
|
||||||
|
responsavelId: v.optional(v.id('funcionarios')),
|
||||||
|
acaoId: v.optional(v.id('acoes')),
|
||||||
|
texto: v.optional(v.string()),
|
||||||
|
periodoInicio: v.optional(v.number()),
|
||||||
|
periodoFim: v.optional(v.number())
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
// Reutilizar lógica de filtro
|
||||||
|
let base = await ctx.db.query('planejamentosPedidos').collect();
|
||||||
|
|
||||||
|
if (args.responsavelId) {
|
||||||
|
base = base.filter((p) => p.responsavelId === args.responsavelId);
|
||||||
|
}
|
||||||
|
if (args.acaoId) {
|
||||||
|
base = base.filter((p) => p.acaoId === args.acaoId);
|
||||||
|
}
|
||||||
|
if (args.statuses && args.statuses.length > 0) {
|
||||||
|
base = base.filter((p) => args.statuses!.includes(p.status));
|
||||||
|
}
|
||||||
|
if (args.periodoInicio) {
|
||||||
|
base = base.filter(
|
||||||
|
(p) => p.data >= new Date(args.periodoInicio!).toISOString().split('T')[0]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (args.periodoFim) {
|
||||||
|
base = base.filter((p) => p.data <= new Date(args.periodoFim!).toISOString().split('T')[0]);
|
||||||
|
}
|
||||||
|
if (args.texto) {
|
||||||
|
const t = args.texto.toLowerCase();
|
||||||
|
base = base.filter(
|
||||||
|
(p) => p.titulo.toLowerCase().includes(t) || p.descricao.toLowerCase().includes(t)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
base.sort((a, b) => b.criadoEm - a.criadoEm);
|
||||||
|
|
||||||
|
// Enriquecer dados
|
||||||
|
const planejamentosEnriquecidos = await Promise.all(
|
||||||
|
base.map(async (p) => {
|
||||||
|
const [responsavel, acao, itens] = await Promise.all([
|
||||||
|
ctx.db.get(p.responsavelId),
|
||||||
|
p.acaoId ? ctx.db.get(p.acaoId) : Promise.resolve(null),
|
||||||
|
ctx.db
|
||||||
|
.query('planejamentoItens')
|
||||||
|
.withIndex('by_planejamentoId', (q) => q.eq('planejamentoId', p._id))
|
||||||
|
.collect()
|
||||||
|
]);
|
||||||
|
|
||||||
|
let valorEstimadoTotal = 0;
|
||||||
|
for (const item of itens) {
|
||||||
|
// Corrigir string '1.000,00' -> number
|
||||||
|
const val = parseFloat(
|
||||||
|
item.valorEstimado.replace(/\./g, '').replace(',', '.').replace('R$', '').trim()
|
||||||
|
);
|
||||||
|
if (!isNaN(val)) valorEstimadoTotal += val * item.quantidade;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
responsavelNome: responsavel?.nome ?? 'Desconhecido',
|
||||||
|
acaoNome: acao?.nome ?? undefined,
|
||||||
|
itensCount: itens.length,
|
||||||
|
valorEstimadoTotal
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calcular resumo
|
||||||
|
const totalPlanejamentos = base.length;
|
||||||
|
const totalValorEstimado = planejamentosEnriquecidos.reduce(
|
||||||
|
(acc, curr) => acc + curr.valorEstimadoTotal,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalPorStatus = [
|
||||||
|
{ status: 'rascunho', count: 0 },
|
||||||
|
{ status: 'gerado', count: 0 },
|
||||||
|
{ status: 'cancelado', count: 0 }
|
||||||
|
];
|
||||||
|
|
||||||
|
base.forEach((p) => {
|
||||||
|
const st = totalPorStatus.find((s) => s.status === p.status);
|
||||||
|
if (st) st.count++;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
filtros: args,
|
||||||
|
resumo: {
|
||||||
|
totalPlanejamentos,
|
||||||
|
totalValorEstimado,
|
||||||
|
totalPorStatus
|
||||||
|
},
|
||||||
|
planejamentos: planejamentosEnriquecidos
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export const get = query({
|
export const get = query({
|
||||||
args: { id: v.id('planejamentosPedidos') },
|
args: { id: v.id('planejamentosPedidos') },
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user