Merge remote-tracking branch 'origin' into feat-pedidos

This commit is contained in:
2025-12-11 10:08:12 -03:00
194 changed files with 30374 additions and 10247 deletions

View File

@@ -0,0 +1,142 @@
/**
* Cache de avatares para reduzir requisições repetidas
* Usa Cache API do navegador e cache em memória
*/
interface AvatarCacheEntry {
url: string;
timestamp: number;
}
// Cache em memória (útil durante a sessão)
const memoryCache = new Map<string, AvatarCacheEntry>();
// Nome do cache no Cache API
const CACHE_NAME = 'sgse-avatars-v1';
const CACHE_DURATION = 7 * 24 * 60 * 60 * 1000; // 7 dias
/**
* Obtém avatar do cache ou faz requisição
*/
export async function getCachedAvatar(
avatarUrl: string | null | undefined,
userId?: string
): Promise<string | null> {
if (!avatarUrl) return null;
// Usar userId como chave se disponível, senão usar a URL
const cacheKey = userId || avatarUrl;
// Verificar cache em memória primeiro
const memoryEntry = memoryCache.get(cacheKey);
if (memoryEntry && Date.now() - memoryEntry.timestamp < CACHE_DURATION) {
return memoryEntry.url;
}
// Verificar Cache API do navegador
if (typeof window !== 'undefined' && 'caches' in window) {
try {
const cache = await caches.open(CACHE_NAME);
const cachedResponse = await cache.match(avatarUrl);
if (cachedResponse) {
const blob = await cachedResponse.blob();
const url = URL.createObjectURL(blob);
// Atualizar cache em memória
memoryCache.set(cacheKey, {
url,
timestamp: Date.now()
});
return url;
}
} catch (error) {
console.warn('Erro ao acessar cache de avatares:', error);
}
}
// Se não está no cache, fazer requisição e armazenar
try {
const response = await fetch(avatarUrl);
if (!response.ok) return null;
const blob = await response.blob();
const url = URL.createObjectURL(blob);
// Armazenar no cache em memória
memoryCache.set(cacheKey, {
url,
timestamp: Date.now()
});
// Armazenar no Cache API
if (typeof window !== 'undefined' && 'caches' in window) {
try {
const cache = await caches.open(CACHE_NAME);
await cache.put(avatarUrl, new Response(blob));
} catch (error) {
console.warn('Erro ao armazenar avatar no cache:', error);
}
}
return url;
} catch (error) {
console.warn('Erro ao carregar avatar:', error);
return null;
}
}
/**
* Limpa o cache de avatares
*/
export async function clearAvatarCache(): Promise<void> {
memoryCache.clear();
if (typeof window !== 'undefined' && 'caches' in window) {
try {
await caches.delete(CACHE_NAME);
} catch (error) {
console.warn('Erro ao limpar cache de avatares:', error);
}
}
}
/**
* Limpa avatares antigos do cache (mais de 7 dias)
*/
export async function cleanOldAvatars(): Promise<void> {
const now = Date.now();
// Limpar cache em memória
for (const [key, entry] of memoryCache.entries()) {
if (now - entry.timestamp > CACHE_DURATION) {
URL.revokeObjectURL(entry.url);
memoryCache.delete(key);
}
}
// Limpar Cache API (manter apenas os últimos 100)
if (typeof window !== 'undefined' && 'caches' in window) {
try {
const cache = await caches.open(CACHE_NAME);
const keys = await cache.keys();
// Se há mais de 100 avatares, remover os mais antigos
if (keys.length > 100) {
const toDelete = keys.slice(0, keys.length - 100);
await Promise.all(toDelete.map((key) => cache.delete(key)));
}
} catch (error) {
console.warn('Erro ao limpar cache antigo:', error);
}
}
}
// Limpar cache antigo periodicamente (a cada hora)
if (typeof window !== 'undefined') {
setInterval(() => {
cleanOldAvatars();
}, 60 * 60 * 1000);
}

View File

@@ -94,8 +94,8 @@ export async function getLocalIP(): Promise<string | undefined> {
let resolved = false;
const foundIPs: string[] = [];
let publicIP: string | undefined;
let localIP: string | undefined;
let publicIP: string | undefined = undefined;
let localIP: string | undefined = undefined;
const timeout = setTimeout(() => {
if (!resolved) {
@@ -119,7 +119,7 @@ export async function getLocalIP(): Promise<string | undefined> {
/\b([0-9a-fA-F]{1,4}(?::[0-9a-fA-F]{1,4}){2,7}|::[0-9a-fA-F]{1,4}(?::[0-9a-fA-F]{1,4}){0,6}|[0-9a-fA-F]{1,4}::[0-9a-fA-F]{1,4}(?::[0-9a-fA-F]{1,4}){0,5})\b/
);
let ip: string | undefined;
let ip: string | undefined = undefined;
if (ipv4Match && ipv4Match[1]) {
const candidateIP = ipv4Match[1];

View File

@@ -0,0 +1,63 @@
/**
* Utilitários para manipulação de datas
* Resolve problemas de timezone ao trabalhar com datas no formato YYYY-MM-DD
*/
/**
* Converte uma string de data no formato YYYY-MM-DD para um objeto Date local
* (sem considerar timezone, garantindo que a data seja interpretada como data local)
*
* @param dateString - String no formato YYYY-MM-DD
* @returns Date objeto representando a data local (meia-noite no timezone local)
*
* @example
* parseLocalDate('2024-01-15') // Retorna Date para 15/01/2024 00:00:00 no timezone local
*/
export function parseLocalDate(dateString: string): Date {
if (!dateString || typeof dateString !== 'string') {
throw new Error('dateString deve ser uma string válida');
}
// Validar formato YYYY-MM-DD
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
if (!dateRegex.test(dateString)) {
throw new Error('dateString deve estar no formato YYYY-MM-DD');
}
// Extrair ano, mês e dia
const [year, month, day] = dateString.split('-').map(Number);
// Criar data local (sem considerar timezone)
// O mês no Date é 0-indexed, então subtraímos 1
const date = new Date(year, month - 1, day, 0, 0, 0, 0);
// Validar se a data é válida
if (isNaN(date.getTime())) {
throw new Error(`Data inválida: ${dateString}`);
}
return date;
}
/**
* Formata uma data para o formato brasileiro (DD/MM/YYYY)
*
* @param date - Date objeto ou string no formato YYYY-MM-DD
* @returns String formatada no formato DD/MM/YYYY
*/
export function formatarDataBR(date: Date | string): string {
let dateObj: Date;
if (typeof date === 'string') {
dateObj = parseLocalDate(date);
} else {
dateObj = date;
}
const day = dateObj.getDate().toString().padStart(2, '0');
const month = (dateObj.getMonth() + 1).toString().padStart(2, '0');
const year = dateObj.getFullYear();
return `${day}/${month}/${year}`;
}

View File

@@ -116,11 +116,7 @@ export async function gerarDeclaracaoAcumulacaoCargo(funcionario: Funcionario):
align: 'center'
});
y += 6;
addText(doc, 'FUNÇÃO PÚBLICA OU PROVENTOS', 105, y, {
bold: true,
size: 12,
align: 'center'
});
addText(doc, 'FUNÇÃO PÚBLICA OU PROVENTOS', 105, y, { bold: true, size: 12, align: 'center' });
y += 15;
// Corpo
@@ -219,9 +215,7 @@ export async function gerarDeclaracaoAcumulacaoCargo(funcionario: Funcionario):
// Rodapé
doc.setFontSize(8);
doc.setTextColor(100);
doc.text('SGSE - Sistema de Gerenciamento de Secretaria', 105, 285, {
align: 'center'
});
doc.text('SGSE - Sistema de Gerenciamento de Secretaria', 105, 285, { align: 'center' });
return doc.output('blob');
}
@@ -250,17 +244,9 @@ export async function gerarDeclaracaoDependentesIR(funcionario: Funcionario): Pr
y = Math.max(y, 40);
y += 5;
addText(doc, 'DECLARAÇÃO DE DEPENDENTES', 105, y, {
bold: true,
size: 12,
align: 'center'
});
addText(doc, 'DECLARAÇÃO DE DEPENDENTES', 105, y, { bold: true, size: 12, align: 'center' });
y += 6;
addText(doc, 'PARA FINS DE IMPOSTO DE RENDA', 105, y, {
bold: true,
size: 12,
align: 'center'
});
addText(doc, 'PARA FINS DE IMPOSTO DE RENDA', 105, y, { bold: true, size: 12, align: 'center' });
y += 15;
// Corpo
@@ -337,9 +323,7 @@ export async function gerarDeclaracaoDependentesIR(funcionario: Funcionario): Pr
// Rodapé
doc.setFontSize(8);
doc.setTextColor(100);
doc.text('SGSE - Sistema de Gerenciamento de Secretaria', 105, 285, {
align: 'center'
});
doc.text('SGSE - Sistema de Gerenciamento de Secretaria', 105, 285, { align: 'center' });
return doc.output('blob');
}
@@ -368,11 +352,7 @@ export async function gerarDeclaracaoIdoneidade(funcionario: Funcionario): Promi
y = Math.max(y, 40);
y += 5;
addText(doc, 'DECLARAÇÃO DE IDONEIDADE MORAL', 105, y, {
bold: true,
size: 12,
align: 'center'
});
addText(doc, 'DECLARAÇÃO DE IDONEIDADE MORAL', 105, y, { bold: true, size: 12, align: 'center' });
y += 15;
// Corpo
@@ -439,9 +419,7 @@ export async function gerarDeclaracaoIdoneidade(funcionario: Funcionario): Promi
// Rodapé
doc.setFontSize(8);
doc.setTextColor(100);
doc.text('SGSE - Sistema de Gerenciamento de Secretaria', 105, 285, {
align: 'center'
});
doc.text('SGSE - Sistema de Gerenciamento de Secretaria', 105, 285, { align: 'center' });
return doc.output('blob');
}
@@ -566,9 +544,7 @@ export async function gerarTermoNepotismo(funcionario: Funcionario): Promise<Blo
// Rodapé
doc.setFontSize(8);
doc.setTextColor(100);
doc.text('SGSE - Sistema de Gerenciamento de Secretaria', 105, 285, {
align: 'center'
});
doc.text('SGSE - Sistema de Gerenciamento de Secretaria', 105, 285, { align: 'center' });
return doc.output('blob');
}
@@ -597,11 +573,7 @@ export async function gerarTermoOpcaoRemuneracao(funcionario: Funcionario): Prom
y = Math.max(y, 40);
y += 5;
addText(doc, 'TERMO DE OPÇÃO DE REMUNERAÇÃO', 105, y, {
bold: true,
size: 12,
align: 'center'
});
addText(doc, 'TERMO DE OPÇÃO DE REMUNERAÇÃO', 105, y, { bold: true, size: 12, align: 'center' });
y += 15;
// Corpo
@@ -705,9 +677,7 @@ export async function gerarTermoOpcaoRemuneracao(funcionario: Funcionario): Prom
// Rodapé
doc.setFontSize(8);
doc.setTextColor(100);
doc.text('SGSE - Sistema de Gerenciamento de Secretaria', 105, 285, {
align: 'center'
});
doc.text('SGSE - Sistema de Gerenciamento de Secretaria', 105, 285, { align: 'center' });
return doc.output('blob');
}

View File

