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:
@@ -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% {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
436
apps/web/src/lib/utils/fichaPontoPDF.ts
Normal file
436
apps/web/src/lib/utils/fichaPontoPDF.ts
Normal 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' }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
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