Merge remote-tracking branch 'origin' into feat-pedidos
This commit is contained in:
142
apps/web/src/lib/utils/avatarCache.ts
Normal file
142
apps/web/src/lib/utils/avatarCache.ts
Normal 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);
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
63
apps/web/src/lib/utils/datas.ts
Normal file
63
apps/web/src/lib/utils/datas.ts
Normal 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}`;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
301
apps/web/src/lib/utils/e2eEncryption.ts
Normal file
301
apps/web/src/lib/utils/e2eEncryption.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
446
apps/web/src/lib/utils/fichaPontoPDF.ts
Normal file
446
apps/web/src/lib/utils/fichaPontoPDF.ts
Normal 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' }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) {
|
||||
|
||||
349
apps/web/src/lib/utils/ponto/calculos.ts
Normal file
349
apps/web/src/lib/utils/ponto/calculos.ts
Normal 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;
|
||||
}
|
||||
|
||||
89
apps/web/src/lib/utils/ponto/formatacao.ts
Normal file
89
apps/web/src/lib/utils/ponto/formatacao.ts
Normal 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()] || '';
|
||||
}
|
||||
|
||||
1064
apps/web/src/lib/utils/ponto/pdf/geradorDetalhesPDF.ts
Normal file
1064
apps/web/src/lib/utils/ponto/pdf/geradorDetalhesPDF.ts
Normal file
File diff suppressed because it is too large
Load Diff
612
apps/web/src/lib/utils/ponto/pdf/geradorPDF.ts
Normal file
612
apps/web/src/lib/utils/ponto/pdf/geradorPDF.ts
Normal 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;
|
||||
}
|
||||
|
||||
684
apps/web/src/lib/utils/ponto/processamento.ts
Normal file
684
apps/web/src/lib/utils/ponto/processamento.ts
Normal 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
|
||||
};
|
||||
}
|
||||
|
||||
111
apps/web/src/lib/utils/ponto/tipos.ts
Normal file
111
apps/web/src/lib/utils/ponto/tipos.ts
Normal 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;
|
||||
}
|
||||
|
||||
43
apps/web/src/lib/utils/ponto/validacao.ts
Normal file
43
apps/web/src/lib/utils/ponto/validacao.ts
Normal 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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user