feat: implement error handling and logging in server hooks to capture and notify on 404 and 500 errors, enhancing server reliability and monitoring

This commit is contained in:
2025-12-08 11:52:27 -03:00
parent e1f1af7530
commit fdfbd8b051
31 changed files with 7305 additions and 932 deletions

View File

@@ -52,6 +52,12 @@
let dragThreshold = $state(5); // Distância mínima em pixels para considerar arrastar
let hasMoved = $state(false); // Flag para verificar se houve movimento durante o arrastar
let shouldPreventClick = $state(false); // Flag para prevenir clique após arrastar
// Suporte a gestos touch (swipe)
let touchStart = $state<{ x: number; y: number; time: number } | null>(null);
let touchCurrent = $state<{ x: number; y: number } | null>(null);
let isTouching = $state(false);
let swipeVelocity = $state(0); // Velocidade do swipe para animação
// Tamanho da janela (redimensionável)
const MIN_WIDTH = 300;
@@ -613,6 +619,134 @@
// Não prevenir default para permitir clique funcionar se não houver movimento
}
// Handlers para gestos touch (swipe)
function handleTouchStart(e: TouchEvent) {
if (!position || e.touches.length !== 1) return;
const touch = e.touches[0];
touchStart = {
x: touch.clientX,
y: touch.clientY,
time: Date.now()
};
touchCurrent = { x: touch.clientX, y: touch.clientY };
isTouching = true;
isDragging = true;
dragStart = {
x: touch.clientX - position.x,
y: touch.clientY - position.y
};
hasMoved = false;
shouldPreventClick = false;
document.body.classList.add('dragging');
}
function handleTouchMove(e: TouchEvent) {
if (!isTouching || !touchStart || !position || e.touches.length !== 1) return;
const touch = e.touches[0];
touchCurrent = { x: touch.clientX, y: touch.clientY };
// Calcular velocidade do swipe
const deltaTime = Date.now() - touchStart.time;
const deltaX = touch.clientX - touchStart.x;
const deltaY = touch.clientY - touchStart.y;
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
if (deltaTime > 0) {
swipeVelocity = distance / deltaTime; // pixels por ms
}
// Calcular nova posição
const newX = touch.clientX - dragStart.x;
const newY = touch.clientY - dragStart.y;
// Verificar se houve movimento significativo
const deltaXAbs = Math.abs(newX - position.x);
const deltaYAbs = Math.abs(newY - position.y);
if (deltaXAbs > dragThreshold || deltaYAbs > dragThreshold) {
hasMoved = true;
shouldPreventClick = true;
}
// Dimensões do widget
const widgetWidth = isOpen && !isMinimized ? windowSize.width : 72;
const widgetHeight = isOpen && !isMinimized ? windowSize.height : 72;
const winWidth =
windowDimensions.width || (typeof window !== 'undefined' ? window.innerWidth : 0);
const winHeight =
windowDimensions.height || (typeof window !== 'undefined' ? window.innerHeight : 0);
const minX = -(widgetWidth - 100);
const maxX = Math.max(0, winWidth - 100);
const minY = -(widgetHeight - 100);
const maxY = Math.max(0, winHeight - 100);
position = {
x: Math.max(minX, Math.min(newX, maxX)),
y: Math.max(minY, Math.min(newY, maxY))
};
}
function handleTouchEnd(e: TouchEvent) {
if (!isTouching || !touchStart || !position) return;
const hadMoved = hasMoved;
// Aplicar momentum se houver velocidade suficiente
if (swipeVelocity > 0.5 && hadMoved) {
const deltaX = touchCurrent ? touchCurrent.x - touchStart.x : 0;
const deltaY = touchCurrent ? touchCurrent.y - touchStart.y : 0;
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
if (distance > 10) {
// Aplicar momentum suave
const momentum = Math.min(swipeVelocity * 50, 200); // Limitar momentum
const angle = Math.atan2(deltaY, deltaX);
let momentumX = position.x + Math.cos(angle) * momentum;
let momentumY = position.y + Math.sin(angle) * momentum;
// Limitar dentro dos bounds
const widgetWidth = isOpen && !isMinimized ? windowSize.width : 72;
const widgetHeight = isOpen && !isMinimized ? windowSize.height : 72;
const winWidth = windowDimensions.width || (typeof window !== 'undefined' ? window.innerWidth : 0);
const winHeight = windowDimensions.height || (typeof window !== 'undefined' ? window.innerHeight : 0);
const minX = -(widgetWidth - 100);
const maxX = Math.max(0, winWidth - 100);
const minY = -(widgetHeight - 100);
const maxY = Math.max(0, winHeight - 100);
momentumX = Math.max(minX, Math.min(momentumX, maxX));
momentumY = Math.max(minY, Math.min(momentumY, maxY));
position = { x: momentumX, y: momentumY };
isAnimating = true;
setTimeout(() => {
isAnimating = false;
ajustarPosicao();
}, 300);
}
} else {
ajustarPosicao();
}
isDragging = false;
isTouching = false;
touchStart = null;
touchCurrent = null;
swipeVelocity = 0;
document.body.classList.remove('dragging');
setTimeout(() => {
hasMoved = false;
shouldPreventClick = false;
}, 100);
savePosition();
}
function handleMouseMove(e: MouseEvent) {
if (isResizing) {
handleResizeMove(e);
@@ -747,10 +881,14 @@
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
window.addEventListener('touchmove', handleTouchMove, { passive: false });
window.addEventListener('touchend', handleTouchEnd);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
window.removeEventListener('touchmove', handleTouchMove);
window.removeEventListener('touchend', handleTouchEnd);
};
});
</script>
@@ -789,9 +927,10 @@
onmouseup={(e) => {
handleMouseUp(e);
}}
ontouchstart={handleTouchStart}
onclick={(e) => {
// Só executar toggle se não houve movimento durante o arrastar
if (!shouldPreventClick && !hasMoved) {
if (!shouldPreventClick && !hasMoved && !isTouching) {
handleToggle();
} else {
// Prevenir clique se houve movimento
@@ -802,11 +941,23 @@
}}
aria-label="Abrir chat"
>
<!-- Anel de brilho rotativo -->
<!-- Anel de brilho rotativo melhorado com múltiplas camadas -->
<div
class="absolute inset-0 rounded-full opacity-0 transition-opacity duration-500 group-hover:opacity-100"
style="background: conic-gradient(from 0deg, transparent 0%, rgba(255,255,255,0.3) 50%, transparent 100%); animation: rotate 3s linear infinite;"
style="background: conic-gradient(from 0deg, transparent 0%, rgba(255,255,255,0.4) 25%, rgba(255,255,255,0.6) 50%, rgba(255,255,255,0.4) 75%, transparent 100%); animation: rotate 3s linear infinite; transform-origin: center;"
></div>
<!-- Segunda camada para efeito de profundidade -->
<div
class="absolute inset-0 rounded-full opacity-0 transition-opacity duration-700 group-hover:opacity-60"
style="background: conic-gradient(from 180deg, transparent 0%, rgba(255,255,255,0.2) 30%, transparent 60%); animation: rotate 4s linear infinite reverse; transform-origin: center;"
></div>
<!-- Efeito de brilho pulsante durante arrasto -->
{#if isDragging || isTouching}
<div
class="absolute inset-0 rounded-full opacity-30 animate-pulse"
style="background: radial-gradient(circle at center, rgba(255,255,255,0.4) 0%, transparent 70%); animation: pulse-glow 1.5s ease-in-out infinite;"
></div>
{/if}
<!-- Ícone de chat moderno com efeito 3D -->
<MessageCircle
@@ -1182,7 +1333,7 @@
}
}
/* Rotação para anel de brilho */
/* Rotação para anel de brilho - suavizada */
@keyframes rotate {
from {
transform: rotate(0deg);
@@ -1192,6 +1343,18 @@
}
}
/* Efeito de pulso de brilho durante arrasto */
@keyframes pulse-glow {
0%, 100% {
opacity: 0.2;
transform: scale(1);
}
50% {
opacity: 0.4;
transform: scale(1.05);
}
}
/* Efeito shimmer para o header */
@keyframes shimmer {
0% {

View File

@@ -0,0 +1,104 @@
<script lang="ts">
import { BarChart3, CheckCircle2, XCircle, Users } from 'lucide-svelte';
interface Estatisticas {
totalRegistros: number;
dentroDoPrazo: number;
foraDoPrazo: number;
totalFuncionarios: number;
funcionariosDentroPrazo: number;
funcionariosForaPrazo: number;
}
interface Props {
estatisticas?: Estatisticas;
}
let { estatisticas = undefined }: Props = $props();
</script>
{#if estatisticas}
<div class="mb-8 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
<!-- Total de Registros -->
<div
class="card transform border border-blue-500/20 bg-gradient-to-br from-blue-500/10 to-blue-600/20 shadow-xl transition-all duration-300 hover:-translate-y-1 hover:shadow-2xl"
>
<div class="card-body">
<div class="flex items-center justify-between">
<div class="flex-1">
<p class="text-base-content/70 mb-1 text-sm font-semibold">Total de Registros</p>
<p class="text-base-content text-3xl font-bold">{estatisticas.totalRegistros}</p>
</div>
<div class="rounded-xl bg-blue-500/20 p-3">
<BarChart3 class="h-8 w-8 text-blue-600" strokeWidth={2.5} />
</div>
</div>
</div>
</div>
<!-- Dentro do Prazo -->
<div
class="card transform border border-green-500/20 bg-gradient-to-br from-green-500/10 to-green-600/20 shadow-xl transition-all duration-300 hover:-translate-y-1 hover:shadow-2xl"
>
<div class="card-body">
<div class="flex items-center justify-between">
<div class="flex-1">
<p class="text-base-content/70 mb-1 text-sm font-semibold">Dentro do Prazo</p>
<p class="text-3xl font-bold text-green-600">{estatisticas.dentroDoPrazo}</p>
<p class="text-base-content/60 mt-1 text-xs">
{estatisticas.totalRegistros > 0
? ((estatisticas.dentroDoPrazo / estatisticas.totalRegistros) * 100).toFixed(1)
: 0}% do total
</p>
</div>
<div class="rounded-xl bg-green-500/20 p-3">
<CheckCircle2 class="h-8 w-8 text-green-600" strokeWidth={2.5} />
</div>
</div>
</div>
</div>
<!-- Fora do Prazo -->
<div
class="card transform border border-red-500/20 bg-gradient-to-br from-red-500/10 to-red-600/20 shadow-xl transition-all duration-300 hover:-translate-y-1 hover:shadow-2xl"
>
<div class="card-body">
<div class="flex items-center justify-between">
<div class="flex-1">
<p class="text-base-content/70 mb-1 text-sm font-semibold">Fora do Prazo</p>
<p class="text-3xl font-bold text-red-600">{estatisticas.foraDoPrazo}</p>
<p class="text-base-content/60 mt-1 text-xs">
{estatisticas.totalRegistros > 0
? ((estatisticas.foraDoPrazo / estatisticas.totalRegistros) * 100).toFixed(1)
: 0}% do total
</p>
</div>
<div class="rounded-xl bg-red-500/20 p-3">
<XCircle class="h-8 w-8 text-red-600" strokeWidth={2.5} />
</div>
</div>
</div>
</div>
<!-- Funcionários -->
<div
class="card transform border border-purple-500/20 bg-gradient-to-br from-purple-500/10 to-purple-600/20 shadow-xl transition-all duration-300 hover:-translate-y-1 hover:shadow-2xl"
>
<div class="card-body">
<div class="flex items-center justify-between">
<div class="flex-1">
<p class="text-base-content/70 mb-1 text-sm font-semibold">Funcionários</p>
<p class="text-3xl font-bold text-purple-600">{estatisticas.totalFuncionarios}</p>
<p class="text-base-content/60 mt-1 text-xs">
{estatisticas.funcionariosDentroPrazo} dentro, {estatisticas.funcionariosForaPrazo} fora
</p>
</div>
<div class="rounded-xl bg-purple-500/20 p-3">
<Users class="h-8 w-8 text-purple-600" strokeWidth={2.5} />
</div>
</div>
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1,218 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { Chart, registerables } from 'chart.js';
import { BarChart3, XCircle, FileText } from 'lucide-svelte';
interface Estatisticas {
totalRegistros: number;
dentroDoPrazo: number;
foraDoPrazo: number;
}
interface Props {
estatisticas?: Estatisticas;
chartData?: {
labels: string[];
datasets: Array<{
label: string;
data: number[];
backgroundColor: string;
borderColor: string;
borderWidth: number;
}>;
} | null;
isLoading?: boolean;
error?: Error | null;
}
let { estatisticas = undefined, chartData = null, isLoading = false, error = null }: Props = $props();
let chartCanvas: HTMLCanvasElement;
let chartInstance: Chart | null = null;
onMount(() => {
Chart.register(...registerables);
const timeoutId = setTimeout(() => {
if (chartCanvas && estatisticas && chartData && !chartInstance) {
try {
criarGrafico();
} catch (err) {
console.error('Erro ao criar gráfico no onMount:', err);
}
}
}, 500);
return () => {
clearTimeout(timeoutId);
};
});
onDestroy(() => {
if (chartInstance) {
chartInstance.destroy();
}
});
function criarGrafico() {
if (!chartCanvas || !estatisticas || !chartData) {
return;
}
const ctx = chartCanvas.getContext('2d');
if (!ctx) {
return;
}
if (chartInstance) {
chartInstance.destroy();
chartInstance = null;
}
try {
chartInstance = new Chart(ctx, {
type: 'bar',
data: chartData,
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
position: 'top',
labels: {
color: 'hsl(var(--bc))',
font: {
size: 12,
family: "'Inter', sans-serif"
},
usePointStyle: true,
padding: 15
}
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.85)',
titleColor: '#fff',
bodyColor: '#fff',
borderColor: 'hsl(var(--p))',
borderWidth: 1,
padding: 12,
callbacks: {
label: function (context) {
const label = context.dataset.label || '';
const value = context.parsed.y;
const total = estatisticas!.totalRegistros;
const percentage = total > 0 ? ((value / total) * 100).toFixed(1) : '0.0';
return `${label}: ${value} (${percentage}%)`;
}
}
}
},
scales: {
x: {
stacked: true,
grid: {
display: false
},
ticks: {
color: 'hsl(var(--bc))',
font: {
size: 12
}
}
},
y: {
stacked: true,
beginAtZero: true,
grid: {
color: 'rgba(0, 0, 0, 0.05)'
},
ticks: {
color: 'hsl(var(--bc))',
font: {
size: 11
},
stepSize: 1
}
}
},
animation: {
duration: 1000,
easing: 'easeInOutQuart'
},
interaction: {
mode: 'index',
intersect: false
}
}
});
} catch (error) {
console.error('Erro ao criar gráfico:', error);
}
}
$effect(() => {
if (chartCanvas && estatisticas && chartData) {
if (chartInstance) {
chartInstance.destroy();
chartInstance = null;
}
const timeoutId = setTimeout(() => {
try {
criarGrafico();
} catch (err) {
console.error('Erro ao criar gráfico no effect:', err);
}
}, 200);
return () => {
clearTimeout(timeoutId);
};
}
});
</script>
<div class="card bg-base-100/90 border-base-300 mb-8 border shadow-xl backdrop-blur-sm">
<div class="card-body">
<div class="mb-6 flex items-center justify-between">
<h2 class="card-title text-2xl">
<div class="bg-primary/10 rounded-lg p-2">
<BarChart3 class="text-primary h-6 w-6" strokeWidth={2.5} />
</div>
<span>Visão Geral das Estatísticas</span>
</h2>
</div>
<div class="bg-base-200/50 border-base-300 relative h-80 w-full rounded-xl border p-4">
{#if isLoading}
<div class="bg-base-200/30 absolute inset-0 flex items-center justify-center rounded-xl">
<div class="flex flex-col items-center gap-4">
<span class="loading loading-spinner loading-lg text-primary"></span>
<span class="text-base-content/70 font-medium">Carregando estatísticas...</span>
</div>
</div>
{:else if error}
<div class="bg-base-200/30 absolute inset-0 flex items-center justify-center rounded-xl">
<div class="alert alert-error shadow-lg">
<XCircle class="h-6 w-6" />
<div>
<h3 class="font-bold">Erro ao carregar estatísticas</h3>
<div class="mt-1 text-sm">
{error?.message || String(error) || 'Erro desconhecido'}
</div>
</div>
</div>
</div>
{:else if !estatisticas || !chartData}
<div class="bg-base-200/30 absolute inset-0 flex items-center justify-center rounded-xl">
<div class="text-center">
<FileText class="text-base-content/30 mx-auto mb-2 h-12 w-12" />
<p class="text-base-content/70">Nenhuma estatística disponível</p>
</div>
</div>
{:else}
<canvas bind:this={chartCanvas} class="h-full w-full"></canvas>
{/if}
</div>
</div>
</div>

View File

@@ -0,0 +1,66 @@
<script lang="ts">
import { Clock } from 'lucide-svelte';
interface Estatisticas {
totalRegistros: number;
totalFuncionarios: number;
dentroDoPrazo: number;
}
interface Props {
estatisticas?: Estatisticas;
}
let { estatisticas = undefined }: Props = $props();
</script>
<section
class="border-base-300 from-primary/10 via-base-100 to-secondary/10 relative mb-8 overflow-hidden rounded-2xl border bg-gradient-to-br p-8 shadow-lg"
>
<div class="bg-primary/20 absolute top-10 -left-10 h-40 w-40 rounded-full blur-3xl"></div>
<div class="bg-secondary/20 absolute right-0 -bottom-16 h-56 w-56 rounded-full blur-3xl"></div>
<div class="relative z-10 flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
<div class="flex items-center gap-4">
<div
class="bg-primary/20 border-primary/30 rounded-2xl border p-4 shadow-lg backdrop-blur-sm"
>
<Clock class="text-primary h-10 w-10" strokeWidth={2.5} />
</div>
<div class="max-w-3xl space-y-2">
<h1 class="text-base-content text-4xl leading-tight font-black sm:text-5xl">
Registro de Pontos
</h1>
<p class="text-base-content/70 text-base leading-relaxed sm:text-lg">
Gerencie e visualize os registros de ponto dos funcionários com informações detalhadas e
relatórios
</p>
</div>
</div>
{#if estatisticas}
<div
class="border-base-200/60 bg-base-100/70 grid grid-cols-2 gap-4 rounded-2xl border p-6 shadow-lg backdrop-blur sm:max-w-sm"
>
<div>
<p class="text-base-content/60 text-sm font-semibold">Total de Registros</p>
<p class="text-base-content mt-2 text-2xl font-bold">{estatisticas.totalRegistros}</p>
</div>
<div class="text-right">
<p class="text-base-content/60 text-sm font-semibold">Funcionários</p>
<p class="text-base-content mt-2 text-xl font-bold">{estatisticas.totalFuncionarios}</p>
</div>
<div
class="via-base-300 col-span-2 h-px bg-gradient-to-r from-transparent to-transparent"
></div>
<div class="text-base-content/70 col-span-2 flex items-center justify-between text-sm">
<span>
{estatisticas.totalRegistros > 0
? ((estatisticas.dentroDoPrazo / estatisticas.totalRegistros) * 100).toFixed(1)
: 0}% dentro do prazo
</span>
<span class="badge badge-primary badge-sm">Ativo</span>
</div>
</div>
{/if}
</div>
</section>

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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