@@ -776,11 +776,8 @@ export async function obterIPPublico(): Promise<string | undefined> {
async function solicitarPermissaoSensor(): Promise<PermissionState> {
if (
typeof DeviceMotionEvent === 'undefined' ||
typeof (
DeviceMotionEvent as {
requestPermission?: () => Promise<PermissionState>;
}
).requestPermission !== 'function'
typeof (DeviceMotionEvent as { requestPermission?: () => Promise<PermissionState> })
.requestPermission !== 'function'
) {
// Permissão não necessária ou já concedida (navegadores modernos)
return 'granted';
@@ -827,12 +824,7 @@ async function obterDadosAcelerometro(duracaoMs: number = 5000): Promise<{
}
return new Promise((resolve) => {
const leiturasAcelerometro: Array<{
x: number;
y: number;
z: number;
timestamp: number;
}> = [];
const leiturasAcelerometro: Array<{ x: number; y: number; z: number; timestamp: number }> = [];
const leiturasGiroscopio: Array<{
alpha: number;
beta: number;
@@ -864,7 +856,7 @@ async function obterDadosAcelerometro(duracaoMs: number = 5000): Promise<{
leiturasAcelerometro.reduce((sum, l) => sum + l.z, 0) / leiturasAcelerometro.length;
const variacoes = leiturasAcelerometro.map(
(l) => (l.x - mediaX) ** 2 + (l.y - mediaY) ** 2 + (l.z - mediaZ) ** 2
(l) => Math.pow(l.x - mediaX, 2) + Math.pow(l.y - mediaY, 2) + Math.pow(l.z - mediaZ, 2)
);
const variacao = variacoes.reduce((sum, v) => sum + v, 0) / variacoes.length;

View File

@@ -0,0 +1,301 @@
/**
* Utilitário de Criptografia End-to-End (E2E) para mensagens do chat
* Usa Web Crypto API com AES-GCM para criptografia simétrica
*/
export interface EncryptionKey {
key: CryptoKey;
keyId: string; // Identificador único da chave
createdAt: number; // Timestamp de criação
}
export interface EncryptedMessage {
encryptedContent: string; // Base64
iv: string; // Base64
keyId: string; // Identificador da chave usada
}
/**
* Gera uma chave de criptografia AES-GCM de 256 bits
*/
export async function generateEncryptionKey(): Promise<EncryptionKey> {
const key = await crypto.subtle.generateKey(
{
name: 'AES-GCM',
length: 256
},
true, // extractable
['encrypt', 'decrypt']
);
// Gerar um ID único para a chave
const keyId = crypto.randomUUID();
return {
key,
keyId,
createdAt: Date.now()
};
}
/**
* Exporta uma chave para formato JSON (armazenamento)
*/
export async function exportKey(key: CryptoKey): Promise<string> {
const exported = await crypto.subtle.exportKey('raw', key);
const exportedArray = new Uint8Array(exported);
return btoa(String.fromCharCode(...exportedArray));
}
/**
* Importa uma chave de formato JSON (armazenamento)
*/
export async function importKey(keyData: string): Promise<CryptoKey> {
const keyArray = Uint8Array.from(atob(keyData), (c) => c.charCodeAt(0));
return await crypto.subtle.importKey(
'raw',
keyArray,
{
name: 'AES-GCM',
length: 256
},
true, // extractable
['encrypt', 'decrypt']
);
}
/**
* Criptografa uma mensagem usando uma chave
*/
export async function encryptMessage(
message: string,
key: CryptoKey
): Promise<EncryptedMessage> {
const encoder = new TextEncoder();
const data = encoder.encode(message);
// Gerar IV (Initialization Vector) aleatório de 12 bytes para AES-GCM
const iv = crypto.getRandomValues(new Uint8Array(12));
// Criptografar
const encrypted = await crypto.subtle.encrypt(
{
name: 'AES-GCM',
iv: iv
},
key,
data
);
// Converter para base64 para armazenamento
const encryptedArray = new Uint8Array(encrypted);
const encryptedBase64 = btoa(String.fromCharCode(...encryptedArray));
const ivBase64 = btoa(String.fromCharCode(...iv));
// Gerar keyId (usaremos o hash da chave exportada como identificador)
const keyData = await exportKey(key);
const keyId = await hashString(keyData);
return {
encryptedContent: encryptedBase64,
iv: ivBase64,
keyId
};
}
/**
* Criptografa um arquivo (Blob) usando uma chave
*/
export async function encryptFile(
file: Blob,
key: CryptoKey
): Promise<{ encryptedBlob: Blob; iv: string; keyId: string }> {
const arrayBuffer = await file.arrayBuffer();
const data = new Uint8Array(arrayBuffer);
// Gerar IV (Initialization Vector) aleatório de 12 bytes para AES-GCM
const iv = crypto.getRandomValues(new Uint8Array(12));
// Criptografar
const encrypted = await crypto.subtle.encrypt(
{
name: 'AES-GCM',
iv: iv
},
key,
data
);
// Converter para Blob
const encryptedBlob = new Blob([encrypted], { type: 'application/octet-stream' });
// Converter IV para base64
const ivBase64 = btoa(String.fromCharCode(...iv));
// Gerar keyId
const keyData = await exportKey(key);
const keyId = await hashString(keyData);
return {
encryptedBlob,
iv: ivBase64,
keyId
};
}
/**
* Descriptografa um arquivo (Blob) usando uma chave
*/
export async function decryptFile(
encryptedBlob: Blob,
iv: string,
key: CryptoKey
): Promise<Blob> {
try {
// Decodificar base64 do IV
const ivArray = Uint8Array.from(atob(iv), (c) => c.charCodeAt(0));
// Ler dados criptografados
const encryptedArrayBuffer = await encryptedBlob.arrayBuffer();
const encrypted = new Uint8Array(encryptedArrayBuffer);
// Descriptografar
const decrypted = await crypto.subtle.decrypt(
{
name: 'AES-GCM',
iv: ivArray
},
key,
encrypted
);
// Converter para Blob
return new Blob([decrypted]);
} catch (error) {
console.error('Erro ao descriptografar arquivo:', error);
throw new Error('Falha ao descriptografar arquivo. A chave pode estar incorreta.');
}
}
/**
* Descriptografa uma mensagem usando uma chave
*/
export async function decryptMessage(
encryptedMessage: EncryptedMessage,
key: CryptoKey
): Promise<string> {
try {
// Decodificar base64
const encryptedArray = Uint8Array.from(
atob(encryptedMessage.encryptedContent),
(c) => c.charCodeAt(0)
);
const iv = Uint8Array.from(atob(encryptedMessage.iv), (c) => c.charCodeAt(0));
// Descriptografar
const decrypted = await crypto.subtle.decrypt(
{
name: 'AES-GCM',
iv: iv
},
key,
encryptedArray
);
// Converter para string
const decoder = new TextDecoder();
return decoder.decode(decrypted);
} catch (error) {
console.error('Erro ao descriptografar mensagem:', error);
throw new Error('Falha ao descriptografar mensagem. A chave pode estar incorreta.');
}
}
/**
* Gera hash SHA-256 de uma string (para usar como keyId)
*/
async function hashString(str: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(str);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = new Uint8Array(hashBuffer);
// Retornar apenas os primeiros 16 bytes como hex para keyId mais curto
return Array.from(hashArray.slice(0, 16))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
/**
* Armazena uma chave de criptografia no localStorage
*/
export function storeEncryptionKey(conversaId: string, keyData: string, keyId: string): void {
try {
const storageKey = `e2e_key_${conversaId}`;
const keyInfo = {
keyData,
keyId,
createdAt: Date.now()
};
localStorage.setItem(storageKey, JSON.stringify(keyInfo));
} catch (error) {
console.error('Erro ao armazenar chave de criptografia:', error);
throw new Error('Falha ao armazenar chave de criptografia');
}
}
/**
* Recupera uma chave de criptografia do localStorage
*/
export function getStoredEncryptionKey(conversaId: string): {
keyData: string;
keyId: string;
createdAt: number;
} | null {
try {
const storageKey = `e2e_key_${conversaId}`;
const stored = localStorage.getItem(storageKey);
if (!stored) {
return null;
}
return JSON.parse(stored) as { keyData: string; keyId: string; createdAt: number };
} catch (error) {
console.error('Erro ao recuperar chave de criptografia:', error);
return null;
}
}
/**
* Remove uma chave de criptografia do localStorage
*/
export function removeStoredEncryptionKey(conversaId: string): void {
try {
const storageKey = `e2e_key_${conversaId}`;
localStorage.removeItem(storageKey);
} catch (error) {
console.error('Erro ao remover chave de criptografia:', error);
}
}
/**
* Verifica se uma conversa tem criptografia E2E habilitada
*/
export function hasEncryptionKey(conversaId: string): boolean {
return getStoredEncryptionKey(conversaId) !== null;
}
/**
* Carrega e importa uma chave de criptografia do localStorage
*/
export async function loadEncryptionKey(conversaId: string): Promise<CryptoKey | null> {
const stored = getStoredEncryptionKey(conversaId);
if (!stored) {
return null;
}
try {
return await importKey(stored.keyData);
} catch (error) {
console.error('Erro ao importar chave de criptografia:', error);
return null;
}
}

View File

@@ -0,0 +1,446 @@
import jsPDF from 'jspdf';
import autoTable from 'jspdf-autotable';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { formatarHoraPonto, formatarDataDDMMAAAA, getTipoRegistroLabel } from './ponto';
// Tipos e interfaces
export type TipoDia = 'normal' | 'atestado' | 'ausencia' | 'licenca' | 'abonado' | 'nao_computado' | 'ferias' | 'inconsistente';
export interface SaldoDiario {
diferencaMinutos: number;
trabalhadoMinutos: number;
esperadoMinutos: number;
}
export interface RegistroPonto {
_id: Id<'registrosPonto'>;
tipo: 'entrada' | 'saida_almoco' | 'retorno_almoco' | 'saida';
data: string;
hora: number;
minuto: number;
timestamp: number;
dentroDoPrazo: boolean;
}
export interface DiaFichaPonto {
data: string;
dataFormatada: string;
tipoDia: TipoDia;
registros: RegistroPonto[];
registrosEsperados: Array<{ tipo: string; hora: number; minuto: number; data: string }>;
saldoDiario: SaldoDiario | null;
saldoAcumulado: number;
atestado: {
_id: Id<'atestados'>;
tipo: string;
dataInicio: string;
dataFim: string;
motivo?: string;
} | null;
ausencia: {
_id: Id<'solicitacoesAusencias'>;
motivo: string;
dataInicio: string;
dataFim: string;
status: string;
} | null;
licenca: {
_id: Id<'licencas'>;
tipo: string;
dataInicio: string;
dataFim: string;
} | null;
ajustes: Array<{
_id: Id<'ajustesBancoHoras'>;
tipo: 'abonar' | 'descontar' | 'compensar';
valorMinutos: number;
motivoDescricao?: string;
gestorId?: Id<'usuarios'>;
}>;
inconsistencias: Array<{
_id: Id<'inconsistenciasBancoHoras'>;
tipo: string;
descricao: string;
dataDetectada: string;
status: 'pendente' | 'resolvida' | 'ignorada';
resolvidoPor?: Id<'usuarios'>;
resolvidoEm?: number;
}>;
homologacoes: Array<{
_id: Id<'homologacoesPonto'>;
motivoDescricao?: string;
gestorId: Id<'usuarios'>;
}>;
dispensa: {
_id: Id<'dispensasRegistro'>;
motivo: string;
dataInicio: string;
dataFim: string;
ativo: boolean;
} | null;
computado: boolean;
}
export interface ResumoPeriodo {
totalDias: number;
diasTrabalhados: number;
diasComAtestado: number;
diasAusentes: number;
diasComLicenca: number;
diasAbonados: number;
diasNaoComputados: number;
diasComInconsistencia: number;
totalHorasTrabalhadas: number;
totalHorasEsperadas: number;
diferencaTotal: number;
saldoInicial: number;
saldoFinal: number;
saldoPeriodo: number;
totalInconsistencias: number;
saldoInicialFormatado?: string;
saldoPeriodoFormatado?: string;
saldoFinalFormatado?: string;
totalHorasTrabalhadasFormatado?: string;
totalHorasEsperadasFormatado?: string;
diferencaTotalFormatado?: string;
}
export interface SectionsPDF {
dadosFuncionario: boolean;
registrosPonto: boolean;
saldoDiario: boolean;
bancoHoras: boolean;
alteracoesGestor: boolean;
dispensasRegistro: boolean;
}
export interface FuncionarioPDF {
_id: Id<'funcionarios'>;
nome: string;
matricula?: string;
descricaoCargo?: string;
}
export interface ConfigPontoPDF {
horarioEntrada: string;
horarioSaidaAlmoco: string;
horarioRetornoAlmoco: string;
horarioSaida: string;
nomeEntrada?: string;
nomeSaidaAlmoco?: string;
nomeRetornoAlmoco?: string;
nomeSaida?: string;
}
type JsPDFWithAutoTable = jsPDF & {
lastAutoTable?: { finalY: number };
};
// Função auxiliar para adicionar logo
export async function adicionarLogo(doc: jsPDF, logoGovPE: string): Promise<number> {
let yPosition = 20;
try {
const logoImg = new Image();
logoImg.src = logoGovPE;
await new Promise<void>((resolve, reject) => {
logoImg.onload = () => resolve();
logoImg.onerror = () => reject();
setTimeout(() => reject(), 3000);
});
const logoWidth = 25;
const aspectRatio = logoImg.height / logoImg.width;
const logoHeight = logoWidth * aspectRatio;
doc.addImage(logoImg, 'PNG', 15, 10, logoWidth, logoHeight);
yPosition = Math.max(20, 10 + logoHeight / 2);
} catch (err) {
console.warn('Não foi possível carregar a logo:', err);
}
return yPosition;
}
// Função auxiliar para adicionar cabeçalho
export function adicionarCabecalho(doc: jsPDF, yPosition: number): number {
doc.setFontSize(16);
doc.setTextColor(41, 128, 185);
doc.text('FICHA DE PONTO', 105, yPosition, { align: 'center' });
return yPosition + 10;
}
// Função auxiliar para verificar se precisa de nova página
export function verificarNovaPagina(doc: jsPDF, yPosition: number, limite: number = 250): number {
if (yPosition > limite) {
doc.addPage();
return 20;
}
return yPosition;
}
// Função auxiliar para adicionar dados do funcionário
export function adicionarDadosFuncionario(
doc: jsPDF,
yPosition: number,
funcionario: FuncionarioPDF,
dataInicio: string,
dataFim: string
): number {
doc.setFontSize(12);
doc.setTextColor(0, 0, 0);
doc.setFont('helvetica', 'bold');
doc.text('DADOS DO FUNCIONÁRIO', 15, yPosition);
doc.setFont('helvetica', 'normal');
yPosition += 8;
doc.setFontSize(10);
if (funcionario.matricula) {
doc.text(`Matrícula: ${funcionario.matricula}`, 15, yPosition);
yPosition += 6;
}
doc.text(`Nome: ${funcionario.nome}`, 15, yPosition);
yPosition += 6;
if (funcionario.descricaoCargo) {
doc.text(`Cargo/Função: ${funcionario.descricaoCargo}`, 15, yPosition);
yPosition += 6;
}
yPosition += 5;
const periodoFormatado = `${formatarDataDDMMAAAA(dataInicio)} a ${formatarDataDDMMAAAA(dataFim)}`;
doc.text(`Período: ${periodoFormatado}`, 15, yPosition);
yPosition += 10;
return yPosition;
}
// Função auxiliar para formatar horas e minutos
export function formatarHorasMinutos(minutos: number): string {
const horas = Math.floor(Math.abs(minutos) / 60);
const mins = Math.abs(minutos) % 60;
const sinal = minutos >= 0 ? '+' : '-';
return `${sinal}${horas}h ${mins}min`;
}
// Função auxiliar para adicionar resumo do período
export function adicionarResumoPeriodo(
doc: jsPDF,
yPosition: number,
resumo: ResumoPeriodo,
formatarHoras: (minutos: number) => string,
formatarMinutos: (minutos: number) => string
): number {
yPosition = verificarNovaPagina(doc, yPosition);
doc.setFontSize(14);
doc.setTextColor(41, 128, 185);
doc.setFont('helvetica', 'bold');
doc.text('RESUMO DO PERÍODO', 15, yPosition);
yPosition += 10;
const resumoData: Array<[string, string]> = [
['Total de Dias', resumo.totalDias.toString()],
['Dias Trabalhados', resumo.diasTrabalhados.toString()],
['Dias com Atestado', resumo.diasComAtestado.toString()],
['Dias Ausentes', resumo.diasAusentes.toString()],
['Dias com Licença', resumo.diasComLicenca.toString()],
['Dias Abonados', resumo.diasAbonados.toString()],
['Dias Não Computados', resumo.diasNaoComputados.toString()],
['Dias com Inconsistência', resumo.diasComInconsistencia.toString()],
['Total de Inconsistências', resumo.totalInconsistencias.toString()],
['Total de Horas Trabalhadas', resumo.totalHorasTrabalhadasFormatado || formatarHoras(resumo.totalHorasTrabalhadas)],
['Total de Horas Esperadas', resumo.totalHorasEsperadasFormatado || formatarHoras(resumo.totalHorasEsperadas)],
['Diferença Total', resumo.diferencaTotalFormatado || formatarMinutos(resumo.diferencaTotal)]
];
autoTable(doc, {
startY: yPosition,
head: [['Item', 'Valor']],
body: resumoData,
theme: 'striped',
headStyles: {
fillColor: [41, 128, 185],
textColor: [255, 255, 255],
fontStyle: 'bold',
fontSize: 10
},
bodyStyles: {
fontSize: 9
},
columnStyles: {
0: { cellWidth: 100, fontStyle: 'bold' },
1: { cellWidth: 90 }
},
margin: { left: 15, right: 15 },
styles: { cellPadding: 3 },
didParseCell: (data) => {
if (data.section === 'body' && data.column.index === 1) {
const valor = data.cell.text[0] as string;
if (valor.startsWith('+')) {
data.cell.styles.textColor = [0, 128, 0];
data.cell.styles.fontStyle = 'bold';
} else if (valor.startsWith('-')) {
data.cell.styles.textColor = [200, 0, 0];
data.cell.styles.fontStyle = 'bold';
}
}
}
});
const finalYResumo = (doc as JsPDFWithAutoTable).lastAutoTable?.finalY ?? yPosition + 10;
return finalYResumo + 10;
}
// Função auxiliar para adicionar saldos do período
export function adicionarSaldosPeriodo(
doc: jsPDF,
yPosition: number,
resumo: ResumoPeriodo,
formatarMinutos: (minutos: number) => string
): number {
yPosition = verificarNovaPagina(doc, yPosition);
doc.setFontSize(12);
doc.setFont('helvetica', 'bold');
doc.setTextColor(41, 128, 185);
doc.text('SALDOS DO PERÍODO', 15, yPosition);
doc.setFont('helvetica', 'normal');
doc.setTextColor(0, 0, 0);
yPosition += 10;
const saldosData: Array<[string, string]> = [
['Saldo Inicial', resumo.saldoInicialFormatado || formatarMinutos(resumo.saldoInicial)],
['Saldo do Período', resumo.saldoPeriodoFormatado || formatarMinutos(resumo.saldoPeriodo)],
['Saldo Final', resumo.saldoFinalFormatado || formatarMinutos(resumo.saldoFinal)]
];
autoTable(doc, {
startY: yPosition,
head: [['Tipo', 'Valor']],
body: saldosData,
theme: 'striped',
headStyles: {
fillColor: [41, 128, 185],
textColor: 255,
fontStyle: 'bold'
},
styles: {
fontSize: 10,
cellPadding: 4
},
columnStyles: {
0: { cellWidth: 120, fontStyle: 'bold' },
1: { cellWidth: 60, halign: 'right' }
},
didParseCell: (data) => {
if (data.section === 'body' && data.column.index === 1) {
const valor = data.cell.text[0] as string;
const linhaIndex = data.row.index;
let saldoMinutos = 0;
if (linhaIndex === 0) {
saldoMinutos = resumo.saldoInicial;
} else if (linhaIndex === 1) {
saldoMinutos = resumo.saldoPeriodo;
} else if (linhaIndex === 2) {
saldoMinutos = resumo.saldoFinal;
}
if (saldoMinutos > 0) {
data.cell.styles.textColor = [0, 128, 0];
data.cell.styles.fontStyle = 'bold';
} else if (saldoMinutos < 0) {
data.cell.styles.textColor = [200, 0, 0];
data.cell.styles.fontStyle = 'bold';
} else {
data.cell.styles.textColor = [0, 0, 0];
}
}
}
});
const finalYSaldos = (doc as JsPDFWithAutoTable).lastAutoTable?.finalY ?? yPosition + 10;
return finalYSaldos + 10;
}
// Função auxiliar para adicionar legenda
export function adicionarLegenda(doc: jsPDF, yPosition: number): number {
yPosition = verificarNovaPagina(doc, yPosition);
doc.setFontSize(14);
doc.setTextColor(41, 128, 185);
doc.setFont('helvetica', 'bold');
doc.text('LEGENDA', 15, yPosition);
yPosition += 10;
const legendaData: Array<[string, string]> = [
['Cor de Fundo - Branco', 'Dia normal'],
['Cor de Fundo - Azul Claro', 'Dia com atestado médico'],
['Cor de Fundo - Amarelo Claro', 'Dia com ausência aprovada'],
['Cor de Fundo - Verde Claro', 'Dia abonado'],
['Cor de Fundo - Cinza Claro', 'Dia não computado (dispensa/férias)'],
['Cor de Fundo - Laranja Claro', 'Dia com inconsistência'],
['Texto Verde', 'Saldo positivo / Registro marcado'],
['Texto Vermelho', 'Saldo negativo / Registro não marcado'],
['✓', 'Registro marcado'],
['✗', 'Registro não marcado'],
['⚠', 'Inconsistência detectada'],
['🏥', 'Atestado médico'],
['🚫', 'Ausência'],
['📋', 'Licença'],
['✅', 'Abonado'],
['⏸', 'Não computado']
];
autoTable(doc, {
startY: yPosition,
head: [['Símbolo/Cor', 'Significado']],
body: legendaData,
theme: 'striped',
headStyles: {
fillColor: [60, 60, 60],
textColor: [255, 255, 255],
fontStyle: 'bold',
fontSize: 10
},
bodyStyles: {
fontSize: 9
},
columnStyles: {
0: { cellWidth: 80, fontStyle: 'bold' },
1: { cellWidth: 110 }
},
margin: { left: 15, right: 15 },
styles: { cellPadding: 3 }
});
const finalYLegenda = (doc as JsPDFWithAutoTable).lastAutoTable?.finalY ?? yPosition + 10;
return finalYLegenda + 10;
}
// Função auxiliar para adicionar rodapé
export function adicionarRodape(doc: jsPDF): void {
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() - 10,
{ align: 'center' }
);
}
}

View File

@@ -204,7 +204,7 @@ export function formatarTamanhoBlob(bytes: number): string {
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / k ** i) * 100) / 100 + ' ' + sizes[i];
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
}
/**

View File

@@ -3,8 +3,8 @@
* Coleta métricas do navegador e aplicação para monitoramento
*/
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { ConvexClient } from 'convex/browser';
import { api } from '@sgse-app/backend/convex/_generated/api';
export interface SystemMetrics {
cpuUsage?: number;
@@ -53,9 +53,9 @@ async function estimateCPUUsage(): Promise<number> {
*/
function getMemoryUsage(): number {
try {
// @ts-expect-error - performance.memory é específico do Chrome
// @ts-ignore - performance.memory é específico do Chrome
if (performance.memory) {
// @ts-expect-error
// @ts-ignore
const { usedJSHeapSize, jsHeapSizeLimit } = performance.memory;
const usage = (usedJSHeapSize / jsHeapSizeLimit) * 100;
return Math.round(usage);
@@ -77,7 +77,7 @@ async function measureNetworkLatency(): Promise<number> {
const start = performance.now();
// Fazer uma requisição pequena para medir latência
await fetch(window.location.origin + '/favicon.ico', {
await fetch(window.location.origin + '/favicon.png', {
method: 'HEAD',
cache: 'no-cache'
});
@@ -106,7 +106,7 @@ async function getStorageUsage(): Promise<number> {
// Fallback: estimar baseado em localStorage
let totalSize = 0;
for (const key in localStorage) {
if (Object.hasOwn(localStorage, key)) {
if (localStorage.hasOwnProperty(key)) {
totalSize += localStorage[key].length + key.length;
}
}
@@ -181,7 +181,7 @@ let errorCount = 0;
// Interceptar erros globais
if (typeof window !== 'undefined') {
const originalError = console.error;
console.error = (...args: any[]) => {
console.error = function (...args: any[]) {
errorCount++;
originalError.apply(console, args);
};
@@ -295,7 +295,7 @@ export function getNetworkStatus(): {
} {
const online = navigator.onLine;
// @ts-expect-error - navigator.connection é experimental
// @ts-ignore - navigator.connection é experimental
const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
if (connection) {

View File

@@ -0,0 +1,349 @@
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
/**
* Calcula saldos parciais entre cada par entrada/saída
* Retorna um mapa com o índice do registro e seu saldo parcial
*/
export function calcularSaldosParciais(
registros: Array<{ tipo: string; hora: number; minuto: number; _id?: Id<'registrosPonto'> }>
): Map<
number,
{ saldoMinutos: number; horas: number; minutos: number; positivo: boolean; parNumero: number }
> {
const saldos = new Map<
number,
{ saldoMinutos: number; horas: number; minutos: number; positivo: boolean; parNumero: number }
>();
if (registros.length === 0) return saldos;
// Criar array com índices originais
const registrosComIndice = registros.map((r, idx) => ({ ...r, originalIndex: idx }));
// Ordenar registros por hora e minuto para processar em ordem cronológica
const registrosOrdenados = [...registrosComIndice].sort((a, b) => {
if (a.hora !== b.hora) {
return a.hora - b.hora;
}
return a.minuto - b.minuto;
});
// Identificar pares entrada/saída
// Par 1: entrada -> saida_almoco
// Par 2: retorno_almoco -> saida
let entradaAtual: (typeof registrosComIndice)[0] | null = null;
let parNumero = 1;
for (let i = 0; i < registrosOrdenados.length; i++) {
const registro = registrosOrdenados[i];
// Considerar entrada ou retorno_almoco como início de um período
if (registro.tipo === 'entrada' || registro.tipo === 'retorno_almoco') {
entradaAtual = registro;
} else if (entradaAtual) {
// Qualquer saída (saida_almoco ou saida) fecha o período atual
if (registro.tipo === 'saida_almoco' || registro.tipo === 'saida') {
// Calcular diferença entre saída e entrada
const minutosEntrada = entradaAtual.hora * 60 + entradaAtual.minuto;
const minutosSaida = registro.hora * 60 + registro.minuto;
let saldoMinutos = minutosSaida - minutosEntrada;
if (saldoMinutos < 0) {
saldoMinutos += 24 * 60; // Adicionar um dia em minutos
}
const horas = Math.floor(saldoMinutos / 60);
const minutos = saldoMinutos % 60;
// Salvar saldo no índice original do registro de saída
saldos.set(registro.originalIndex, {
saldoMinutos,
horas,
minutos,
positivo: true,
parNumero
});
entradaAtual = null; // Resetar para próximo par
parNumero++;
}
}
}
return saldos;
}
/**
* Calcula saldo diário simples (entrada até saída)
*/
export function calcularSaldoDiario(
registros: Array<{ tipo: string; hora: number; minuto: number }>
): { saldoMinutos: number; horas: number; minutos: number; positivo: boolean } | null {
if (registros.length === 0) return null;
// Ordenar registros por hora e minuto
const registrosOrdenados = [...registros].sort((a, b) => {
if (a.hora !== b.hora) {
return a.hora - b.hora;
}
return a.minuto - b.minuto;
});
// Buscar entrada (primeiro registro do tipo 'entrada')
const entrada = registrosOrdenados.find((r) => r.tipo === 'entrada');
// Buscar saída (último registro do tipo 'saida')
const saida = registrosOrdenados.filter((r) => r.tipo === 'saida').pop();
if (!entrada || !saida) return null;
// Calcular diferença em minutos
const minutosEntrada = entrada.hora * 60 + entrada.minuto;
const minutosSaida = saida.hora * 60 + saida.minuto;
// Se a saída for no dia seguinte (após meia-noite), adicionar 24 horas
let saldoMinutos = minutosSaida - minutosEntrada;
if (saldoMinutos < 0) {
saldoMinutos += 24 * 60; // Adicionar um dia em minutos
}
const horas = Math.floor(saldoMinutos / 60);
const minutos = saldoMinutos % 60;
return {
saldoMinutos,
horas,
minutos,
positivo: true // Sempre positivo, pois é tempo trabalhado
};
}
/**
* Calcula saldos por par entrada/saída
* Retorna um mapa com o índice do registro e informações do saldo do par
*/
export function calcularSaldosPorPar(
registros: Array<{ tipo: string; hora: number; minuto: number }>
): Map<
number,
{ saldoMinutos: number; horas: number; minutos: number; parIndex: number; tamanhoPar: number }
> {
const saldos = new Map<
number,
{ saldoMinutos: number; horas: number; minutos: number; parIndex: number; tamanhoPar: number }
>();
if (registros.length === 0) return saldos;
// Ordenar registros por hora e minuto
const registrosOrdenados = [...registros].sort((a, b) => {
if (a.hora !== b.hora) {
return a.hora - b.hora;
}
return a.minuto - b.minuto;
});
let parIndex = 0;
let entradaAtual: { tipo: string; hora: number; minuto: number; index: number } | null = null;
let indicesPar: number[] = [];
for (let i = 0; i < registrosOrdenados.length; i++) {
const reg = registrosOrdenados[i];
// Identificar início de um par (entrada ou retorno_almoco)
if (reg.tipo === 'entrada' || reg.tipo === 'retorno_almoco') {
// Se havia um par anterior incompleto, limpar
if (entradaAtual && indicesPar.length > 0) {
indicesPar = [];
}
entradaAtual = { ...reg, index: i };
indicesPar = [i];
}
// Identificar fim de um par (saida_almoco ou saida)
else if ((reg.tipo === 'saida_almoco' || reg.tipo === 'saida') && entradaAtual) {
indicesPar.push(i);
// Calcular saldo do par (saída - entrada)
const minutosEntrada = entradaAtual.hora * 60 + entradaAtual.minuto;
const minutosSaida = reg.hora * 60 + reg.minuto;
let saldoMinutos = minutosSaida - minutosEntrada;
if (saldoMinutos < 0) {
saldoMinutos += 24 * 60; // Adicionar um dia em minutos
}
const horas = Math.floor(saldoMinutos / 60);
const minutos = saldoMinutos % 60;
// Associar saldo a todos os registros do par
for (const idx of indicesPar) {
saldos.set(idx, {
saldoMinutos,
horas,
minutos,
parIndex,
tamanhoPar: indicesPar.length
});
}
parIndex++;
entradaAtual = null;
indicesPar = [];
}
}
return saldos;
}
/**
* Calcula saldos comparativos por par entrada/saída
* Compara horários reais com horários esperados configurados
* Retorna mapa com saldo trabalhado, esperado e diferença
*/
export function calcularSaldoComparativoPorPar(
registros: Array<{ tipo: string; hora: number; minuto: number }>,
config: {
horarioEntrada: string;
horarioSaidaAlmoco: string;
horarioRetornoAlmoco: string;
horarioSaida: string;
}
): Map<
number,
{
trabalhadoMinutos: number;
trabalhadoHoras: number;
trabalhadoMinutosResto: number;
esperadoMinutos: number;
esperadoHoras: number;
esperadoMinutosResto: number;
diferencaMinutos: number;
diferencaHoras: number;
diferencaMinutosResto: number;
parIndex: number;
tamanhoPar: number;
}
> {
const saldos = new Map<
number,
{
trabalhadoMinutos: number;
trabalhadoHoras: number;
trabalhadoMinutosResto: number;
esperadoMinutos: number;
esperadoHoras: number;
esperadoMinutosResto: number;
diferencaMinutos: number;
diferencaHoras: number;
diferencaMinutosResto: number;
parIndex: number;
tamanhoPar: number;
}
>();
if (registros.length === 0) return saldos;
// Parsear horários esperados da configuração
const [horaEntradaEsperada, minutoEntradaEsperado] = config.horarioEntrada.split(':').map(Number);
const [horaSaidaAlmocoEsperada, minutoSaidaAlmocoEsperado] = config.horarioSaidaAlmoco
.split(':')
.map(Number);
const [horaRetornoAlmocoEsperado, minutoRetornoAlmocoEsperado] = config.horarioRetornoAlmoco
.split(':')
.map(Number);
const [horaSaidaEsperada, minutoSaidaEsperado] = config.horarioSaida.split(':').map(Number);
// Ordenar registros por hora e minuto
const registrosOrdenados = [...registros].sort((a, b) => {
if (a.hora !== b.hora) {
return a.hora - b.hora;
}
return a.minuto - b.minuto;
});
let parIndex = 0;
let entradaAtual: { tipo: string; hora: number; minuto: number; index: number } | null = null;
let indicesPar: number[] = [];
for (let i = 0; i < registrosOrdenados.length; i++) {
const reg = registrosOrdenados[i];
// Identificar início de um par (entrada ou retorno_almoco)
if (reg.tipo === 'entrada' || reg.tipo === 'retorno_almoco') {
// Se havia um par anterior incompleto, limpar
if (entradaAtual && indicesPar.length > 0) {
indicesPar = [];
}
entradaAtual = { ...reg, index: i };
indicesPar = [i];
}
// Identificar fim de um par (saida_almoco ou saida)
else if ((reg.tipo === 'saida_almoco' || reg.tipo === 'saida') && entradaAtual) {
indicesPar.push(i);
// Calcular tempo trabalhado real (saída - entrada)
const minutosEntradaReal = entradaAtual.hora * 60 + entradaAtual.minuto;
const minutosSaidaReal = reg.hora * 60 + reg.minuto;
let trabalhadoMinutos = minutosSaidaReal - minutosEntradaReal;
if (trabalhadoMinutos < 0) {
trabalhadoMinutos += 24 * 60;
}
// Calcular tempo esperado baseado no tipo de par
let esperadoMinutos: number;
if (entradaAtual.tipo === 'entrada') {
// Par 1: entrada -> saida_almoco
const minutosEntradaEsperada = horaEntradaEsperada * 60 + minutoEntradaEsperado;
const minutosSaidaEsperada = horaSaidaAlmocoEsperada * 60 + minutoSaidaAlmocoEsperado;
esperadoMinutos = minutosSaidaEsperada - minutosEntradaEsperada;
if (esperadoMinutos < 0) {
esperadoMinutos += 24 * 60;
}
} else {
// Par 2: retorno_almoco -> saida
const minutosEntradaEsperada =
horaRetornoAlmocoEsperado * 60 + minutoRetornoAlmocoEsperado;
const minutosSaidaEsperada = horaSaidaEsperada * 60 + minutoSaidaEsperado;
esperadoMinutos = minutosSaidaEsperada - minutosEntradaEsperada;
if (esperadoMinutos < 0) {
esperadoMinutos += 24 * 60;
}
}
// Calcular diferença (trabalhado - esperado)
const diferencaMinutos = trabalhadoMinutos - esperadoMinutos;
// Converter para horas e minutos
const trabalhadoHoras = Math.floor(trabalhadoMinutos / 60);
const trabalhadoMinutosResto = trabalhadoMinutos % 60;
const esperadoHoras = Math.floor(esperadoMinutos / 60);
const esperadoMinutosResto = esperadoMinutos % 60;
const diferencaHoras = Math.floor(Math.abs(diferencaMinutos) / 60);
const diferencaMinutosResto = Math.abs(diferencaMinutos) % 60;
// Associar saldo a todos os registros do par
for (const idx of indicesPar) {
saldos.set(idx, {
trabalhadoMinutos,
trabalhadoHoras,
trabalhadoMinutosResto,
esperadoMinutos,
esperadoHoras,
esperadoMinutosResto,
diferencaMinutos,
diferencaHoras,
diferencaMinutosResto,
parIndex,
tamanhoPar: indicesPar.length
});
}
parIndex++;
entradaAtual = null;
indicesPar = [];
}
}
return saldos;
}

View File

@@ -0,0 +1,89 @@
/**
* Funções de formatação para dados de ponto
*/
/**
* Converte data de yyyy-mm-dd para dd/mm/yyyy
*/
export function formatarDataParaExibicao(data: string): string {
if (!data) return '';
const [ano, mes, dia] = data.split('-');
return `${dia}/${mes}/${ano}`;
}
/**
* Converte data de dd/mm/yyyy para yyyy-mm-dd
*/
export function formatarDataParaBackend(data: string, onlyDigits: (str: string) => string, validateDate: (str: string) => boolean): string {
if (!data) return '';
const apenasDigitos = onlyDigits(data);
if (apenasDigitos.length !== 8) return data;
const dia = apenasDigitos.slice(0, 2);
const mes = apenasDigitos.slice(2, 4);
const ano = apenasDigitos.slice(4, 8);
// Validar se a data é válida
if (!validateDate(`${dia}/${mes}/${ano}`)) {
return data; // Retornar valor original se inválido
}
return `${ano}-${mes}-${dia}`;
}
/**
* Formata saldo de horas em minutos para string legível
*/
export function formatarSaldoHoras(minutos: number): string {
const horas = Math.floor(Math.abs(minutos) / 60);
const mins = Math.abs(minutos) % 60;
const sinal = minutos >= 0 ? '+' : '-';
return `${sinal}${horas}h ${mins}min`;
}
/**
* Formata saldo diário
*/
export function formatarSaldoDiario(saldo?: {
saldoMinutos: number;
horas: number;
minutos: number;
positivo: boolean;
}): string {
if (!saldo) return '-';
const sinal = saldo.positivo ? '+' : '-';
return `${sinal}${saldo.horas}h ${saldo.minutos}min`;
}
/**
* Formata minutos para string HH:MM
*/
export function formatarMinutos(minutos: number): string {
const absMinutos = Math.abs(minutos);
const horas = Math.floor(absMinutos / 60);
const mins = absMinutos % 60;
const sinal = minutos >= 0 ? '+' : '-';
return `${sinal}${String(horas).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
}
/**
* Formata minutos para string de horas
*/
export function formatarHoras(minutos: number): string {
const absMinutos = Math.abs(minutos);
const horas = Math.floor(absMinutos / 60);
const mins = absMinutos % 60;
const sinal = minutos >= 0 ? '+' : '-';
return `${sinal}${horas}h ${mins}min`;
}
/**
* Obtém o nome do dia da semana
*/
export function obterDiaSemana(data: string): string {
const [ano, mes, dia] = data.split('-').map(Number);
const date = new Date(ano, mes - 1, dia);
const dias = ['Dom', 'Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb'];
return dias[date.getDay()] || '';
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,612 @@
import jsPDF from 'jspdf';
import autoTable from 'jspdf-autotable';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import type { ConvexClient } from 'convex-svelte';
import { formatarHoraPonto, formatarDataDDMMAAAA, getTipoRegistroLabel } from '../../ponto';
import type { DiaFichaPonto, ResumoPeriodo, TipoDia } from '../tipos';
import { processarDadosFichaPonto } from '../processamento';
import {
adicionarLogo,
adicionarCabecalho,
adicionarDadosFuncionario,
adicionarResumoPeriodo,
adicionarSaldosPeriodo,
adicionarLegenda,
adicionarRodape,
type SectionsPDF
} from '../../fichaPontoPDF';
import { formatarHoras, formatarMinutos } from '../formatacao';
import { validarPeriodo } from '../validacao';
/**
* Gera PDF com seleção de seções
*/
export async function gerarPDFComSelecao(
client: ConvexClient,
sections: SectionsPDF,
funcionarioId: Id<'funcionarios'>,
dataInicio: string,
dataFim: string,
funcionarios: Array<{ _id: Id<'funcionarios'>; nome: string; matricula?: string }>,
logoGovPE: string,
onError: (message: string) => void,
onSuccess: () => void,
setCarregando: (value: boolean) => void
): Promise<void> {
console.log('[gerarPDFComSelecao] Iniciando geração de PDF', {
funcionarioId,
dataInicio,
dataFim,
sections
});
// Verificar se pelo menos uma seção foi selecionada
if (!Object.values(sections).some((v) => v)) {
console.error('[gerarPDFComSelecao] Nenhuma seção selecionada');
onError('Selecione pelo menos uma seção para imprimir');
return;
}
// Validar período
const validacaoPeriodo = validarPeriodo(dataInicio, dataFim);
if (!validacaoPeriodo.valido) {
console.error('[gerarPDFComSelecao] Período inválido', validacaoPeriodo);
onError(validacaoPeriodo.erro || 'Período inválido');
return;
}
const funcionario = funcionarios.find((f) => f._id === funcionarioId);
if (!funcionario) {
console.error('[gerarPDFComSelecao] Funcionário não encontrado', funcionarioId);
onError('Funcionário não encontrado');
return;
}
try {
setCarregando(true);
console.log('[gerarPDFComSelecao] Processando dados...');
// Processar todos os dados necessários
const { dias, resumo, config: configPonto } = await processarDadosFichaPonto(
client,
funcionarioId,
dataInicio,
dataFim
);
console.log('[gerarPDFComSelecao] Dados processados', {
diasCount: dias.length,
resumo,
config: configPonto
});
if (dias.length === 0) {
console.error('[gerarPDFComSelecao] Nenhum dado encontrado');
onError('Nenhum dado encontrado para este funcionário no período selecionado');
setCarregando(false);
return;
}
const doc = new jsPDF();
// Logo e cabeçalho
let yPosition = await adicionarLogo(doc, logoGovPE);
yPosition = adicionarCabecalho(doc, yPosition);
// Dados do Funcionário
if (sections.dadosFuncionario) {
yPosition = adicionarDadosFuncionario(doc, yPosition, funcionario, dataInicio, dataFim);
}
// SEÇÃO: TABELA PRINCIPAL DE REGISTROS (PRIMEIRO)
if (sections.registrosPonto) {
yPosition = gerarTabelaRegistrosPDF(doc, yPosition, dias, configPonto, sections);
}
// Resumo do Período
yPosition = adicionarResumoPeriodo(doc, yPosition, resumo, formatarHoras, formatarMinutos);
// Saldos do Período
yPosition = adicionarSaldosPeriodo(doc, yPosition, resumo, formatarMinutos);
// Legenda
yPosition = adicionarLegenda(doc, yPosition);
// SEÇÃO: BANCO DE HORAS
if (sections.bancoHoras) {
yPosition = await gerarSecaoBancoHorasPDF(
doc,
yPosition,
client,
funcionarioId,
dataInicio,
dataFim,
configPonto
);
}
// SEÇÃO: INCONSISTÊNCIAS (usando alteracoesGestor)
if (sections.alteracoesGestor) {
yPosition = gerarSecaoInconsistenciasPDF(doc, yPosition, dias);
}
// SEÇÃO: AJUSTES (usando alteracoesGestor)
if (sections.alteracoesGestor) {
yPosition = gerarSecaoAjustesPDF(doc, yPosition, dias);
}
// SEÇÃO: DISPENSAS
if (sections.dispensasRegistro) {
yPosition = gerarSecaoDispensasPDF(doc, yPosition, dias);
}
// Rodapé
adicionarRodape(doc);
// Salvar
const nomeArquivo = `ficha-ponto-${funcionario.matricula || funcionario.nome}-${dataInicio}-${dataFim}.pdf`;
console.log('[gerarPDFComSelecao] Salvando PDF:', nomeArquivo);
doc.save(nomeArquivo);
console.log('[gerarPDFComSelecao] PDF gerado com sucesso');
onSuccess();
} catch (error) {
console.error('Erro ao gerar PDF:', error);
const errorMessage = error instanceof Error ? error.message : String(error);
// Mensagens de erro mais específicas
if (errorMessage.includes('Configuração de ponto não encontrada')) {
onError('Configuração de ponto não encontrada. Entre em contato com o administrador.');
} else if (errorMessage.includes('Nenhum dado encontrado')) {
onError('Nenhum dado encontrado para este funcionário no período selecionado.');
} else if (errorMessage.includes('timeout') || errorMessage.includes('Timeout')) {
onError('Tempo de geração excedido. Tente um período menor (máximo 90 dias).');
} else {
onError(`Erro ao gerar ficha de ponto: ${errorMessage}`);
}
} finally {
setCarregando(false);
}
}
/**
* Gera tabela de registros de ponto no PDF
*/
function gerarTabelaRegistrosPDF(
doc: jsPDF,
yPosition: number,
dias: DiaFichaPonto[],
config: {
horarioEntrada: string;
horarioSaidaAlmoco: string;
horarioRetornoAlmoco: string;
horarioSaida: string;
nomeEntrada?: string;
nomeSaidaAlmoco?: string;
nomeRetornoAlmoco?: string;
nomeSaida?: string;
},
sections: SectionsPDF
): number {
if (yPosition > 250) {
doc.addPage();
yPosition = 20;
}
doc.setFontSize(14);
doc.setTextColor(41, 128, 185);
doc.setFont('helvetica', 'bold');
doc.text('REGISTROS DE PONTO', 15, yPosition);
yPosition += 10;
// Função auxiliar para obter cor de fundo baseada no tipo de dia
const obterCorFundoTipoDia = (tipoDia: TipoDia): number[] => {
switch (tipoDia) {
case 'atestado':
return [230, 240, 255]; // Azul claro
case 'ausencia':
return [255, 255, 230]; // Amarelo claro
case 'abonado':
return [230, 255, 230]; // Verde claro
case 'nao_computado':
return [240, 240, 240]; // Cinza claro
case 'inconsistente':
return [255, 240, 230]; // Laranja claro
default:
return [255, 255, 255]; // Branco
}
};
// Função auxiliar para obter ícone do tipo de dia
const obterIconeTipoDia = (dia: DiaFichaPonto): string => {
if (dia.atestado) return '🏥';
if (dia.ausencia) return '🚫';
if (dia.licenca) return '📋';
if (dia.tipoDia === 'abonado') return '✅';
if (dia.tipoDia === 'nao_computado') return '⏸';
if (dia.inconsistencias.length > 0) return '⚠';
return '';
};
// Preparar dados da tabela
const tableData: Array<
Array<
| string
| {
content: string;
styles?: { fillColor?: number[]; textColor?: number[]; fontStyle?: string };
}
>
> = [];
for (const dia of dias) {
const dataFormatada = dia.dataFormatada;
const todosRegistros = [
...dia.registros.map((r) => ({ ...r, real: true })),
...dia.registrosEsperados
.filter((re) => !dia.registros.some((r) => r.tipo === re.tipo))
.map((re) => ({ ...re, real: false }))
].sort((a, b) => {
if (a.hora !== b.hora) return a.hora - b.hora;
return a.minuto - b.minuto;
});
for (let i = 0; i < todosRegistros.length; i++) {
const reg = todosRegistros[i];
const linha: Array<
string | { content: string; styles?: { fillColor?: number[]; textColor?: number[]; fontStyle?: string } }
> = [];
// Coluna Data (apenas na primeira linha)
if (i === 0) {
linha.push({
content: `${dataFormatada} ${obterIconeTipoDia(dia)}`,
styles: {
fillColor: obterCorFundoTipoDia(dia.tipoDia),
fontStyle: 'bold'
}
});
} else {
linha.push('');
}
// Coluna Tipo
const tipoLabel = config
? getTipoRegistroLabel(reg.tipo as 'entrada' | 'saida_almoco' | 'retorno_almoco' | 'saida', {
nomeEntrada: config.nomeEntrada,
nomeSaidaAlmoco: config.nomeSaidaAlmoco,
nomeRetornoAlmoco: config.nomeRetornoAlmoco,
nomeSaida: config.nomeSaida
})
: getTipoRegistroLabel(reg.tipo as 'entrada' | 'saida_almoco' | 'retorno_almoco' | 'saida');
if (!('real' in reg) || reg.real) {
linha.push(tipoLabel);
} else {
linha.push({
content: tipoLabel,
styles: { textColor: [200, 0, 0] } // Vermelho para não marcado
});
}
// Coluna Horário
const horario = formatarHoraPonto(reg.hora, reg.minuto);
if (!('real' in reg) || reg.real) {
linha.push(horario);
} else {
linha.push({
content: horario,
styles: { textColor: [200, 0, 0] } // Vermelho para não marcado
});
}
// Coluna Saldo Diário (se seção selecionada)
if (sections.saldoDiario) {
if (i === 0 && dia.saldoDiario) {
const saldoFormatado = formatarMinutos(dia.saldoDiario.diferencaMinutos);
const corSaldo = dia.saldoDiario.diferencaMinutos < 0 ? [200, 0, 0] : [0, 128, 0];
linha.push({
content: saldoFormatado,
styles: { textColor: corSaldo, fontStyle: 'bold' }
});
} else {
linha.push('');
}
}
// Coluna Observações (apenas na primeira linha)
if (i === 0) {
const observacoes: string[] = [];
if (dia.atestado) {
observacoes.push(`Atestado: ${dia.atestado.tipo}`);
}
if (dia.ausencia) {
observacoes.push(`Ausência: ${dia.ausencia.motivo}`);
}
if (dia.licenca) {
observacoes.push(`Licença: ${dia.licenca.tipo}`);
}
if (dia.dispensa) {
observacoes.push(`Dispensa: ${dia.dispensa.motivo}`);
}
if (dia.inconsistencias.length > 0) {
observacoes.push(`Inconsistências: ${dia.inconsistencias.length}`);
}
if (dia.ajustes.length > 0) {
observacoes.push(
`Ajustes: ${dia.ajustes.map((a) => `${a.tipo} ${formatarMinutos(a.valorMinutos)}`).join(', ')}`
);
}
linha.push(observacoes.join('; ') || '-');
} else {
linha.push('');
}
// Coluna Dentro do Prazo
if ('real' in reg && reg.real && 'dentroDoPrazo' in reg) {
linha.push(reg.dentroDoPrazo ? 'Sim' : 'Não');
} else {
linha.push('Não marcado');
}
tableData.push(linha);
}
}
// Cabeçalhos da tabela
const headers = ['Data', 'Tipo', 'Horário'];
if (sections.saldoDiario) {
headers.push('Saldo Diário');
}
headers.push('Observações', 'Dentro do Prazo');
// Adicionar tabela
autoTable(doc, {
startY: yPosition,
head: [headers],
body: tableData,
theme: 'grid',
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
styles: { fontSize: 9 },
didParseCell: function (data: any) {
if (data.section === 'body' && data.cell.raw) {
const cellData = data.cell.raw;
if (typeof cellData === 'object' && cellData.styles) {
if (cellData.styles.fillColor) {
data.cell.styles.fillColor = cellData.styles.fillColor;
}
if (cellData.styles.textColor) {
data.cell.styles.textColor = cellData.styles.textColor;
}
if (cellData.styles.fontStyle) {
data.cell.styles.fontStyle = cellData.styles.fontStyle;
}
}
}
}
});
// Calcular nova posição Y
const lastPage = doc.getNumberOfPages();
doc.setPage(lastPage);
type JsPDFWithAutoTable = jsPDF & {
lastAutoTable?: { finalY: number };
};
const finalY = (doc as JsPDFWithAutoTable).lastAutoTable?.finalY;
return finalY ? finalY + 10 : yPosition + tableData.length * 7 + 10;
}
/**
* Gera seção de banco de horas no PDF
*/
async function gerarSecaoBancoHorasPDF(
doc: jsPDF,
yPosition: number,
client: ConvexClient,
funcionarioId: Id<'funcionarios'>,
dataInicio: string,
dataFim: string,
config: {
horarioEntrada: string;
horarioSaidaAlmoco: string;
horarioRetornoAlmoco: string;
horarioSaida: string;
}
): Promise<number> {
if (yPosition > doc.internal.pageSize.getHeight() - 60) {
doc.addPage();
yPosition = 20;
}
doc.setFontSize(12);
doc.setFont('helvetica', 'bold');
doc.setTextColor(41, 128, 185);
doc.text('BANCO DE HORAS', 15, yPosition);
doc.setFont('helvetica', 'normal');
doc.setTextColor(0, 0, 0);
yPosition += 10;
// Buscar banco de horas
const { api } = await import('@sgse-app/backend/convex/_generated/api');
const bancoHoras = await client.query(api.pontos.obterBancoHorasFuncionario, {
funcionarioId
});
if (bancoHoras) {
const bancoData = [
['Saldo Atual', formatarMinutos(bancoHoras.saldoAtualMinutos || 0)],
['Saldo Inicial', formatarMinutos(bancoHoras.saldoInicialMinutos || 0)],
['Saldo Final', formatarMinutos(bancoHoras.saldoFinalMinutos || 0)]
];
autoTable(doc, {
startY: yPosition,
head: [['Campo', 'Valor']],
body: bancoData,
theme: 'striped',
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
styles: { fontSize: 10 }
});
type JsPDFWithAutoTable = jsPDF & {
lastAutoTable?: { finalY: number };
};
const finalY = (doc as JsPDFWithAutoTable).lastAutoTable?.finalY;
return finalY ? finalY + 10 : yPosition + bancoData.length * 7 + 10;
}
return yPosition;
}
/**
* Gera seção de inconsistências no PDF
*/
function gerarSecaoInconsistenciasPDF(doc: jsPDF, yPosition: number, dias: DiaFichaPonto[]): number {
const todasInconsistencias = dias.flatMap((dia) =>
dia.inconsistencias.map((inc) => ({
...inc,
data: dia.data,
dataFormatada: dia.dataFormatada
}))
);
if (todasInconsistencias.length === 0) {
return yPosition;
}
if (yPosition > doc.internal.pageSize.getHeight() - 60) {
doc.addPage();
yPosition = 20;
}
doc.setFontSize(12);
doc.setFont('helvetica', 'bold');
doc.setTextColor(41, 128, 185);
doc.text('INCONSISTÊNCIAS', 15, yPosition);
doc.setFont('helvetica', 'normal');
doc.setTextColor(0, 0, 0);
yPosition += 10;
const inconsistenciasData = todasInconsistencias.map((inc) => [
formatarDataDDMMAAAA(inc.data),
inc.tipo,
inc.descricao,
inc.status
]);
autoTable(doc, {
startY: yPosition,
head: [['Data', 'Tipo', 'Descrição', 'Status']],
body: inconsistenciasData,
theme: 'grid',
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
styles: { fontSize: 9 }
});
type JsPDFWithAutoTable = jsPDF & {
lastAutoTable?: { finalY: number };
};
const finalY = (doc as JsPDFWithAutoTable).lastAutoTable?.finalY;
return finalY ? finalY + 10 : yPosition + inconsistenciasData.length * 7 + 10;
}
/**
* Gera seção de ajustes no PDF
*/
function gerarSecaoAjustesPDF(doc: jsPDF, yPosition: number, dias: DiaFichaPonto[]): number {
const todosAjustes = dias.flatMap((dia) =>
dia.ajustes.map((ajuste) => ({
...ajuste,
data: dia.data,
dataFormatada: dia.dataFormatada
}))
);
if (todosAjustes.length === 0) {
return yPosition;
}
if (yPosition > doc.internal.pageSize.getHeight() - 60) {
doc.addPage();
yPosition = 20;
}
doc.setFontSize(12);
doc.setFont('helvetica', 'bold');
doc.setTextColor(41, 128, 185);
doc.text('AJUSTES DE BANCO DE HORAS', 15, yPosition);
doc.setFont('helvetica', 'normal');
doc.setTextColor(0, 0, 0);
yPosition += 10;
const ajustesData = todosAjustes.map((ajuste) => [
formatarDataDDMMAAAA(ajuste.data),
ajuste.tipo === 'abonar' ? 'Abonar' : ajuste.tipo === 'descontar' ? 'Descontar' : 'Compensar',
formatarMinutos(ajuste.valorMinutos),
ajuste.motivoDescricao || '-'
]);
autoTable(doc, {
startY: yPosition,
head: [['Data', 'Tipo', 'Valor', 'Motivo']],
body: ajustesData,
theme: 'grid',
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
styles: { fontSize: 9 }
});
type JsPDFWithAutoTable = jsPDF & {
lastAutoTable?: { finalY: number };
};
const finalY = (doc as JsPDFWithAutoTable).lastAutoTable?.finalY;
return finalY ? finalY + 10 : yPosition + ajustesData.length * 7 + 10;
}
/**
* Gera seção de dispensas no PDF
*/
function gerarSecaoDispensasPDF(doc: jsPDF, yPosition: number, dias: DiaFichaPonto[]): number {
const dispensas = dias
.map((dia) => dia.dispensa)
.filter((d): d is NonNullable<typeof d> => d !== null)
.filter((d, index, self) => index === self.findIndex((disp) => disp._id === d._id));
if (dispensas.length === 0) {
return yPosition;
}
if (yPosition > doc.internal.pageSize.getHeight() - 60) {
doc.addPage();
yPosition = 20;
}
doc.setFontSize(12);
doc.setFont('helvetica', 'bold');
doc.setTextColor(41, 128, 185);
doc.text('DISPENSAS DE REGISTRO', 15, yPosition);
doc.setFont('helvetica', 'normal');
doc.setTextColor(0, 0, 0);
yPosition += 10;
const dispensasData = dispensas.map((d) => [
`${formatarDataDDMMAAAA(d.dataInicio)} a ${formatarDataDDMMAAAA(d.dataFim)}`,
d.motivo,
d.ativo ? 'Ativa' : 'Inativa'
]);
autoTable(doc, {
startY: yPosition,
head: [['Período', 'Motivo', 'Status']],
body: dispensasData,
theme: 'grid',
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
styles: { fontSize: 9 }
});
type JsPDFWithAutoTable = jsPDF & {
lastAutoTable?: { finalY: number };
};
const finalY = (doc as JsPDFWithAutoTable).lastAutoTable?.finalY;
return finalY ? finalY + 10 : yPosition + dispensasData.length * 7 + 10;
}

View File

@@ -0,0 +1,684 @@
import type { ConvexClient } from 'convex-svelte';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { DiaFichaPonto, ResumoPeriodo, RegistroPonto, TipoDia } from './tipos';
import { calcularSaldoComparativoPorPar } from './calculos';
import { registroFoiMarcado } from './validacao';
import { formatarDataDDMMAAAA } from '../ponto';
import { formatarMinutos, formatarHoras } from './formatacao';
/**
* Gera array de todas as datas do período selecionado
*/
export function gerarDiasPeriodo(dataInicio: string, dataFim: string): string[] {
const dias: string[] = [];
const inicio = new Date(dataInicio);
const fim = new Date(dataFim);
for (let d = new Date(inicio); d <= fim; d.setDate(d.getDate() + 1)) {
dias.push(d.toISOString().split('T')[0]!);
}
return dias;
}
/**
* Gera registros esperados para um dia baseado na configuração
*/
export function gerarRegistrosEsperados(
data: string,
config: {
horarioEntrada: string;
horarioSaidaAlmoco: string;
horarioRetornoAlmoco: string;
horarioSaida: string;
}
): Array<{ tipo: string; hora: number; minuto: number; data: string }> {
const [horaEntrada, minutoEntrada] = config.horarioEntrada.split(':').map(Number);
const [horaSaidaAlmoco, minutoSaidaAlmoco] = config.horarioSaidaAlmoco.split(':').map(Number);
const [horaRetornoAlmoco, minutoRetornoAlmoco] = config.horarioRetornoAlmoco.split(':').map(Number);
const [horaSaida, minutoSaida] = config.horarioSaida.split(':').map(Number);
return [
{ tipo: 'entrada', hora: horaEntrada, minuto: minutoEntrada, data },
{ tipo: 'saida_almoco', hora: horaSaidaAlmoco, minuto: minutoSaidaAlmoco, data },
{ tipo: 'retorno_almoco', hora: horaRetornoAlmoco, minuto: minutoRetornoAlmoco, data },
{ tipo: 'saida', hora: horaSaida, minuto: minutoSaida, data }
];
}
/**
* Agrupa registros por funcionário e data
*/
export function agruparRegistrosPorFuncionario(
registros: Array<{
_id: Id<'registrosPonto'>;
funcionarioId: Id<'funcionarios'>;
data: string;
funcionario?: { nome: string; matricula?: string; descricaoCargo?: string } | null;
[key: string]: any;
}>,
config?: {
horarioEntrada: string;
horarioSaidaAlmoco: string;
horarioRetornoAlmoco: string;
horarioSaida: string;
}
): Array<{
funcionario: { nome: string; matricula?: string; descricaoCargo?: string } | null;
funcionarioId: Id<'funcionarios'>;
registrosPorData: Record<
string,
{
data: string;
registros: typeof registros;
saldoDiario?: {
saldoMinutos: number;
horas: number;
minutos: number;
positivo: boolean;
};
saldoDiarioComparativo?: {
trabalhadoMinutos: number;
esperadoMinutos: number;
diferencaMinutos: number;
};
}
>;
}> {
const agrupados: Record<
string,
{
funcionario: { nome: string; matricula?: string; descricaoCargo?: string } | null;
funcionarioId: Id<'funcionarios'>;
registrosPorData: Record<
string,
{
data: string;
registros: typeof registros;
saldoDiario?: {
saldoMinutos: number;
horas: number;
minutos: number;
positivo: boolean;
};
saldoDiarioComparativo?: {
trabalhadoMinutos: number;
esperadoMinutos: number;
diferencaMinutos: number;
};
}
>;
}
> = {};
const registrosProcessados = new Set<string>();
if (!Array.isArray(registros) || registros.length === 0) {
return [];
}
for (const registro of registros) {
if (!registro || !registro._id || !registro.funcionarioId || !registro.data) {
continue;
}
const chaveUnica = `${registro._id}`;
if (registrosProcessados.has(chaveUnica)) {
continue;
}
registrosProcessados.add(chaveUnica);
const key = registro.funcionarioId;
if (!agrupados[key]) {
agrupados[key] = {
funcionario: registro.funcionario,
funcionarioId: registro.funcionarioId,
registrosPorData: {}
};
}
const dataKey = registro.data;
if (!agrupados[key]!.registrosPorData[dataKey]) {
agrupados[key]!.registrosPorData[dataKey] = {
data: dataKey,
registros: [],
saldoDiario: undefined
};
}
const jaExiste = agrupados[key]!.registrosPorData[dataKey]!.registros.some(
(r) => r._id === registro._id
);
if (!jaExiste) {
agrupados[key]!.registrosPorData[dataKey]!.registros.push(registro);
}
}
const resultado = Object.values(agrupados);
resultado.sort((a, b) => {
const nomeA = a.funcionario?.nome || '';
const nomeB = b.funcionario?.nome || '';
return nomeA.localeCompare(nomeB, 'pt-BR');
});
for (const grupo of resultado) {
const datasOrdenadas = Object.keys(grupo.registrosPorData).sort((a, b) => {
return new Date(b).getTime() - new Date(a).getTime();
});
const registrosPorDataOrdenado: Record<string, (typeof grupo.registrosPorData)[string]> = {};
for (const dataKey of datasOrdenadas) {
registrosPorDataOrdenado[dataKey] = grupo.registrosPorData[dataKey]!;
}
grupo.registrosPorData = registrosPorDataOrdenado;
for (const dataKey in grupo.registrosPorData) {
const grupoData = grupo.registrosPorData[dataKey];
if (grupoData && grupoData.registros.length > 0) {
grupoData.registros.sort((a, b) => {
if (a.hora !== b.hora) {
return a.hora - b.hora;
}
return a.minuto - b.minuto;
});
if (config) {
const regsReaisOrdenados = [...grupoData.registros].sort((a, b) => {
if (a.hora !== b.hora) return a.hora - b.hora;
return a.minuto - b.minuto;
});
const saldosComparativosPorPar = calcularSaldoComparativoPorPar(regsReaisOrdenados, config);
let totalTrabalhado = 0;
const paresProcessados = new Set<number>();
for (const [, saldo] of saldosComparativosPorPar.entries()) {
if (!paresProcessados.has(saldo.parIndex)) {
totalTrabalhado += saldo.trabalhadoMinutos;
paresProcessados.add(saldo.parIndex);
}
}
const [horaEntradaConfig, minutoEntradaConfig] = config.horarioEntrada.split(':').map(Number);
const [horaSaidaAlmocoConfig, minutoSaidaAlmocoConfig] = config.horarioSaidaAlmoco
.split(':')
.map(Number);
const [horaRetornoAlmocoConfig, minutoRetornoAlmocoConfig] =
config.horarioRetornoAlmoco.split(':').map(Number);
const [horaSaidaConfig, minutoSaidaConfig] = config.horarioSaida.split(':').map(Number);
const minutosPar1EsperadoConfig =
horaSaidaAlmocoConfig * 60 +
minutoSaidaAlmocoConfig -
(horaEntradaConfig * 60 + minutoEntradaConfig);
const minutosPar1EsperadoAjustadoConfig =
minutosPar1EsperadoConfig < 0
? minutosPar1EsperadoConfig + 24 * 60
: minutosPar1EsperadoConfig;
const minutosPar2EsperadoConfig =
horaSaidaConfig * 60 +
minutoSaidaConfig -
(horaRetornoAlmocoConfig * 60 + minutoRetornoAlmocoConfig);
const minutosPar2EsperadoAjustadoConfig =
minutosPar2EsperadoConfig < 0
? minutosPar2EsperadoConfig + 24 * 60
: minutosPar2EsperadoConfig;
const cargaHorariaDiariaEsperadaMinutos =
minutosPar1EsperadoAjustadoConfig + minutosPar2EsperadoAjustadoConfig;
const diferencaMinutos = totalTrabalhado - cargaHorariaDiariaEsperadaMinutos;
grupoData.saldoDiarioComparativo = {
trabalhadoMinutos: totalTrabalhado,
esperadoMinutos: cargaHorariaDiariaEsperadaMinutos,
diferencaMinutos: diferencaMinutos
};
}
}
}
}
return resultado.filter((grupo) => {
const temRegistros = Object.values(grupo.registrosPorData).some(
(grupoData) => grupoData.registros && grupoData.registros.length > 0
);
return temRegistros;
});
}
/**
* Processa dados para ficha de ponto
* Esta é uma função grande que processa todos os dados necessários para gerar a ficha
*/
export async function processarDadosFichaPonto(
client: ConvexClient,
funcionarioId: Id<'funcionarios'>,
dataInicio: string,
dataFim: string
): Promise<{
dias: DiaFichaPonto[];
resumo: ResumoPeriodo;
config: {
horarioEntrada: string;
horarioSaidaAlmoco: string;
horarioRetornoAlmoco: string;
horarioSaida: string;
};
}> {
// Buscar todos os dados necessários
const [
registrosFuncionario,
atestadosLicencas,
ausenciasTodas,
ajustes,
inconsistencias,
homologacoes,
dispensas,
config
] = await Promise.all([
client.query(api.pontos.listarRegistrosPeriodo, {
funcionarioId,
dataInicio,
dataFim
}),
client.query(api.atestadosLicencas.listarPorFuncionario, {
funcionarioId
}),
client.query(api.ausencias.listarTodas, {}),
client.query(api.pontos.listarAjustesBancoHoras, {
funcionarioId
}),
client.query(api.pontos.listarInconsistenciasBancoHoras, {}),
client.query(api.pontos.listarHomologacoes, {
funcionarioId
}),
client.query(api.pontos.listarDispensas, {
funcionarioId,
apenasAtivas: false
}),
client.query(api.configuracaoPonto.obterConfiguracao, {})
]);
const atestados = atestadosLicencas?.atestados || [];
const licencas = atestadosLicencas?.licencas || [];
const ausencias = (ausenciasTodas || []).filter((a) => a.funcionarioId === funcionarioId);
if (!config) {
throw new Error('Configuração de ponto não encontrada');
}
// Filtrar dados pelo período
const dataInicioObj = new Date(dataInicio + 'T00:00:00');
const dataFimObj = new Date(dataFim + 'T23:59:59');
const atestadosPeriodo = (atestados || []).filter((a) => {
const inicio = new Date(a.dataInicio);
const fim = new Date(a.dataFim);
return inicio <= dataFimObj && fim >= dataInicioObj;
});
const ausenciasPeriodo = (ausencias || []).filter((a) => {
const inicio = new Date(a.dataInicio);
const fim = new Date(a.dataFim);
return inicio <= dataFimObj && fim >= dataInicioObj;
});
const licencasPeriodo = (licencas || []).filter((l) => {
const inicio = new Date(l.dataInicio);
const fim = new Date(l.dataFim);
return inicio <= dataFimObj && fim >= dataInicioObj;
});
const ajustesPeriodo = (ajustes || []).filter((a) => {
const dataAjuste = new Date(a.dataAplicacao);
return dataAjuste >= dataInicioObj && dataAjuste <= dataFimObj;
});
const inconsistenciasPeriodo = (inconsistencias || []).filter((i) => {
if (i.funcionarioId !== funcionarioId) return false;
const dataInconsistencia = new Date(i.dataDetectada);
return dataInconsistencia >= dataInicioObj && dataInconsistencia <= dataFimObj;
});
const dataInicioTimestamp = dataInicioObj.getTime();
const dataFimTimestamp = dataFimObj.getTime();
const homologacoesPeriodo = (homologacoes || []).filter((h) => {
return h.criadoEm >= dataInicioTimestamp && h.criadoEm <= dataFimTimestamp;
});
const dispensasPeriodo = (dispensas || []).filter((d) => {
const dispensaInicio = new Date(d.dataInicio + 'T00:00:00');
const dispensaFim = new Date(d.dataFim + 'T23:59:59');
return dispensaInicio <= dataFimObj && dispensaFim >= dataInicioObj;
});
// Gerar todos os dias do período
const diasPeriodo = gerarDiasPeriodo(dataInicio, dataFim);
const diasProcessados: DiaFichaPonto[] = [];
// Agrupar registros por data
const registrosPorData: Record<string, RegistroPonto[]> = {};
for (const r of registrosFuncionario || []) {
if (!registrosPorData[r.data]) {
registrosPorData[r.data] = [];
}
registrosPorData[r.data]!.push(r);
}
// Processar cada dia
for (const data of diasPeriodo) {
const dataObj = new Date(data);
const regsReais = registrosPorData[data] || [];
const regsEsperados = gerarRegistrosEsperados(data, config);
// Verificar atestado
const atestadoDia =
atestadosPeriodo.find((a) => {
const inicio = new Date(a.dataInicio);
const fim = new Date(a.dataFim);
return dataObj >= inicio && dataObj <= fim;
}) || null;
// Verificar ausência
const ausenciaDia =
ausenciasPeriodo.find((a) => {
const inicio = new Date(a.dataInicio);
const fim = new Date(a.dataFim);
return dataObj >= inicio && dataObj <= fim;
}) || null;
// Verificar licença
const licencaDia =
licencasPeriodo.find((l) => {
const inicio = new Date(l.dataInicio);
const fim = new Date(l.dataFim);
return dataObj >= inicio && dataObj <= fim;
}) || null;
// Verificar ajustes do dia
const ajustesDia = ajustesPeriodo.filter((a) => a.dataAplicacao === data);
// Verificar inconsistências do dia
const inconsistenciasDia = inconsistenciasPeriodo.filter((i) => i.dataDetectada === data);
// Verificar homologações do dia
const homologacoesDia = homologacoesPeriodo.filter((h) => {
if (h.registroId) {
const registro = regsReais.find((r) => r._id === h.registroId);
return registro !== undefined;
}
return false;
});
// Verificar dispensa
const dispensaDia =
dispensasPeriodo.find((d) => {
const dispensaInicio = new Date(d.dataInicio + 'T00:00:00');
const dispensaFim = new Date(d.dataFim + 'T23:59:59');
return dataObj >= dispensaInicio && dataObj <= dispensaFim;
}) || null;
// Calcular saldo diário
const regsReaisOrdenados = [...regsReais].sort((a, b) => {
if (a.hora !== b.hora) return a.hora - b.hora;
return a.minuto - b.minuto;
});
const saldosComparativosPorPar = calcularSaldoComparativoPorPar(regsReaisOrdenados, config);
let saldoDiario: { diferencaMinutos: number; trabalhadoMinutos: number; esperadoMinutos: number } | null = null;
let saldoDiarioTotalDiferencaMinutos = 0;
let saldoDiarioTotalTrabalhadoMinutos = 0;
let saldoDiarioTotalEsperadoMinutos = 0;
// Somar saldos dos pares
const paresProcessados = new Set<number>();
for (const [, saldo] of saldosComparativosPorPar.entries()) {
if (!paresProcessados.has(saldo.parIndex)) {
saldoDiarioTotalDiferencaMinutos += saldo.diferencaMinutos;
saldoDiarioTotalTrabalhadoMinutos += saldo.trabalhadoMinutos;
saldoDiarioTotalEsperadoMinutos += saldo.esperadoMinutos;
paresProcessados.add(saldo.parIndex);
}
}
// Calcular saldo para pares não marcados
const todosRegistros: Array<{ tipo: string; hora: number; minuto: number; real: boolean }> = [];
for (const reg of regsReais) {
todosRegistros.push({
tipo: reg.tipo,
hora: reg.hora,
minuto: reg.minuto,
real: true
});
}
for (const regEsperado of regsEsperados) {
if (!registroFoiMarcado(regEsperado, regsReais)) {
todosRegistros.push({
tipo: regEsperado.tipo,
hora: regEsperado.hora,
minuto: regEsperado.minuto,
real: false
});
}
}
// Identificar pares não marcados e calcular saldo negativo
for (let i = 0; i < todosRegistros.length; i++) {
const reg = todosRegistros[i];
if ((reg.tipo === 'entrada' || reg.tipo === 'retorno_almoco') && !reg.real) {
const tipoSaidaEsperado = reg.tipo === 'entrada' ? 'saida_almoco' : 'saida';
const saidaEsperada = todosRegistros.find((r, idx) => {
if (idx <= i) return false;
if (r.tipo !== tipoSaidaEsperado || r.real) return false;
const minutosEntrada = reg.hora * 60 + reg.minuto;
const minutosSaidaEsperada = r.hora * 60 + r.minuto;
const temRegistroRealNoIntervalo = regsReais.some((real) => {
if (real.tipo !== tipoSaidaEsperado) return false;
const minutosReal = real.hora * 60 + real.minuto;
return minutosReal >= minutosEntrada && minutosReal < minutosSaidaEsperada;
});
return !temRegistroRealNoIntervalo;
});
if (saidaEsperada) {
let esperadoMinutos: number;
if (reg.tipo === 'entrada') {
const [horaEntradaEsperada, minutoEntradaEsperado] = config.horarioEntrada
.split(':')
.map(Number);
const [horaSaidaAlmocoEsperada, minutoSaidaAlmocoEsperado] = config.horarioSaidaAlmoco
.split(':')
.map(Number);
const minutosEntradaEsperada = horaEntradaEsperada * 60 + minutoEntradaEsperado;
const minutosSaidaEsperadaConfig =
horaSaidaAlmocoEsperada * 60 + minutoSaidaAlmocoEsperado;
esperadoMinutos = minutosSaidaEsperadaConfig - minutosEntradaEsperada;
if (esperadoMinutos < 0) esperadoMinutos += 24 * 60;
} else {
const [horaRetornoAlmocoEsperado, minutoRetornoAlmocoEsperado] =
config.horarioRetornoAlmoco.split(':').map(Number);
const [horaSaidaEsperada, minutoSaidaEsperado] = config.horarioSaida
.split(':')
.map(Number);
const minutosEntradaEsperada =
horaRetornoAlmocoEsperado * 60 + minutoRetornoAlmocoEsperado;
const minutosSaidaEsperadaConfig = horaSaidaEsperada * 60 + minutoSaidaEsperado;
esperadoMinutos = minutosSaidaEsperadaConfig - minutosEntradaEsperada;
if (esperadoMinutos < 0) esperadoMinutos += 24 * 60;
}
saldoDiarioTotalDiferencaMinutos -= esperadoMinutos;
saldoDiarioTotalEsperadoMinutos += esperadoMinutos;
}
}
}
// Aplicar ajustes manuais
for (const ajuste of ajustesDia) {
if (ajuste.tipo === 'abonar') {
saldoDiarioTotalDiferencaMinutos += ajuste.valorMinutos;
} else if (ajuste.tipo === 'descontar') {
saldoDiarioTotalDiferencaMinutos -= ajuste.valorMinutos;
}
}
// Calcular diferença final
const diferencaDiariaCorrigida =
saldoDiarioTotalTrabalhadoMinutos - saldoDiarioTotalEsperadoMinutos;
saldoDiario = {
diferencaMinutos: diferencaDiariaCorrigida,
trabalhadoMinutos: saldoDiarioTotalTrabalhadoMinutos,
esperadoMinutos: saldoDiarioTotalEsperadoMinutos
};
// Determinar tipo de dia
let tipoDia: TipoDia = 'normal';
let computado = true;
if (dispensaDia) {
tipoDia = 'nao_computado';
computado = false;
} else if (licencaDia) {
tipoDia = 'licenca';
computado = false;
} else if (atestadoDia) {
tipoDia = 'atestado';
computado = false;
} else if (ausenciaDia) {
tipoDia = 'ausencia';
computado = false;
} else if (ajustesDia.some((a) => a.tipo === 'abonar' && a.valorMinutos >= 240)) {
tipoDia = 'abonado';
}
if (inconsistenciasDia.length > 0) {
tipoDia = 'inconsistente';
}
diasProcessados.push({
data,
dataFormatada: formatarDataDDMMAAAA(data),
tipoDia,
registros: regsReais,
registrosEsperados: regsEsperados,
saldoDiario,
saldoAcumulado: 0, // Será calculado depois
atestado: atestadoDia
? {
_id: atestadoDia._id,
tipo: atestadoDia.tipo,
dataInicio: atestadoDia.dataInicio,
dataFim: atestadoDia.dataFim,
motivo: atestadoDia.observacoes
}
: null,
ausencia: ausenciaDia
? {
_id: ausenciaDia._id,
motivo: ausenciaDia.motivo,
dataInicio: ausenciaDia.dataInicio,
dataFim: ausenciaDia.dataFim,
status: ausenciaDia.status
}
: null,
licenca: licencaDia
? {
_id: licencaDia._id,
tipo: licencaDia.tipo || 'licenca',
dataInicio: licencaDia.dataInicio,
dataFim: licencaDia.dataFim
}
: null,
ajustes: ajustesDia.map((a) => ({
_id: a._id,
tipo: a.tipo,
valorMinutos: a.valorMinutos,
motivoDescricao: a.motivoDescricao,
gestorId: a.gestorId
})),
inconsistencias: inconsistenciasDia.map((i) => ({
_id: i._id,
tipo: i.tipo,
descricao: i.descricao,
dataDetectada: i.dataDetectada,
status: i.status,
resolvidoPor: i.resolvidoPor,
resolvidoEm: i.resolvidoEm
})),
homologacoes: homologacoesDia.map((h) => ({
_id: h._id,
motivoDescricao: h.motivoDescricao,
gestorId: h.gestorId
})),
dispensa: dispensaDia
? {
_id: dispensaDia._id,
motivo: dispensaDia.motivo,
dataInicio: dispensaDia.dataInicio,
dataFim: dispensaDia.dataFim,
ativo: dispensaDia.ativo
}
: null,
computado
});
}
// Calcular saldo acumulado para cada dia
let saldoAcumulado = 0;
for (const dia of diasProcessados) {
if (dia.computado && dia.saldoDiario) {
saldoAcumulado += dia.saldoDiario.diferencaMinutos;
}
dia.saldoAcumulado = saldoAcumulado;
}
// Calcular resumo com formatações
const totalHorasTrabalhadas = diasProcessados
.filter((d) => d.computado)
.reduce((acc, d) => acc + (d.saldoDiario?.trabalhadoMinutos || 0), 0);
const totalHorasEsperadas = diasProcessados
.filter((d) => d.computado)
.reduce((acc, d) => acc + (d.saldoDiario?.esperadoMinutos || 0), 0);
const diferencaTotal = diasProcessados
.filter((d) => d.computado)
.reduce((acc, d) => acc + (d.saldoDiario?.diferencaMinutos || 0), 0);
const saldoPeriodo = diferencaTotal;
const saldoFinal =
diasProcessados.length > 0 ? diasProcessados[diasProcessados.length - 1]!.saldoAcumulado : 0;
const resumo: ResumoPeriodo = {
totalDias: diasProcessados.length,
diasTrabalhados: diasProcessados.filter((d) => d.computado && d.registros.length > 0).length,
diasComAtestado: diasProcessados.filter((d) => d.atestado !== null).length,
diasAusentes: diasProcessados.filter((d) => d.ausencia !== null).length,
diasComLicenca: diasProcessados.filter((d) => d.licenca !== null).length,
diasAbonados: diasProcessados.filter((d) => d.tipoDia === 'abonado').length,
diasNaoComputados: diasProcessados.filter((d) => !d.computado).length,
diasComInconsistencia: diasProcessados.filter((d) => d.inconsistencias.length > 0).length,
totalHorasTrabalhadas,
totalHorasEsperadas,
diferencaTotal,
saldoInicial: 0,
saldoFinal,
saldoPeriodo,
totalInconsistencias: inconsistenciasPeriodo.length,
saldoInicialFormatado: formatarMinutos(0),
saldoPeriodoFormatado: formatarMinutos(saldoPeriodo),
saldoFinalFormatado: formatarMinutos(saldoFinal),
totalHorasTrabalhadasFormatado: formatarHoras(totalHorasTrabalhadas),
totalHorasEsperadasFormatado: formatarHoras(totalHorasEsperadas),
diferencaTotalFormatado: formatarMinutos(diferencaTotal)
};
return {
dias: diasProcessados,
resumo,
config
};
}

View File

@@ -0,0 +1,111 @@
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
export type TipoDia =
| 'normal'
| 'atestado'
| 'ausencia'
| 'licenca'
| 'abonado'
| 'nao_computado'
| 'ferias'
| 'inconsistente';
export interface SaldoDiario {
diferencaMinutos: number;
trabalhadoMinutos: number;
esperadoMinutos: number;
}
export interface RegistroPonto {
_id: Id<'registrosPonto'>;
tipo: 'entrada' | 'saida_almoco' | 'retorno_almoco' | 'saida';
data: string;
hora: number;
minuto: number;
timestamp: number;
dentroDoPrazo: boolean;
}
export interface DiaFichaPonto {
data: string;
dataFormatada: string;
tipoDia: TipoDia;
registros: RegistroPonto[];
registrosEsperados: Array<{ tipo: string; hora: number; minuto: number; data: string }>;
saldoDiario: SaldoDiario | null;
saldoAcumulado: number;
atestado: {
_id: Id<'atestados'>;
tipo: string;
dataInicio: string;
dataFim: string;
motivo?: string;
} | null;
ausencia: {
_id: Id<'solicitacoesAusencias'>;
motivo: string;
dataInicio: string;
dataFim: string;
status: string;
} | null;
licenca: {
_id: Id<'licencas'>;
tipo: string;
dataInicio: string;
dataFim: string;
} | null;
ajustes: Array<{
_id: Id<'ajustesBancoHoras'>;
tipo: 'abonar' | 'descontar' | 'compensar';
valorMinutos: number;
motivoDescricao?: string;
gestorId?: Id<'usuarios'>;
}>;
inconsistencias: Array<{
_id: Id<'inconsistenciasBancoHoras'>;
tipo: string;
descricao: string;
dataDetectada: string;
status: 'pendente' | 'resolvida' | 'ignorada';
resolvidoPor?: Id<'usuarios'>;
resolvidoEm?: number;
}>;
homologacoes: Array<{
_id: Id<'homologacoesPonto'>;
motivoDescricao?: string;
gestorId: Id<'usuarios'>;
}>;
dispensa: {
_id: Id<'dispensasRegistro'>;
motivo: string;
dataInicio: string;
dataFim: string;
ativo: boolean;
} | null;
computado: boolean;
}
export interface ResumoPeriodo {
totalDias: number;
diasTrabalhados: number;
diasComAtestado: number;
diasAusentes: number;
diasComLicenca: number;
diasAbonados: number;
diasNaoComputados: number;
diasComInconsistencia: number;
totalHorasTrabalhadas: number;
totalHorasEsperadas: number;
diferencaTotal: number;
saldoInicial: number;
saldoFinal: number;
saldoPeriodo: number;
totalInconsistencias: number;
saldoInicialFormatado?: string;
saldoPeriodoFormatado?: string;
saldoFinalFormatado?: string;
totalHorasTrabalhadasFormatado?: string;
totalHorasEsperadasFormatado?: string;
diferencaTotalFormatado?: string;
}

View File

@@ -0,0 +1,43 @@
/**
* Funções de validação para dados de ponto
*/
/**
* Valida se um período de datas é válido
*/
export function validarPeriodo(dataInicio: string, dataFim: string): { valido: boolean; erro?: string } {
const inicio = new Date(dataInicio);
const fim = new Date(dataFim);
const hoje = new Date();
hoje.setHours(23, 59, 59, 999);
if (isNaN(inicio.getTime()) || isNaN(fim.getTime())) {
return { valido: false, erro: 'Datas inválidas' };
}
if (inicio > fim) {
return { valido: false, erro: 'Data de início deve ser anterior à data de fim' };
}
const diasDiferenca = Math.ceil((fim.getTime() - inicio.getTime()) / (1000 * 60 * 60 * 24));
if (diasDiferenca > 90) {
return { valido: false, erro: 'Período máximo é de 90 dias' };
}
if (fim > hoje) {
return { valido: false, erro: 'Data de fim não pode ser no futuro' };
}
return { valido: true };
}
/**
* Verifica se um registro esperado foi marcado
*/
export function registroFoiMarcado(
registroEsperado: { tipo: string; hora: number; minuto: number; data: string },
registrosReais: Array<{ tipo: string; hora: number; minuto: number; data: string }>
): boolean {
return registrosReais.some((r) => r.tipo === registroEsperado.tipo);
}