Files
sgse-app/apps/web/src/routes/(dashboard)/recursos-humanos/registro-pontos/+page.svelte

4517 lines
155 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts">
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { Chart, registerables } from 'chart.js';
import { useConvexClient, useQuery } from 'convex-svelte';
import jsPDF from 'jspdf';
import autoTable from 'jspdf-autotable';
import {
BarChart3,
CheckCircle2,
Clock,
Download,
FileText,
Filter,
Printer,
TrendingDown,
TrendingUp,
Users,
XCircle
} from 'lucide-svelte';
import Papa from 'papaparse';
import { onDestroy, onMount } from 'svelte';
import { toast } from 'svelte-sonner';
import logoGovPE from '$lib/assets/logo_governo_PE.png';
import LocalizacaoIcon from '$lib/components/ponto/LocalizacaoIcon.svelte';
import PrintPontoModal from '$lib/components/ponto/PrintPontoModal.svelte';
import SaldoDiarioBadge from '$lib/components/ponto/SaldoDiarioBadge.svelte';
import SaldoDiarioComparativoBadge from '$lib/components/ponto/SaldoDiarioComparativoBadge.svelte';
import { formatarDataDDMMAAAA, formatarHoraPonto, getTipoRegistroLabel } from '$lib/utils/ponto';
Chart.register(...registerables);
const client = useConvexClient();
// Estados
// Expandir período padrão para últimos 30 dias para facilitar visualização
const hoje = new Date();
const trintaDiasAtras = new Date(hoje);
trintaDiasAtras.setDate(hoje.getDate() - 30);
let dataInicio = $state(trintaDiasAtras.toISOString().split('T')[0]!);
let dataFim = $state(hoje.toISOString().split('T')[0]!);
let funcionarioIdFiltro = $state<Id<'funcionarios'> | ''>('');
let statusFiltro = $state<'todos' | 'dentro' | 'fora'>('todos');
let localizacaoFiltro = $state<'todos' | 'dentro' | 'fora'>('todos');
const carregando = $state(false);
let mostrarModalImpressao = $state(false);
let funcionarioParaImprimir = $state<Id<'funcionarios'> | ''>('');
let mostrarModalDetalhes = $state(false);
let registroDetalhesId = $state<Id<'registrosPonto'> | ''>('');
let chartCanvas: HTMLCanvasElement;
let chartInstance: Chart | null = null;
// Parâmetros reativos para queries
const registrosParams = $derived({
funcionarioId:
funcionarioIdFiltro && funcionarioIdFiltro !== '' ? funcionarioIdFiltro : undefined,
dataInicio,
dataFim
});
const estatisticasParams = $derived({
dataInicio,
dataFim,
funcionarioId:
funcionarioIdFiltro && funcionarioIdFiltro !== '' ? funcionarioIdFiltro : undefined
});
// Queries
const funcionariosQuery = useQuery(api.funcionarios.getAll, {});
const registrosQuery = useQuery(api.pontos.listarRegistrosPeriodo, registrosParams);
const estatisticasQuery = useQuery(api.pontos.obterEstatisticas, estatisticasParams);
const configQuery = useQuery(api.configuracaoPonto.obterConfiguracao, {});
const funcionarios = $derived(funcionariosQuery?.data || []);
const registros = $derived(registrosQuery?.data || []);
const estatisticas = $derived(estatisticasQuery?.data);
const config = $derived(configQuery?.data);
// Debug: Log dos dados recebidos
$effect(() => {
if (registrosQuery !== undefined) {
console.log('[Frontend] registrosQuery:', {
isLoading: registrosQuery?.isLoading,
error: registrosQuery?.error,
dataLength: registrosQuery?.data?.length ?? 0,
params: registrosParams
});
}
if (registros && registros.length > 0) {
console.log('[Frontend] Primeiros registros:', registros.slice(0, 3));
}
});
// Dados do gráfico baseados nas estatísticas
const chartData = $derived.by(() => {
if (!estatisticas) return null;
return {
labels: ['Estatísticas de Registros'],
datasets: [
{
label: 'Dentro do Prazo',
data: [estatisticas.dentroDoPrazo || 0],
backgroundColor: 'rgba(34, 197, 94, 0.8)',
borderColor: 'rgba(34, 197, 94, 1)',
borderWidth: 2
},
{
label: 'Fora do Prazo',
data: [estatisticas.foraDoPrazo || 0],
backgroundColor: 'rgba(239, 68, 68, 0.8)',
borderColor: 'rgba(239, 68, 68, 1)',
borderWidth: 2
}
]
};
});
// Função para criar/atualizar o gráfico
function criarGrafico() {
if (!chartCanvas || !estatisticas || !chartData) {
return;
}
const ctx = chartCanvas.getContext('2d');
if (!ctx) {
return;
}
// Destruir gráfico anterior se existir
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: (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);
}
}
// Inicializar gráfico quando canvas e dados estiverem disponíveis
$effect(() => {
if (chartCanvas && estatisticas && chartData) {
// Destruir gráfico anterior se existir
if (chartInstance) {
chartInstance.destroy();
chartInstance = null;
}
// Aguardar um pouco para garantir que o canvas está renderizado
const timeoutId = setTimeout(() => {
try {
criarGrafico();
} catch (error) {
console.error('Erro ao criar gráfico no effect:', error);
}
}, 200);
return () => {
clearTimeout(timeoutId);
};
}
});
// Também tentar criar quando o canvas for montado
onMount(() => {
// Tentar criar o gráfico após um pequeno delay para garantir que tudo está renderizado
const timeoutId = setTimeout(() => {
if (chartCanvas && estatisticas && chartData && !chartInstance) {
try {
criarGrafico();
} catch (error) {
console.error('Erro ao criar gráfico no onMount:', error);
}
}
}, 500);
return () => {
clearTimeout(timeoutId);
};
});
onDestroy(() => {
if (chartInstance) {
chartInstance.destroy();
}
});
// Filtrar registros com base nos filtros avançados
// Nota: Os filtros de data e funcionário são aplicados no backend através de registrosParams
// Os filtros de status e localização são aplicados aqui no frontend
const registrosFiltrados = $derived.by(() => {
if (!registros || registros.length === 0) return [];
let resultado = [...registros];
// Filtro de status (Dentro/Fora do Prazo)
if (statusFiltro !== 'todos') {
resultado = resultado.filter((r) => {
if (statusFiltro === 'dentro') return r.dentroDoPrazo === true;
if (statusFiltro === 'fora') return r.dentroDoPrazo === false;
return true;
});
}
// Filtro de localização (Dentro/Fora do Raio)
if (localizacaoFiltro !== 'todos') {
resultado = resultado.filter((r) => {
// Se não houver informação de localização, excluir do resultado quando filtro está ativo
if (r.dentroRaioPermitido === undefined || r.dentroRaioPermitido === null) {
return false;
}
if (localizacaoFiltro === 'dentro') return r.dentroRaioPermitido === true;
if (localizacaoFiltro === 'fora') return r.dentroRaioPermitido === false;
return true;
});
}
return resultado;
});
// Agrupar registros por funcionário e data
const registrosAgrupados = $derived.by(() => {
const configData = config;
const agrupados: Record<
string,
{
funcionario: {
nome: string;
matricula?: string;
descricaoCargo?: string;
} | null;
funcionarioId: Id<'funcionarios'>;
registrosPorData: Record<
string,
{
data: string;
registros: Array<(typeof registros)[number]>;
saldoDiario?: {
saldoMinutos: number;
horas: number;
minutos: number;
positivo: boolean;
};
saldoDiarioComparativo?: {
trabalhadoMinutos: number;
esperadoMinutos: number;
diferencaMinutos: number;
};
}
>;
}
> = {};
// Usar Set para evitar registros duplicados
const registrosProcessados = new Set<string>();
// Usar registros filtrados ao invés de registros originais
const registrosParaAgrupar = registrosFiltrados;
// Verificar se registros é um array válido
if (!Array.isArray(registrosParaAgrupar) || registrosParaAgrupar.length === 0) {
return [];
}
for (const registro of registrosParaAgrupar) {
// Verificar se o registro tem os campos necessários
if (!registro || !registro._id || !registro.funcionarioId || !registro.data) {
console.warn('⚠️ [DEBUG] Registro inválido ignorado:', registro);
continue;
}
// Criar chave única para evitar duplicatas
const chaveUnica = `${registro._id}`;
if (registrosProcessados.has(chaveUnica)) {
continue; // Pular se já foi processado
}
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
};
}
// Verificar se o registro já não está no array antes de adicionar
const jaExiste = agrupados[key]!.registrosPorData[dataKey]!.registros.some(
(r) => r._id === registro._id
);
if (!jaExiste) {
agrupados[key]!.registrosPorData[dataKey]!.registros.push(registro);
}
}
// Ordenar registros por data e hora dentro de cada grupo e usar saldo diário da query
const resultado = Object.values(agrupados);
// Ordenar grupos por nome do funcionário
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) {
// Ordenar datas dentro de cada grupo (mais recente primeiro)
const datasOrdenadas = Object.keys(grupo.registrosPorData).sort((a, b) => {
return new Date(b).getTime() - new Date(a).getTime();
});
// Criar novo objeto com datas ordenadas
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) {
// Ordenar por hora e minuto
grupoData.registros.sort((a, b) => {
if (a.hora !== b.hora) {
return a.hora - b.hora;
}
return a.minuto - b.minuto;
});
// Calcular saldo diário comparativo usando a soma dos saldos parciais
if (configData) {
// Calcular saldos parciais (Par 1, Par 2, etc.)
const saldosParciais = calcularSaldosParciais(grupoData.registros);
// Somar todos os saldos parciais para obter o total trabalhado
let totalTrabalhado = 0;
for (const saldoParcial of saldosParciais.values()) {
totalTrabalhado += saldoParcial.saldoMinutos;
}
// Calcular carga horária diária total esperada (soma dos dois pares)
const [horaEntradaConfig, minutoEntradaConfig] = configData.horarioEntrada
.split(':')
.map(Number);
const [horaSaidaAlmocoConfig, minutoSaidaAlmocoConfig] = configData.horarioSaidaAlmoco
.split(':')
.map(Number);
const [horaRetornoAlmocoConfig, minutoRetornoAlmocoConfig] =
configData.horarioRetornoAlmoco.split(':').map(Number);
const [horaSaidaConfig, minutoSaidaConfig] = configData.horarioSaida
.split(':')
.map(Number);
// Par 1: entrada -> saida_almoco
const minutosPar1EsperadoConfig =
horaSaidaAlmocoConfig * 60 +
minutoSaidaAlmocoConfig -
(horaEntradaConfig * 60 + minutoEntradaConfig);
const minutosPar1EsperadoAjustadoConfig =
minutosPar1EsperadoConfig < 0
? minutosPar1EsperadoConfig + 24 * 60
: minutosPar1EsperadoConfig;
// Par 2: retorno_almoco -> saida
const minutosPar2EsperadoConfig =
horaSaidaConfig * 60 +
minutoSaidaConfig -
(horaRetornoAlmocoConfig * 60 + minutoRetornoAlmocoConfig);
const minutosPar2EsperadoAjustadoConfig =
minutosPar2EsperadoConfig < 0
? minutosPar2EsperadoConfig + 24 * 60
: minutosPar2EsperadoConfig;
const cargaHorariaDiariaEsperadaMinutos =
minutosPar1EsperadoAjustadoConfig + minutosPar2EsperadoAjustadoConfig;
// Calcular diferença em relação à carga horária diária total configurada
const diferencaMinutos = totalTrabalhado - cargaHorariaDiariaEsperadaMinutos;
// Armazenar saldo comparativo
grupoData.saldoDiarioComparativo = {
trabalhadoMinutos: totalTrabalhado,
esperadoMinutos: cargaHorariaDiariaEsperadaMinutos,
diferencaMinutos: diferencaMinutos
};
} else {
// Fallback: usar cálculo simples se não houver configuração
const primeiroRegistro = grupoData.registros[0];
if (
primeiroRegistro &&
'saldoDiario' in primeiroRegistro &&
primeiroRegistro.saldoDiario
) {
grupoData.saldoDiario = primeiroRegistro.saldoDiario;
} else {
grupoData.saldoDiario = calcularSaldoDiario(grupoData.registros);
}
}
}
}
}
// Filtrar grupos que não têm registros após aplicar os filtros
// Isso garante que apenas funcionários com registros que passam pelos filtros sejam exibidos
const resultadoFiltrado = resultado.filter((grupo) => {
// Verificar se o grupo tem pelo menos um registro em alguma data
const temRegistros = Object.values(grupo.registrosPorData).some(
(grupoData) => grupoData.registros && grupoData.registros.length > 0
);
return temRegistros;
});
return resultadoFiltrado;
});
// Query para banco de horas de cada funcionário
const funcionariosComBancoHoras = $derived.by(() => {
return registrosAgrupados.map((grupo) => grupo.funcionarioId);
});
// Função para formatar saldo de horas
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`;
}
// Função para formatar saldo diário
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`;
}
// Usar função centralizada formatarDataDDMMAAAA da lib/utils/ponto.ts
// Obter nome do funcionário selecionado
const funcionarioSelecionadoNome = $derived.by(() => {
if (!funcionarioIdFiltro) return null;
return funcionarios.find((f) => f._id === funcionarioIdFiltro)?.nome || null;
});
// Função para calcular saldo diário como diferença entre saída e entrada
/**
* Calcula saldos parciais entre cada par entrada/saída
* Retorna um mapa com o índice do registro e seu saldo parcial
*/
function calcularSaldosParciais(
registros: Array<{ tipo: string; hora: number; minuto: number; _id?: any }>
): 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;
}
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
*/
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
*/
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;
}
/**
* Gera array de todas as datas do período selecionado
*/
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
*/
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 }
];
}
/**
* Verifica se um registro esperado foi marcado
*/
function registroFoiMarcado(
registroEsperado: { tipo: string; hora: number; minuto: number },
registrosReais: Array<{ tipo: string; hora: number; minuto: number }>
): boolean {
return registrosReais.some((r) => r.tipo === registroEsperado.tipo);
}
function abrirModalImpressao(funcionarioId: Id<'funcionarios'>) {
funcionarioParaImprimir = funcionarioId;
mostrarModalImpressao = true;
}
// Função para limpar todos os filtros
function limparFiltros() {
const hoje = new Date();
const trintaDiasAtras = new Date(hoje);
trintaDiasAtras.setDate(hoje.getDate() - 30);
dataInicio = trintaDiasAtras.toISOString().split('T')[0]!;
dataFim = hoje.toISOString().split('T')[0]!;
funcionarioIdFiltro = '';
statusFiltro = 'todos';
localizacaoFiltro = 'todos';
toast.success('Filtros limpos com sucesso!');
}
// Função para exportar registros para CSV
async function exportarCSV() {
try {
const registrosParaExportar = registrosFiltrados;
if (!registrosParaExportar || registrosParaExportar.length === 0) {
toast.error('Nenhum registro para exportar');
return;
}
// Preparar dados para CSV
const csvData = registrosParaExportar.map((registro) => {
const funcionarioNome = registro.funcionario?.nome || 'N/A';
const funcionarioMatricula = registro.funcionario?.matricula || 'N/A';
const tipo = config
? getTipoRegistroLabel(registro.tipo, {
nomeEntrada: config.nomeEntrada,
nomeSaidaAlmoco: config.nomeSaidaAlmoco,
nomeRetornoAlmoco: config.nomeRetornoAlmoco,
nomeSaida: config.nomeSaida
})
: getTipoRegistroLabel(registro.tipo);
const horario = formatarHoraPonto(registro.hora, registro.minuto);
const status = registro.dentroDoPrazo ? 'Dentro do Prazo' : 'Fora do Prazo';
const localizacao =
registro.dentroRaioPermitido === true
? 'Dentro do Raio'
: registro.dentroRaioPermitido === false
? 'Fora do Raio'
: 'Não Validado';
return {
Data: formatarDataDDMMAAAA(registro.data),
Funcionário: funcionarioNome,
Matrícula: funcionarioMatricula,
Tipo: tipo,
Horário: horario,
Status: status,
Localização: localizacao,
IP: registro.ipAddress || 'N/A',
Dispositivo: registro.deviceType || 'N/A'
};
});
// Gerar CSV usando Papa Parse
const csv = Papa.unparse(csvData, {
header: true,
delimiter: ';',
encoding: 'UTF-8'
});
// Adicionar BOM para Excel reconhecer UTF-8 corretamente
const BOM = '\uFEFF';
const csvComBOM = BOM + csv;
// Criar blob e download
const blob = new Blob([csvComBOM], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute(
'download',
`registros-ponto-${formatarDataDDMMAAAA(dataInicio)}-${formatarDataDDMMAAAA(dataFim)}.csv`
);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
toast.success('CSV exportado com sucesso!');
} catch (error) {
console.error('Erro ao exportar CSV:', error);
toast.error('Erro ao exportar CSV. Tente novamente.');
}
}
async function gerarPDFComSelecao(sections: {
dadosFuncionario: boolean;
registrosPonto: boolean;
saldoDiario: boolean;
bancoHoras: boolean;
alteracoesGestor: boolean;
dispensasRegistro: boolean;
}) {
if (!funcionarioParaImprimir) return;
const funcionarioId = funcionarioParaImprimir;
// Verificar se pelo menos uma seção foi selecionada
if (!Object.values(sections).some((v) => v)) {
toast.error('Selecione pelo menos uma seção para imprimir');
return;
}
const funcionario = funcionarios.find((f) => f._id === funcionarioId);
if (!funcionario) {
toast.error('Funcionário não encontrado');
return;
}
// Buscar registros do funcionário no período selecionado
const registrosFuncionario = await client.query(api.pontos.listarRegistrosPeriodo, {
funcionarioId,
dataInicio,
dataFim
});
if (!registrosFuncionario || registrosFuncionario.length === 0) {
toast.error('Nenhum registro encontrado para este funcionário no período selecionado');
return;
}
try {
const doc = new jsPDF();
// Logo
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);
}
// Cabeçalho
doc.setFontSize(16);
doc.setTextColor(41, 128, 185);
doc.text('FICHA DE PONTO', 105, yPosition, { align: 'center' });
yPosition += 10;
// Dados do Funcionário
if (sections.dadosFuncionario) {
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;
// Formatar período para exibição usando função centralizada
const periodoFormatado = `${formatarDataDDMMAAAA(dataInicio)} a ${formatarDataDDMMAAAA(dataFim)}`;
doc.text(`Período: ${periodoFormatado}`, 15, yPosition);
yPosition += 10;
}
// Buscar homologações e dispensas
let homologacoes: Array<{
_id: Id<'homologacoesPonto'>;
criadoEm: number;
registroId?: Id<'registrosPonto'>;
horaAnterior?: number;
minutoAnterior?: number;
horaNova?: number;
minutoNova?: number;
tipoAjuste?: 'compensar' | 'abonar' | 'descontar';
periodoDias?: number;
periodoHoras?: number;
periodoMinutos?: number;
motivoDescricao?: string;
motivoTipo?: string;
observacoes?: string;
}> = [];
let dispensas: Array<{
dataInicio: string;
dataFim: string;
horaInicio: number;
minutoInicio: number;
horaFim: number;
minutoFim: number;
motivo: string;
isento: boolean;
}> = [];
if (sections.alteracoesGestor) {
try {
const todasHomologacoes =
(await client.query(api.pontos.listarHomologacoes, {
funcionarioId
})) || [];
// Filtrar homologações pelo período selecionado
const dataInicioTimestamp = new Date(dataInicio + 'T00:00:00').getTime();
const dataFimTimestamp = new Date(dataFim + 'T23:59:59').getTime();
homologacoes = todasHomologacoes.filter((h) => {
return h.criadoEm >= dataInicioTimestamp && h.criadoEm <= dataFimTimestamp;
});
} catch (error) {
console.warn('Erro ao buscar homologações:', error);
// Continuar mesmo se houver erro ao buscar homologações
}
}
if (sections.dispensasRegistro) {
try {
const todasDispensas =
(await client.query(api.pontos.listarDispensas, {
funcionarioId,
apenasAtivas: false
})) || [];
// Filtrar dispensas que têm interseção com o período selecionado
const dataInicioPeriodo = new Date(dataInicio + 'T00:00:00');
const dataFimPeriodo = new Date(dataFim + 'T23:59:59');
dispensas = todasDispensas.filter((d) => {
const dispensaInicio = new Date(d.dataInicio + 'T00:00:00');
const dispensaFim = new Date(d.dataFim + 'T23:59:59');
// Verificar se há interseção entre os períodos
return dispensaInicio <= dataFimPeriodo && dispensaFim >= dataInicioPeriodo;
});
} catch (error) {
console.warn('Erro ao buscar dispensas:', error);
// Continuar mesmo se houver erro ao buscar dispensas
}
}
// Variável para armazenar saldos diários (usada no resumo do banco de horas)
const saldosDiariosPorData: Record<
string,
{
diferencaMinutos: number;
trabalhadoMinutos: number;
esperadoMinutos: number;
}
> = {};
// Tabela de registros
if (sections.registrosPonto) {
const config = await client.query(api.configuracaoPonto.obterConfiguracao, {});
if (!config) {
throw new Error('Configuração de ponto não encontrada');
}
const tableData: any[][] = [];
// Agrupar registros reais por data
const registrosPorData: Record<
string,
Array<{
data: string;
tipo: string;
hora: number;
minuto: number;
dentroDoPrazo: boolean;
dentroRaioPermitido: boolean | null | undefined;
acelerometroX?: number | undefined;
acelerometroY?: number | undefined;
acelerometroZ?: number | undefined;
movimentoDetectado?: boolean | undefined;
magnitudeMovimento?: number | undefined;
sensorDisponivel?: boolean | undefined;
}>
> = {};
for (const r of registrosFuncionario) {
const dataKey = r.data;
if (!registrosPorData[dataKey]) {
registrosPorData[dataKey] = [];
}
registrosPorData[dataKey]!.push({
data: r.data,
tipo: r.tipo,
hora: r.hora,
minuto: r.minuto,
dentroDoPrazo: r.dentroDoPrazo,
dentroRaioPermitido: r.dentroRaioPermitido,
acelerometroX: r.acelerometroX,
acelerometroY: r.acelerometroY,
acelerometroZ: r.acelerometroZ,
movimentoDetectado: r.movimentoDetectado,
magnitudeMovimento: r.magnitudeMovimento,
sensorDisponivel: r.sensorDisponivel
});
}
// Gerar todos os dias do período
const diasPeriodo = gerarDiasPeriodo(dataInicio, dataFim);
// Processar cada dia do período
for (const data of diasPeriodo) {
const dataFormatada = formatarDataDDMMAAAA(data);
const regsReais = registrosPorData[data] || [];
const regsEsperados = gerarRegistrosEsperados(data, config);
// Combinar registros reais e esperados
const todosRegistros: Array<{
tipo: string;
hora: number;
minuto: number;
real: boolean;
dentroDoPrazo?: boolean;
}> = [];
// Adicionar registros reais
for (const reg of regsReais) {
todosRegistros.push({
tipo: reg.tipo,
hora: reg.hora,
minuto: reg.minuto,
real: true,
dentroDoPrazo: reg.dentroDoPrazo
});
}
// Adicionar registros esperados não marcados (em vermelho)
for (const regEsperado of regsEsperados) {
if (!registroFoiMarcado(regEsperado, regsReais)) {
todosRegistros.push({
tipo: regEsperado.tipo,
hora: regEsperado.hora,
minuto: regEsperado.minuto,
real: false
});
}
}
// Ordenar todos os registros por hora e minuto
todosRegistros.sort((a, b) => {
if (a.hora !== b.hora) {
return a.hora - b.hora;
}
return a.minuto - b.minuto;
});
// Calcular saldos comparativos por par entrada/saída (apenas com registros reais)
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
);
// Calcular saldos esperados para pares incompletos ou dias sem registros
const saldosEsperadosPorPar: Map<
number,
{
trabalhadoMinutos: number;
trabalhadoHoras: number;
trabalhadoMinutosResto: number;
esperadoMinutos: number;
esperadoHoras: number;
esperadoMinutosResto: number;
diferencaMinutos: number;
diferencaHoras: number;
diferencaMinutosResto: number;
tamanhoPar: number;
incompleto: boolean;
}
> = new Map();
// Calcular saldo diário total (diferença acumulada de todos os pares)
// Declarar variáveis ANTES de usá-las
let saldoDiarioTotalDiferencaMinutos = 0;
let saldoDiarioTotalTrabalhadoMinutos = 0;
let saldoDiarioTotalEsperadoMinutos = 0;
// Criar conjunto de chaves de registros que já foram processados como completos
// Isso será usado tanto para evitar criar pares incompletos quanto para evitar somar duplicados
const chavesProcessadasCompletas = new Set<string>();
const paresCompletosProcessados = new Set<number>(); // Rastrear parIndex já processados
// Criar conjunto de chaves e somar saldos dos pares completos
// IMPORTANTE: saldosComparativosPorPar associa o saldo a AMBOS os registros do par (entrada + saída)
// Então precisamos garantir que somamos apenas UMA VEZ por par, não uma vez por registro
for (const [index, saldo] of saldosComparativosPorPar.entries()) {
const regEntrada = regsReaisOrdenados[index];
if (regEntrada) {
// Verificar se este parIndex já foi processado
if (!paresCompletosProcessados.has(saldo.parIndex)) {
// Primeira vez processando este par, somar o saldo
saldoDiarioTotalDiferencaMinutos += saldo.diferencaMinutos;
saldoDiarioTotalTrabalhadoMinutos += saldo.trabalhadoMinutos;
saldoDiarioTotalEsperadoMinutos += saldo.esperadoMinutos;
paresCompletosProcessados.add(saldo.parIndex);
}
// Adicionar chaves para evitar processamento duplicado depois
const chaveEntrada = `${regEntrada.tipo}-${regEntrada.hora}-${regEntrada.minuto}`;
chavesProcessadasCompletas.add(chaveEntrada);
// Encontrar saída correspondente
const tipoSaidaEsperado = regEntrada.tipo === 'entrada' ? 'saida_almoco' : 'saida';
for (let j = index + 1; j < regsReaisOrdenados.length; j++) {
const regSaida = regsReaisOrdenados[j];
if (regSaida && regSaida.tipo === tipoSaidaEsperado) {
const chaveSaida = `${regSaida.tipo}-${regSaida.hora}-${regSaida.minuto}`;
chavesProcessadasCompletas.add(chaveSaida);
break;
}
}
}
}
// Identificar pares incompletos e calcular saldos esperados
// IMPORTANTE: Só processar pares que NÃO foram processados como completos
for (let i = 0; i < todosRegistros.length; i++) {
const reg = todosRegistros[i];
// Se é entrada ou retorno_almoco (início de par) e é real
if ((reg.tipo === 'entrada' || reg.tipo === 'retorno_almoco') && reg.real) {
// Verificar se este registro já foi processado como par completo
const chaveRegistro = `${reg.tipo}-${reg.hora}-${reg.minuto}`;
if (chavesProcessadasCompletas.has(chaveRegistro)) {
continue; // Já foi processado como par completo, pular
}
// Verificar se há saída correspondente real
const tipoSaidaEsperado = reg.tipo === 'entrada' ? 'saida_almoco' : 'saida';
const saidaEncontrada = regsReais.find((r) => r.tipo === tipoSaidaEsperado);
// Se há saída correspondente, verificar se ela também está em chavesProcessadasCompletas
// Se estiver, significa que este par já foi processado como completo
if (saidaEncontrada) {
const chaveSaida = `${saidaEncontrada.tipo}-${saidaEncontrada.hora}-${saidaEncontrada.minuto}`;
if (chavesProcessadasCompletas.has(chaveSaida)) {
continue; // Par completo já processado, pular
}
}
if (!saidaEncontrada) {
// Par incompleto: entrada real sem saída correspondente
// NÃO calcular tempo trabalhado aqui porque não há saída marcada
// O tempo trabalhado será 0, e a diferença será negativa (0 - esperado)
const regEsperado = regsEsperados.find((r) => r.tipo === tipoSaidaEsperado);
if (regEsperado) {
// Tempo trabalhado = 0 (não há saída marcada, então não podemos assumir tempo trabalhado)
const trabalhadoMinutos = 0;
// Calcular tempo esperado
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;
}
// Calcular diferença (0 - esperado = negativo)
const diferencaMinutos = -esperadoMinutos;
const trabalhadoHoras = 0;
const trabalhadoMinutosResto = 0;
const esperadoHoras = Math.floor(esperadoMinutos / 60);
const esperadoMinutosResto = esperadoMinutos % 60;
const diferencaHoras = Math.floor(Math.abs(diferencaMinutos) / 60);
const diferencaMinutosResto = Math.abs(diferencaMinutos) % 60;
// Contar quantos registros fazem parte deste par na lista todosRegistros
const indexNaListaTodos = todosRegistros.findIndex(
(r, idx) =>
idx >= i &&
r.tipo === reg.tipo &&
r.hora === reg.hora &&
r.minuto === reg.minuto
);
// Contar registros do par (entrada + saída esperada)
let tamanhoPar = 1; // entrada
const saidaEsperadaNaLista = todosRegistros.find(
(r, idx) => idx > indexNaListaTodos && r.tipo === tipoSaidaEsperado && !r.real
);
if (saidaEsperadaNaLista) {
tamanhoPar++; // saída faltante já está na lista
}
if (indexNaListaTodos >= 0) {
saldosEsperadosPorPar.set(indexNaListaTodos, {
trabalhadoMinutos,
trabalhadoHoras,
trabalhadoMinutosResto,
esperadoMinutos,
esperadoHoras,
esperadoMinutosResto,
diferencaMinutos,
diferencaHoras,
diferencaMinutosResto,
tamanhoPar,
incompleto: true
});
}
}
}
}
}
// Identificar pares completamente não marcados (quando há registros reais mas um par completo não foi marcado)
if (regsReais.length > 0) {
for (let i = 0; i < todosRegistros.length; i++) {
const reg = todosRegistros[i];
// Se é entrada ou retorno_almoco (início de par) e NÃO é real
if ((reg.tipo === 'entrada' || reg.tipo === 'retorno_almoco') && !reg.real) {
// Verificar se a saída correspondente também não foi marcada
const tipoSaidaEsperado = reg.tipo === 'entrada' ? 'saida_almoco' : 'saida';
const saidaEsperada = todosRegistros.find(
(r, idx) => idx > i && r.tipo === tipoSaidaEsperado && !r.real
);
if (saidaEsperada) {
// Par completamente não marcado: calcular saldo negativo
// Tempo trabalhado = 0, tempo esperado = configurado, diferença = -esperado
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;
}
// Trabalhado = 0, diferença = -esperado
const trabalhadoMinutos = 0;
const diferencaMinutos = -esperadoMinutos;
const trabalhadoHoras = 0;
const trabalhadoMinutosResto = 0;
const esperadoHoras = Math.floor(esperadoMinutos / 60);
const esperadoMinutosResto = esperadoMinutos % 60;
const diferencaHoras = Math.floor(Math.abs(diferencaMinutos) / 60);
const diferencaMinutosResto = Math.abs(diferencaMinutos) % 60;
// Encontrar índice da saída esperada na lista
const indexSaidaEsperada = todosRegistros.findIndex(
(r, idx) => idx > i && r.tipo === tipoSaidaEsperado && !r.real
);
// Associar saldo negativo ao início do par (entrada)
saldosEsperadosPorPar.set(i, {
trabalhadoMinutos,
trabalhadoHoras,
trabalhadoMinutosResto,
esperadoMinutos,
esperadoHoras,
esperadoMinutosResto,
diferencaMinutos,
diferencaHoras,
diferencaMinutosResto,
tamanhoPar: indexSaidaEsperada >= 0 ? 2 : 1, // entrada + saída
incompleto: false // Par completo não marcado
});
}
}
}
}
// NOTA: Os saldos dos pares completos já foram somados acima quando criamos chavesProcessadasCompletas
// As variáveis saldoDiarioTotal* já foram declaradas e inicializadas acima
// Somar saldos dos pares completamente não marcados e incompletos
// IMPORTANTE: Não somar pares que já foram processados como completos acima
// Usar o conjunto chavesProcessadasCompletas criado anteriormente
for (const [indexNaListaTodos, saldo] of saldosEsperadosPorPar.entries()) {
const regNaListaTodos = todosRegistros[indexNaListaTodos];
// Verificar se este registro real já foi processado como par completo
if (regNaListaTodos && regNaListaTodos.real) {
const chaveRegistro = `${regNaListaTodos.tipo}-${regNaListaTodos.hora}-${regNaListaTodos.minuto}`;
if (chavesProcessadasCompletas.has(chaveRegistro)) {
// Este registro está em chavesProcessadasCompletas
// Verificar se há uma saída correspondente que também está lá
// Se ambas estão, significa que o par completo já foi processado
const tipoSaidaEsperado =
regNaListaTodos.tipo === 'entrada' ? 'saida_almoco' : 'saida';
// Procurar saída correspondente em regsReais que também está em chavesProcessadasCompletas
const saidaCompletaEncontrada = regsReais.find((r) => {
if (r.tipo !== tipoSaidaEsperado) return false;
const chaveSaida = `${r.tipo}-${r.hora}-${r.minuto}`;
return chavesProcessadasCompletas.has(chaveSaida);
});
if (saidaCompletaEncontrada) {
continue; // Par completo já processado (entrada + saída estão em chavesProcessadasCompletas), não somar novamente
}
}
}
if (saldo.trabalhadoMinutos === 0 && !saldo.incompleto) {
// Par completamente não marcado: adicionar diferença negativa
saldoDiarioTotalDiferencaMinutos += saldo.diferencaMinutos;
saldoDiarioTotalTrabalhadoMinutos += saldo.trabalhadoMinutos; // 0
saldoDiarioTotalEsperadoMinutos += saldo.esperadoMinutos;
} else if (saldo.incompleto) {
// Par incompleto: entrada real sem saída correspondente
// Só somar se não foi processado como completo acima
saldoDiarioTotalDiferencaMinutos += saldo.diferencaMinutos;
saldoDiarioTotalTrabalhadoMinutos += saldo.trabalhadoMinutos;
saldoDiarioTotalEsperadoMinutos += saldo.esperadoMinutos;
}
}
// Se não há registros reais, calcular saldo esperado baseado na configuração
if (regsReais.length === 0) {
// Calcular saldo esperado do dia completo
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);
// Par 1: entrada -> saida_almoco
const minutosPar1Entrada = horaEntrada * 60 + minutoEntrada;
const minutosPar1Saida = horaSaidaAlmoco * 60 + minutoSaidaAlmoco;
let saldoPar1 = minutosPar1Saida - minutosPar1Entrada;
if (saldoPar1 < 0) saldoPar1 += 24 * 60;
// Par 2: retorno_almoco -> saida
const minutosPar2Entrada = horaRetornoAlmoco * 60 + minutoRetornoAlmoco;
const minutosPar2Saida = horaSaida * 60 + minutoSaida;
let saldoPar2 = minutosPar2Saida - minutosPar2Entrada;
if (saldoPar2 < 0) saldoPar2 += 24 * 60;
saldoDiarioTotalTrabalhadoMinutos = 0; // Nenhum tempo trabalhado
saldoDiarioTotalEsperadoMinutos = saldoPar1 + saldoPar2;
saldoDiarioTotalDiferencaMinutos = -saldoDiarioTotalEsperadoMinutos; // Diferença negativa (0 - esperado)
}
// Calcular diferença corretamente: trabalhado - esperado (não somar diferenças dos pares)
const diferencaDiariaCorrigida =
saldoDiarioTotalTrabalhadoMinutos - saldoDiarioTotalEsperadoMinutos;
// Armazenar saldo diário completo (usado no resumo do banco de horas)
saldosDiariosPorData[data] = {
diferencaMinutos: diferencaDiariaCorrigida,
trabalhadoMinutos: saldoDiarioTotalTrabalhadoMinutos,
esperadoMinutos: saldoDiarioTotalEsperadoMinutos
};
// Calcular carga horária diária total esperada para inicializar saldo acumulado
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);
const minutosPar1Esperado =
horaSaidaAlmoco * 60 + minutoSaidaAlmoco - (horaEntrada * 60 + minutoEntrada);
const minutosPar1EsperadoAjustado =
minutosPar1Esperado < 0 ? minutosPar1Esperado + 24 * 60 : minutosPar1Esperado;
const minutosPar2Esperado =
horaSaida * 60 + minutoSaida - (horaRetornoAlmoco * 60 + minutoRetornoAlmoco);
const minutosPar2EsperadoAjustado =
minutosPar2Esperado < 0 ? minutosPar2Esperado + 24 * 60 : minutosPar2Esperado;
const cargaHorariaDiariaTotalMinutos =
minutosPar1EsperadoAjustado + minutosPar2EsperadoAjustado;
// Inicializar saldo diário acumulado com a carga horária total diária
let saldoDiarioAcumuladoMinutos = cargaHorariaDiariaTotalMinutos;
// Rastrear quais pares já foram processados para evitar decrementar múltiplas vezes
// Usar string como chave: "tipo-parIndex" ou "tipo-indice" para pares incompletos
const paresProcessadosParaSaldo = new Set<string>();
// Criar linhas da tabela
for (let i = 0; i < todosRegistros.length; i++) {
const reg = todosRegistros[i];
const linha: any[] = [
dataFormatada,
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'
),
formatarHoraPonto(reg.hora, reg.minuto)
];
// Marcar linha como não marcada para aplicar cor vermelha depois
if (!reg.real) {
linha._naoMarcado = true;
}
// Saldo Diário por par entrada/saída com cálculo comparativo
if (sections.saldoDiario) {
const isInicioPar = reg.tipo === 'entrada' || reg.tipo === 'retorno_almoco';
if (reg.real && isInicioPar) {
const indexReal = regsReaisOrdenados.findIndex(
(r) => r.tipo === reg.tipo && r.hora === reg.hora && r.minuto === reg.minuto
);
// Verificar se há saldo esperado (par incompleto)
const saldoEsperado = saldosEsperadosPorPar.get(i);
if (saldoEsperado) {
// Par incompleto: decrementar saldo acumulado
// Decrementar saldo acumulado apenas uma vez por par
const chavePar = `${reg.tipo}-incompleto-${i}`;
if (!paresProcessadosParaSaldo.has(chavePar)) {
saldoDiarioAcumuladoMinutos -= saldoEsperado.trabalhadoMinutos;
paresProcessadosParaSaldo.add(chavePar);
}
// Calcular saldo acumulado formatado
// Se saldoDiarioAcumuladoMinutos > 0: ainda falta trabalhar (mostrar como negativo)
// Se saldoDiarioAcumuladoMinutos < 0: trabalhou mais que o esperado (mostrar como positivo)
const saldoAcumuladoHoras = Math.floor(
Math.abs(saldoDiarioAcumuladoMinutos) / 60
);
const saldoAcumuladoMinutosResto = Math.abs(saldoDiarioAcumuladoMinutos) % 60;
// Inverter sinal: positivo quando trabalhou mais, negativo quando ainda falta
const sinalSaldo = saldoDiarioAcumuladoMinutos < 0 ? '+' : '-';
const trabalhouMaisQueEsperado = saldoDiarioAcumuladoMinutos < 0;
// Marcar linha para aplicar cor no saldo
if (trabalhouMaisQueEsperado) {
linha._saldoPositivo = true; // Verde: trabalhou mais que o esperado
} else {
linha._saldoNegativo = true; // Vermelho: ainda falta trabalhar
}
linha.push({
content: `${saldoEsperado.trabalhadoHoras}h ${saldoEsperado.trabalhadoMinutosResto}min parcial | Saldo: ${sinalSaldo}${saldoAcumuladoHoras}h ${saldoAcumuladoMinutosResto}min`,
rowSpan: saldoEsperado.tamanhoPar
});
} else if (indexReal >= 0) {
const saldoPar = saldosComparativosPorPar.get(indexReal);
if (saldoPar) {
// Decrementar saldo acumulado apenas uma vez por par
const chavePar = `${reg.tipo}-${saldoPar.parIndex}`;
if (!paresProcessadosParaSaldo.has(chavePar)) {
saldoDiarioAcumuladoMinutos -= saldoPar.trabalhadoMinutos;
paresProcessadosParaSaldo.add(chavePar);
}
// Calcular saldo acumulado formatado
// Se saldoDiarioAcumuladoMinutos > 0: ainda falta trabalhar (mostrar como negativo)
// Se saldoDiarioAcumuladoMinutos < 0: trabalhou mais que o esperado (mostrar como positivo)
const saldoAcumuladoHoras = Math.floor(
Math.abs(saldoDiarioAcumuladoMinutos) / 60
);
const saldoAcumuladoMinutosResto = Math.abs(saldoDiarioAcumuladoMinutos) % 60;
// Inverter sinal: positivo quando trabalhou mais, negativo quando ainda falta
const sinalSaldo = saldoDiarioAcumuladoMinutos < 0 ? '+' : '-';
const trabalhouMaisQueEsperado = saldoDiarioAcumuladoMinutos < 0;
// Marcar linha para aplicar cor no saldo
if (trabalhouMaisQueEsperado) {
linha._saldoPositivo = true; // Verde: trabalhou mais que o esperado
} else {
linha._saldoNegativo = true; // Vermelho: ainda falta trabalhar
}
linha.push({
content: `${saldoPar.trabalhadoHoras}h ${saldoPar.trabalhadoMinutosResto}min parcial | Saldo: ${sinalSaldo}${saldoAcumuladoHoras}h ${saldoAcumuladoMinutosResto}min`,
rowSpan: saldoPar.tamanhoPar
});
} else {
linha.push('-');
}
} else {
linha.push('-');
}
} else if (reg.real) {
// Saída real: não adicionar (já coberto pelo rowspan da entrada)
// Mas verificar se precisa adicionar '-' se não houver par completo
const tipoEntradaEsperado =
reg.tipo === 'saida_almoco' ? 'entrada' : 'retorno_almoco';
const entradaReal = regsReais.find((r) => r.tipo === tipoEntradaEsperado);
if (!entradaReal) {
linha.push('-');
}
} else {
// Registro não marcado: verificar se faz parte de um par incompleto ou dia sem registros
const isInicioPar = reg.tipo === 'entrada' || reg.tipo === 'retorno_almoco';
if (isInicioPar) {
// Verificar se há saldo esperado para este par
const saldoEsperado = saldosEsperadosPorPar.get(i);
if (saldoEsperado) {
// Par incompleto ou completamente não marcado: decrementar saldo acumulado
// Decrementar saldo acumulado apenas uma vez por par
const chavePar = `${reg.tipo}-esperado-${i}`;
if (!paresProcessadosParaSaldo.has(chavePar)) {
// Se não há tempo trabalhado, decrementar o tempo esperado completo
if (saldoEsperado.trabalhadoMinutos === 0) {
saldoDiarioAcumuladoMinutos -= saldoEsperado.esperadoMinutos;
} else {
saldoDiarioAcumuladoMinutos -= saldoEsperado.trabalhadoMinutos;
}
paresProcessadosParaSaldo.add(chavePar);
}
// Calcular saldo acumulado formatado
// Se saldoDiarioAcumuladoMinutos > 0: ainda falta trabalhar (mostrar como negativo)
// Se saldoDiarioAcumuladoMinutos < 0: trabalhou mais que o esperado (mostrar como positivo)
const saldoAcumuladoHoras = Math.floor(
Math.abs(saldoDiarioAcumuladoMinutos) / 60
);
const saldoAcumuladoMinutosResto = Math.abs(saldoDiarioAcumuladoMinutos) % 60;
// Inverter sinal: positivo quando trabalhou mais, negativo quando ainda falta
const sinalSaldo = saldoDiarioAcumuladoMinutos < 0 ? '+' : '-';
const trabalhouMaisQueEsperado = saldoDiarioAcumuladoMinutos < 0;
// Marcar linha para aplicar cor no saldo
if (trabalhouMaisQueEsperado) {
linha._saldoPositivo = true; // Verde: trabalhou mais que o esperado
} else {
linha._saldoNegativo = true; // Vermelho: ainda falta trabalhar
}
// Se par completamente não marcado (trabalhado = 0), mostrar apenas diferença negativa
if (saldoEsperado.trabalhadoMinutos === 0) {
linha.push({
content: `0h 0min parcial | Saldo: ${sinalSaldo}${saldoAcumuladoHoras}h ${saldoAcumuladoMinutosResto}min`,
rowSpan: saldoEsperado.tamanhoPar
});
} else {
linha.push({
content: `${saldoEsperado.trabalhadoHoras}h ${saldoEsperado.trabalhadoMinutosResto}min parcial | Saldo: ${sinalSaldo}${saldoAcumuladoHoras}h ${saldoAcumuladoMinutosResto}min`,
rowSpan: saldoEsperado.tamanhoPar
});
}
} else if (regsReais.length === 0) {
// Dia sem registros: calcular saldo esperado completo e decrementar saldo acumulado
if (reg.tipo === 'entrada') {
// Par 1 completo esperado
const minutosEntrada = horaEntrada * 60 + minutoEntrada;
const minutosSaida = horaSaidaAlmoco * 60 + minutoSaidaAlmoco;
let saldoMinutos = minutosSaida - minutosEntrada;
if (saldoMinutos < 0) saldoMinutos += 24 * 60;
const horas = Math.floor(saldoMinutos / 60);
const minutos = saldoMinutos % 60;
// Decrementar saldo acumulado apenas uma vez por par
const chavePar = `entrada-sem-registros-${i}`;
if (!paresProcessadosParaSaldo.has(chavePar)) {
saldoDiarioAcumuladoMinutos -= saldoMinutos;
paresProcessadosParaSaldo.add(chavePar);
}
// Calcular saldo acumulado formatado
// Se saldoDiarioAcumuladoMinutos > 0: ainda falta trabalhar (mostrar como negativo)
// Se saldoDiarioAcumuladoMinutos < 0: trabalhou mais que o esperado (mostrar como positivo)
const saldoAcumuladoHoras = Math.floor(
Math.abs(saldoDiarioAcumuladoMinutos) / 60
);
const saldoAcumuladoMinutosResto = Math.abs(saldoDiarioAcumuladoMinutos) % 60;
// Inverter sinal: positivo quando trabalhou mais, negativo quando ainda falta
const sinalSaldo = saldoDiarioAcumuladoMinutos < 0 ? '+' : '-';
const trabalhouMaisQueEsperado = saldoDiarioAcumuladoMinutos < 0;
// Marcar linha para aplicar cor no saldo
if (trabalhouMaisQueEsperado) {
linha._saldoPositivo = true; // Verde: trabalhou mais que o esperado
} else {
linha._saldoNegativo = true; // Vermelho: ainda falta trabalhar
}
// Para dia sem registros, mostrar 0h trabalhado
linha.push({
content: `0h 0min parcial | Saldo: ${sinalSaldo}${saldoAcumuladoHoras}h ${saldoAcumuladoMinutosResto}min`,
rowSpan: 2 // entrada + saida_almoco
});
} else if (reg.tipo === 'retorno_almoco') {
// Par 2 completo esperado
const minutosEntrada = horaRetornoAlmoco * 60 + minutoRetornoAlmoco;
const minutosSaida = horaSaida * 60 + minutoSaida;
let saldoMinutos = minutosSaida - minutosEntrada;
if (saldoMinutos < 0) saldoMinutos += 24 * 60;
const horas = Math.floor(saldoMinutos / 60);
const minutos = saldoMinutos % 60;
// Decrementar saldo acumulado apenas uma vez por par
const chavePar = `retorno_almoco-sem-registros-${i}`;
if (!paresProcessadosParaSaldo.has(chavePar)) {
saldoDiarioAcumuladoMinutos -= saldoMinutos;
paresProcessadosParaSaldo.add(chavePar);
}
// Calcular saldo acumulado formatado
// Se saldoDiarioAcumuladoMinutos > 0: ainda falta trabalhar (mostrar como negativo)
// Se saldoDiarioAcumuladoMinutos < 0: trabalhou mais que o esperado (mostrar como positivo)
const saldoAcumuladoHoras = Math.floor(
Math.abs(saldoDiarioAcumuladoMinutos) / 60
);
const saldoAcumuladoMinutosResto = Math.abs(saldoDiarioAcumuladoMinutos) % 60;
// Inverter sinal: positivo quando trabalhou mais, negativo quando ainda falta
const sinalSaldo = saldoDiarioAcumuladoMinutos < 0 ? '+' : '-';
const trabalhouMaisQueEsperado = saldoDiarioAcumuladoMinutos < 0;
// Marcar linha para aplicar cor no saldo
if (trabalhouMaisQueEsperado) {
linha._saldoPositivo = true; // Verde: trabalhou mais que o esperado
} else {
linha._saldoNegativo = true; // Vermelho: ainda falta trabalhar
}
// Para dia sem registros, mostrar 0h trabalhado
linha.push({
content: `0h 0min parcial | Saldo: ${sinalSaldo}${saldoAcumuladoHoras}h ${saldoAcumuladoMinutosResto}min`,
rowSpan: 2 // retorno_almoco + saida
});
} else {
linha.push('-');
}
} else {
// Há registros reais mas este par não foi marcado completamente
// Verificar se é um par completamente não marcado
const tipoSaidaEsperado = reg.tipo === 'entrada' ? 'saida_almoco' : 'saida';
const saidaEsperadaExiste = todosRegistros.some(
(r, idx) => idx > i && r.tipo === tipoSaidaEsperado && !r.real
);
if (saidaEsperadaExiste) {
// Par completamente não marcado: calcular saldo negativo e decrementar saldo acumulado
const saldoEsperadoCompleto = saldosEsperadosPorPar.get(i);
if (saldoEsperadoCompleto) {
// Decrementar saldo acumulado apenas uma vez por par
const chavePar = `${reg.tipo}-nao-marcado-${i}`;
if (!paresProcessadosParaSaldo.has(chavePar)) {
saldoDiarioAcumuladoMinutos -= saldoEsperadoCompleto.esperadoMinutos;
paresProcessadosParaSaldo.add(chavePar);
}
// Calcular saldo acumulado formatado
// Se saldoDiarioAcumuladoMinutos > 0: ainda falta trabalhar (mostrar como negativo)
// Se saldoDiarioAcumuladoMinutos < 0: trabalhou mais que o esperado (mostrar como positivo)
const saldoAcumuladoHoras = Math.floor(
Math.abs(saldoDiarioAcumuladoMinutos) / 60
);
const saldoAcumuladoMinutosResto =
Math.abs(saldoDiarioAcumuladoMinutos) % 60;
// Inverter sinal: positivo quando trabalhou mais, negativo quando ainda falta
const sinalSaldo = saldoDiarioAcumuladoMinutos < 0 ? '+' : '-';
const trabalhouMaisQueEsperado = saldoDiarioAcumuladoMinutos < 0;
// Marcar linha para aplicar cor no saldo
if (trabalhouMaisQueEsperado) {
linha._saldoPositivo = true; // Verde: trabalhou mais que o esperado
} else {
linha._saldoNegativo = true; // Vermelho: ainda falta trabalhar
}
linha.push({
content: `0h 0min parcial | Saldo: ${sinalSaldo}${saldoAcumuladoHoras}h ${saldoAcumuladoMinutosResto}min`,
rowSpan: saldoEsperadoCompleto.tamanhoPar
});
} else {
linha.push('-');
}
} else {
linha.push('-');
}
}
} else {
// Saída não marcada: verificar se faz parte de um par completamente não marcado
// Se a entrada correspondente também não foi marcada, o saldo já foi adicionado na linha da entrada
// Então apenas não adicionar nada aqui (será coberto pelo rowspan)
const tipoEntradaEsperado =
reg.tipo === 'saida_almoco' ? 'entrada' : 'retorno_almoco';
const entradaEsperadaExiste = todosRegistros.some(
(r, idx) => idx < i && r.tipo === tipoEntradaEsperado && !r.real
);
if (!entradaEsperadaExiste) {
// Saída sem entrada correspondente esperada: não tem saldo
linha.push('-');
}
// Se entrada esperada existe, o saldo já foi adicionado com rowspan na linha da entrada
}
}
}
linha.push(reg.real ? (reg.dentroDoPrazo ? 'Sim' : 'Não') : 'Não marcado');
tableData.push(linha);
}
}
const headers = ['Data', 'Tipo', 'Horário'];
if (sections.saldoDiario) {
headers.push('Saldo Diário');
}
headers.push('Dentro do Prazo');
// Salvar a posição Y antes da tabela
const yPosAntesTabela = yPosition;
autoTable(doc, {
startY: yPosition,
head: [headers],
body: tableData,
theme: 'grid',
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
styles: { fontSize: 9 },
didParseCell: (data) => {
// Aplicar cor vermelha para registros não marcados
if (data.row.raw && (data.row.raw as any)._naoMarcado) {
// Aplicar cor vermelha nas colunas Tipo e Horário
if (data.column.index === 1 || data.column.index === 2) {
data.cell.styles.textColor = [200, 0, 0];
}
}
// Aplicar cor vermelha na coluna de saldo diário quando marcado
// Aplicar cor baseada no saldo acumulado
if (data.row.raw) {
const rowData = data.row.raw as any;
const indiceSaldoDiario = sections.saldoDiario ? 3 : -1;
if (data.column.index === indiceSaldoDiario) {
if (rowData._saldoNegativo) {
// Saldo negativo: cor vermelha
data.cell.styles.textColor = [200, 0, 0];
} else if (rowData._saldoPositivo) {
// Saldo positivo: cor verde
data.cell.styles.textColor = [0, 128, 0];
} else if (rowData._saldoVermelho) {
// Fallback para compatibilidade: cor vermelha
data.cell.styles.textColor = [200, 0, 0];
}
}
}
}
});
// Calcular posição Y após a tabela
const lastPage = doc.getNumberOfPages();
doc.setPage(lastPage);
const finalY = (doc as { lastAutoTable?: { finalY: number } }).lastAutoTable?.finalY;
if (finalY) {
yPosition = finalY;
} else {
const linhasTabela = tableData.length + 1;
yPosition = yPosAntesTabela + linhasTabela * 7 + 10;
}
yPosition += 10;
}
// Banco de Horas
if (sections.bancoHoras) {
if (yPosition > doc.internal.pageSize.getHeight() - 60) {
doc.addPage();
yPosition = 20;
}
const bancoHoras = await client.query(api.pontos.obterBancoHorasFuncionario, {
funcionarioId
});
// Calcular total de dias do período selecionado
const diasPeriodo = gerarDiasPeriodo(dataInicio, dataFim);
const totalDiasPeriodo = diasPeriodo.length;
// Calcular carga horária diária esperada baseada na configuração
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);
// Par 1: entrada -> saida_almoco
const minutosPar1EsperadoConfig =
horaSaidaAlmocoConfig * 60 +
minutoSaidaAlmocoConfig -
(horaEntradaConfig * 60 + minutoEntradaConfig);
const minutosPar1EsperadoAjustadoConfig =
minutosPar1EsperadoConfig < 0
? minutosPar1EsperadoConfig + 24 * 60
: minutosPar1EsperadoConfig;
// Par 2: retorno_almoco -> saida
const minutosPar2EsperadoConfig =
horaSaidaConfig * 60 +
minutoSaidaConfig -
(horaRetornoAlmocoConfig * 60 + minutoRetornoAlmocoConfig);
const minutosPar2EsperadoAjustadoConfig =
minutosPar2EsperadoConfig < 0
? minutosPar2EsperadoConfig + 24 * 60
: minutosPar2EsperadoConfig;
const cargaHorariaDiariaEsperadaMinutos =
minutosPar1EsperadoAjustadoConfig + minutosPar2EsperadoAjustadoConfig;
// Calcular saldos do período selecionado baseado nos saldos diários calculados
let saldoPeriodoTrabalhadoMinutos = 0;
let diasComSaldoPositivo = 0;
let diasComSaldoNegativo = 0;
let diasSemRegistros = 0;
if (sections.registrosPonto && Object.keys(saldosDiariosPorData).length > 0) {
// Somar todos os saldos diários do período
for (const saldo of Object.values(saldosDiariosPorData)) {
saldoPeriodoTrabalhadoMinutos += saldo.trabalhadoMinutos;
// Calcular diferença diária corretamente: trabalhado - esperado
const diferencaDiaria = saldo.trabalhadoMinutos - saldo.esperadoMinutos;
if (diferencaDiaria > 0) {
diasComSaldoPositivo++;
} else if (diferencaDiaria < 0) {
diasComSaldoNegativo++;
}
if (saldo.trabalhadoMinutos === 0 && saldo.esperadoMinutos > 0) {
diasSemRegistros++;
}
}
} else {
// Fallback: calcular a partir dos registros se não tiver saldos diários
const registrosPorDataPeriodo: Record<
string,
Array<{ tipo: string; hora: number; minuto: number }>
> = {};
for (const r of registrosFuncionario) {
const dataKey = r.data;
if (!registrosPorDataPeriodo[dataKey]) {
registrosPorDataPeriodo[dataKey] = [];
}
registrosPorDataPeriodo[dataKey]!.push({
tipo: r.tipo,
hora: r.hora,
minuto: r.minuto
});
}
for (const regs of Object.values(registrosPorDataPeriodo)) {
const saldoDiario = calcularSaldoDiario(regs);
if (saldoDiario) {
saldoPeriodoTrabalhadoMinutos += saldoDiario.saldoMinutos;
}
}
}
// Calcular saldo esperado do período: carga horária diária × número de dias
// SEMPRE calcular diretamente, não somar saldos diários esperados (pode duplicar)
const saldoPeriodoEsperadoMinutos = cargaHorariaDiariaEsperadaMinutos * totalDiasPeriodo;
// Calcular diferença do período corretamente: trabalhado - esperado (para "Saldo do Período Exibido")
const saldoPeriodoDiferencaMinutos =
saldoPeriodoTrabalhadoMinutos - saldoPeriodoEsperadoMinutos;
// Calcular diferença do período (trabalhado - esperado) para exibição na linha "Diferença do Período"
// Negativo quando trabalhado < esperado (vermelho), positivo quando trabalhado > esperado (verde)
const diferencaPeriodoTrabalhadoMenosEsperado =
saldoPeriodoTrabalhadoMinutos - saldoPeriodoEsperadoMinutos;
// Calcular médias diárias
const mediaDiariaTrabalhadaHoras =
totalDiasPeriodo > 0
? Math.floor(saldoPeriodoTrabalhadoMinutos / 60 / totalDiasPeriodo)
: 0;
const mediaDiariaTrabalhadaMinutos =
totalDiasPeriodo > 0
? Math.floor((saldoPeriodoTrabalhadoMinutos / totalDiasPeriodo) % 60)
: 0;
// Calcular média esperada baseada na configuração padrão (não no período)
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);
// Par 1: entrada -> saida_almoco
const minutosPar1Esperado =
horaSaidaAlmoco * 60 + minutoSaidaAlmoco - (horaEntrada * 60 + minutoEntrada);
const minutosPar1EsperadoAjustado =
minutosPar1Esperado < 0 ? minutosPar1Esperado + 24 * 60 : minutosPar1Esperado;
// Par 2: retorno_almoco -> saida
const minutosPar2Esperado =
horaSaida * 60 + minutoSaida - (horaRetornoAlmoco * 60 + minutoRetornoAlmoco);
const minutosPar2EsperadoAjustado =
minutosPar2Esperado < 0 ? minutosPar2Esperado + 24 * 60 : minutosPar2Esperado;
const totalEsperadoDiarioMinutos =
minutosPar1EsperadoAjustado + minutosPar2EsperadoAjustado;
const mediaDiariaEsperadaHoras = Math.floor(totalEsperadoDiarioMinutos / 60);
const mediaDiariaEsperadaMinutos = totalEsperadoDiarioMinutos % 60;
// Formatar valores
// Saldo do Período Exibido: diferença (trabalhado - esperado)
const horasPeriodoDiferenca = Math.floor(Math.abs(saldoPeriodoDiferencaMinutos) / 60);
const minutosPeriodoDiferenca = Math.abs(saldoPeriodoDiferencaMinutos) % 60;
const sinalPeriodoDiferenca = saldoPeriodoDiferencaMinutos >= 0 ? '+' : '-';
const saldoPeriodoDiferencaFormatado = `${sinalPeriodoDiferenca}${horasPeriodoDiferenca}h ${minutosPeriodoDiferenca}min`;
// Diferença do Período: trabalhado - esperado
// Negativo quando trabalhado < esperado (vermelho), positivo quando trabalhado > esperado (verde)
const horasDiferencaPeriodo = Math.floor(
Math.abs(diferencaPeriodoTrabalhadoMenosEsperado) / 60
);
const minutosDiferencaPeriodo = Math.abs(diferencaPeriodoTrabalhadoMenosEsperado) % 60;
const sinalDiferencaPeriodo = diferencaPeriodoTrabalhadoMenosEsperado >= 0 ? '+' : '-';
const diferencaPeriodoFormatado = `${sinalDiferencaPeriodo}${horasDiferencaPeriodo}h ${minutosDiferencaPeriodo}min`;
const horasPeriodoTrabalhado = Math.floor(saldoPeriodoTrabalhadoMinutos / 60);
const minutosPeriodoTrabalhado = saldoPeriodoTrabalhadoMinutos % 60;
const saldoPeriodoTrabalhadoFormatado = `+${horasPeriodoTrabalhado}h ${minutosPeriodoTrabalhado}min`;
const horasPeriodoEsperado = Math.floor(saldoPeriodoEsperadoMinutos / 60);
const minutosPeriodoEsperado = saldoPeriodoEsperadoMinutos % 60;
const saldoPeriodoEsperadoFormatado = `+${horasPeriodoEsperado}h ${minutosPeriodoEsperado}min`;
doc.setFontSize(12);
doc.setFont('helvetica', 'bold');
doc.setTextColor(41, 128, 185);
doc.text('RESUMO DO BANCO DE HORAS', 15, yPosition);
doc.setFont('helvetica', 'normal');
doc.setTextColor(0, 0, 0);
yPosition += 10;
if (bancoHoras) {
const saldoMinutos = bancoHoras.saldoAcumuladoMinutos;
const horas = Math.floor(Math.abs(saldoMinutos) / 60);
const minutos = Math.abs(saldoMinutos) % 60;
const sinal = saldoMinutos >= 0 ? '+' : '-';
const saldoFormatado = `${sinal}${horas}h ${minutos}min`;
// Calcular saldo acumulado de períodos anteriores
// Saldo Anterior = Saldo Total - Saldo do Período Atual
const saldoAnteriorMinutos = saldoMinutos - saldoPeriodoDiferencaMinutos;
const horasAnterior = Math.floor(Math.abs(saldoAnteriorMinutos) / 60);
const minutosAnterior = Math.abs(saldoAnteriorMinutos) % 60;
const sinalAnterior = saldoAnteriorMinutos >= 0 ? '+' : '-';
const saldoAnteriorFormatado = `${sinalAnterior}${horasAnterior}h ${minutosAnterior}min`;
// Calcular resultado final
const resultadoFinalMinutos = saldoAnteriorMinutos + saldoPeriodoDiferencaMinutos;
const horasResultadoFinal = Math.floor(Math.abs(resultadoFinalMinutos) / 60);
const minutosResultadoFinal = Math.abs(resultadoFinalMinutos) % 60;
const sinalResultadoFinal = resultadoFinalMinutos >= 0 ? '+' : '-';
const resultadoFinalFormatado = `${sinalResultadoFinal}${horasResultadoFinal}h ${minutosResultadoFinal}min`;
// Preparar dados da tabela com melhorias
const bancoHorasData: any[][] = [
['Saldo Atual', saldoFormatado],
['Saldo Banco Acumulado de Períodos Anteriores', saldoAnteriorFormatado],
['Saldo do Período Exibido', saldoPeriodoDiferencaFormatado],
['Resultado Final', resultadoFinalFormatado]
];
// Adicionar detalhamento
bancoHorasData.push(['', '']); // Linha separadora
bancoHorasData.push(['Saldo Trabalhado do Período', saldoPeriodoTrabalhadoFormatado]);
bancoHorasData.push(['Saldo Esperado do Período', saldoPeriodoEsperadoFormatado]);
bancoHorasData.push(['Diferença do Período', diferencaPeriodoFormatado]);
// Adicionar estatísticas
bancoHorasData.push(['', '']); // Linha separadora
bancoHorasData.push([
'Média Diária de Horas Trabalhadas',
`+${mediaDiariaTrabalhadaHoras}h ${mediaDiariaTrabalhadaMinutos}min`
]);
bancoHorasData.push([
'Média Diária Esperada',
`+${mediaDiariaEsperadaHoras}h ${mediaDiariaEsperadaMinutos}min`
]);
// Adicionar contagens
bancoHorasData.push(['', '']); // Linha separadora
bancoHorasData.push(['Dias com Saldo Positivo', `${diasComSaldoPositivo} dias`]);
bancoHorasData.push(['Dias com Saldo Negativo', `${diasComSaldoNegativo} dias`]);
bancoHorasData.push(['Dias sem Registros', `${diasSemRegistros} dias`]);
bancoHorasData.push(['Total de Dias do Período', `${totalDiasPeriodo} dias`]);
// Criar tabela no mesmo estilo das outras seções
autoTable(doc, {
startY: yPosition,
head: [['Campo', 'Valor']],
body: bancoHorasData,
theme: 'grid',
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
styles: { fontSize: 9 },
columnStyles: {
0: { fontStyle: 'bold', cellWidth: 80 },
1: { cellWidth: 'auto' }
},
didParseCell: (data) => {
// Ignorar linhas separadoras vazias
if (data.cell.text[0] === '' && data.column.index === 0) {
data.cell.styles.fillColor = [240, 240, 240]; // Cor de fundo cinza claro
return;
}
const campo = data.cell.text[0];
const valor = data.cell.text[1] || '';
// Aplicar cores baseado no valor
if (data.column.index === 1 && valor) {
// Aplicar cor no Saldo Atual (vermelho para negativo, azul para positivo)
if (campo === 'Saldo Atual') {
if (saldoMinutos < 0) {
data.cell.styles.textColor = [200, 0, 0]; // Vermelho para negativo
} else if (saldoMinutos > 0) {
data.cell.styles.textColor = [0, 100, 200]; // Azul para positivo
}
data.cell.styles.fontStyle = 'bold';
}
// Verificar se o valor contém sinal negativo
if (valor.includes('-') && !valor.includes('±')) {
data.cell.styles.textColor = [200, 0, 0]; // Vermelho para negativo
if (
campo === 'Saldo do Período Exibido' ||
campo === 'Diferença do Período' ||
campo === 'Resultado Final'
) {
data.cell.styles.fontStyle = 'bold';
}
} else if (
valor.includes('+') ||
campo.includes('Trabalhado') ||
campo.includes('Esperado')
) {
// Verde para valores positivos ou campos de trabalhado/esperado
if (campo.includes('Diferença do Período')) {
// Diferença do Período: trabalhado - esperado
// Negativo quando trabalhado < esperado (vermelho), positivo quando trabalhado > esperado (verde)
if (diferencaPeriodoTrabalhadoMenosEsperado < 0) {
data.cell.styles.textColor = [200, 0, 0]; // Vermelho quando trabalhado < esperado
} else if (diferencaPeriodoTrabalhadoMenosEsperado > 0) {
data.cell.styles.textColor = [0, 128, 0]; // Verde quando trabalhado > esperado
}
data.cell.styles.fontStyle = 'bold';
} else if (campo.includes('Trabalhado') || campo.includes('Esperado')) {
data.cell.styles.textColor = [0, 128, 0]; // Verde para trabalhado/esperado
}
}
// Destacar campos específicos
if (campo === 'Dias com Saldo Negativo') {
data.cell.styles.textColor = [200, 0, 0];
data.cell.styles.fontStyle = 'bold';
}
if (campo === 'Dias com Saldo Positivo') {
data.cell.styles.textColor = [0, 128, 0];
data.cell.styles.fontStyle = 'bold';
}
if (campo === 'Resultado Final') {
if (resultadoFinalMinutos < 0) {
data.cell.styles.textColor = [200, 0, 0];
} else if (resultadoFinalMinutos > 0) {
data.cell.styles.textColor = [0, 128, 0];
}
data.cell.styles.fontStyle = 'bold';
}
if (campo === 'Saldo do Período Exibido') {
if (saldoPeriodoDiferencaMinutos < 0) {
data.cell.styles.textColor = [200, 0, 0];
} else if (saldoPeriodoDiferencaMinutos > 0) {
data.cell.styles.textColor = [0, 128, 0];
}
data.cell.styles.fontStyle = 'bold';
}
}
}
});
// Calcular posição Y após a tabela
const lastPage = doc.getNumberOfPages();
doc.setPage(lastPage);
const finalY = (doc as { lastAutoTable?: { finalY: number } }).lastAutoTable?.finalY;
if (finalY) {
yPosition = finalY + 10;
} else {
yPosition += bancoHorasData.length * 7 + 10;
}
} else {
// Se não houver banco de horas, criar tabela vazia com mensagem
autoTable(doc, {
startY: yPosition,
head: [['Campo', 'Valor']],
body: [['Banco de horas', 'Não disponível']],
theme: 'grid',
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
styles: { fontSize: 9 }
});
const lastPage = doc.getNumberOfPages();
doc.setPage(lastPage);
const finalY = (doc as { lastAutoTable?: { finalY: number } }).lastAutoTable?.finalY;
if (finalY) {
yPosition = finalY + 10;
} else {
yPosition += 20;
}
}
}
// Alterações pelo Gestor
if (sections.alteracoesGestor && homologacoes.length > 0) {
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('ALTERAÇÕES PELO GESTOR', 15, yPosition);
doc.setFont('helvetica', 'normal');
doc.setTextColor(0, 0, 0);
yPosition += 10;
const homologacoesData = homologacoes
.map((h) => {
// Formatar data de criação usando função centralizada (DD/MM/AAAA)
const dataFormatada = formatarDataDDMMAAAA(h.criadoEm);
if (h.registroId && h.horaAnterior !== undefined) {
return [
dataFormatada,
'Edição de Registro',
h.horaAnterior !== undefined
? `${formatarHoraPonto(h.horaAnterior, h.minutoAnterior || 0)}${formatarHoraPonto(h.horaNova || 0, h.minutoNova || 0)}`
: '-',
h.motivoDescricao || h.motivoTipo || '-',
h.observacoes || '-'
];
} else if (h.tipoAjuste) {
const tipoAjusteLabel =
h.tipoAjuste === 'compensar'
? 'Compensar'
: h.tipoAjuste === 'abonar'
? 'Abonar'
: 'Descontar';
return [
dataFormatada,
`Ajuste: ${tipoAjusteLabel}`,
`${h.periodoDias || 0}d ${h.periodoHoras || 0}h ${h.periodoMinutos || 0}min`,
h.motivoDescricao || h.motivoTipo || '-',
h.observacoes || '-'
];
}
return [];
})
.filter((row) => row.length > 0);
if (homologacoesData.length > 0) {
autoTable(doc, {
startY: yPosition,
head: [['Data', 'Tipo', 'Detalhes', 'Motivo', 'Observações']],
body: homologacoesData,
theme: 'grid',
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
styles: { fontSize: 9 },
columnStyles: {
0: { cellWidth: 30 }, // Data
1: { cellWidth: 40 }, // Tipo
2: {
cellWidth: 50,
cellPadding: { top: 2, bottom: 2, left: 2, right: 2 }
}, // Detalhes - maior largura
3: { cellWidth: 40 }, // Motivo
4: { cellWidth: 'auto' } // Observações
},
didParseCell: (data) => {
// Permitir quebra de linha na coluna Detalhes (índice 2)
if (data.column.index === 2) {
data.cell.styles.overflow = 'linebreak';
data.cell.styles.cellWidth = 50;
}
}
});
const lastPage = doc.getNumberOfPages();
doc.setPage(lastPage);
const finalY = (doc as { lastAutoTable?: { finalY: number } }).lastAutoTable?.finalY;
if (finalY) {
yPosition = finalY + 10;
} else {
yPosition += homologacoesData.length * 7 + 10;
}
}
}
// Dispensas de Registro
if (sections.dispensasRegistro && dispensas.length > 0) {
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) => {
// Formatar data de início e fim usando função centralizada (DD/MM/AAAA)
const dataInicioFormatada = formatarDataDDMMAAAA(d.dataInicio);
const dataFimFormatada = formatarDataDDMMAAAA(d.dataFim);
return [
`${dataInicioFormatada} ${d.horaInicio.toString().padStart(2, '0')}:${d.minutoInicio.toString().padStart(2, '0')}`,
`${dataFimFormatada} ${d.horaFim.toString().padStart(2, '0')}:${d.minutoFim.toString().padStart(2, '0')}`,
d.motivo,
d.isento ? 'Isento (sem expiração)' : 'Temporária'
];
});
autoTable(doc, {
startY: yPosition,
head: [['Início', 'Fim', 'Motivo', 'Tipo']],
body: dispensasData,
theme: 'grid',
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
styles: { fontSize: 9 }
});
const lastPage = doc.getNumberOfPages();
doc.setPage(lastPage);
const finalY = (doc as { lastAutoTable?: { finalY: number } }).lastAutoTable?.finalY;
if (finalY) {
yPosition = finalY + 10;
} else {
yPosition += dispensasData.length * 7 + 10;
}
}
// Rodapé
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' }
);
}
// Salvar
const nomeArquivo = `ficha-ponto-${funcionario.matricula || funcionario.nome}-${dataInicio}-${dataFim}.pdf`;
doc.save(nomeArquivo);
// Fechar modal após gerar PDF
mostrarModalImpressao = false;
funcionarioParaImprimir = '';
toast.success('PDF gerado com sucesso!');
} catch (error) {
console.error('Erro ao gerar PDF:', error);
const errorMessage = error instanceof Error ? error.message : String(error);
toast.error(`Erro ao gerar ficha de ponto: ${errorMessage}`);
}
}
function abrirModalDetalhes(registroId: Id<'registrosPonto'>) {
if (!registroId) {
console.error('Erro: registroId inválido');
return;
}
registroDetalhesId = registroId;
mostrarModalDetalhes = true;
}
function fecharModalDetalhes() {
mostrarModalDetalhes = false;
registroDetalhesId = '';
}
async function imprimirDetalhesRegistro(registroId: Id<'registrosPonto'>) {
try {
// Buscar dados completos do registro
const registro = await client.query(api.pontos.obterRegistro, {
registroId
});
if (!registro) {
alert('Registro não encontrado');
return;
}
const doc = new jsPDF();
// Logo
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 = 30;
const aspectRatio = logoImg.height / logoImg.width;
const logoHeight = logoWidth * aspectRatio;
doc.addImage(logoImg, 'PNG', 15, 10, logoWidth, logoHeight);
yPosition = Math.max(25, 10 + logoHeight / 2);
} catch (err) {
console.warn('Não foi possível carregar a logo:', err);
}
// Cabeçalho com estilo melhorado
doc.setFontSize(18);
doc.setTextColor(41, 128, 185);
doc.setFont('helvetica', 'bold');
doc.text('DETALHES DO REGISTRO DE PONTO', 105, yPosition, {
align: 'center'
});
// Linha decorativa abaixo do título
doc.setDrawColor(41, 128, 185);
doc.setLineWidth(0.5);
doc.line(15, yPosition + 3, 195, yPosition + 3);
yPosition += 15;
// Informações do Funcionário em tabela
const funcionarioData: any[][] = [];
if (registro.funcionario) {
if (registro.funcionario.matricula) {
funcionarioData.push(['Matrícula', registro.funcionario.matricula]);
}
funcionarioData.push(['Nome', registro.funcionario.nome]);
if (registro.funcionario.descricaoCargo) {
funcionarioData.push(['Cargo/Função', registro.funcionario.descricaoCargo]);
}
}
if (funcionarioData.length > 0) {
doc.setFontSize(12);
doc.setTextColor(41, 128, 185);
doc.setFont('helvetica', 'bold');
doc.text('DADOS DO FUNCIONÁRIO', 15, yPosition);
yPosition += 8;
autoTable(doc, {
startY: yPosition,
head: [['Campo', 'Valor']],
body: funcionarioData,
theme: 'striped',
headStyles: {
fillColor: [41, 128, 185],
textColor: [255, 255, 255],
fontStyle: 'bold',
fontSize: 10
},
bodyStyles: {
fontSize: 10,
textColor: [0, 0, 0]
},
columnStyles: {
0: { cellWidth: 50, fontStyle: 'bold' },
1: { cellWidth: 140 }
},
margin: { left: 15, right: 15 },
styles: { cellPadding: 4 }
});
type JsPDFWithAutoTable = jsPDF & {
lastAutoTable?: { finalY: number };
};
const finalY = (doc as JsPDFWithAutoTable).lastAutoTable?.finalY ?? yPosition + 10;
yPosition = finalY + 10;
}
// Informações do Registro em tabela
const config = await client.query(api.configuracaoPonto.obterConfiguracao, {});
const tipoLabel = config
? getTipoRegistroLabel(registro.tipo, {
nomeEntrada: config.nomeEntrada,
nomeSaidaAlmoco: config.nomeSaidaAlmoco,
nomeRetornoAlmoco: config.nomeRetornoAlmoco,
nomeSaida: config.nomeSaida
})
: getTipoRegistroLabel(registro.tipo);
const dataHora = `${registro.data} ${registro.hora.toString().padStart(2, '0')}:${registro.minuto.toString().padStart(2, '0')}:${registro.segundo.toString().padStart(2, '0')}`;
const registroData: any[][] = [
['Tipo', tipoLabel],
['Data e Hora', dataHora],
['Status', registro.dentroDoPrazo ? 'Dentro do Prazo' : 'Fora do Prazo'],
['Tolerância', `${registro.toleranciaMinutos} minutos`],
['Sincronizado', registro.sincronizadoComServidor ? 'Sim (Servidor)' : 'Não (PC Local)']
];
if (registro.justificativa) {
registroData.push(['Justificativa', registro.justificativa]);
}
// Verificar se precisa de nova página
if (yPosition > 200) {
doc.addPage();
yPosition = 20;
}
doc.setFontSize(12);
doc.setTextColor(41, 128, 185);
doc.setFont('helvetica', 'bold');
doc.text('DADOS DO REGISTRO', 15, yPosition);
yPosition += 8;
autoTable(doc, {
startY: yPosition,
head: [['Campo', 'Valor']],
body: registroData,
theme: 'striped',
headStyles: {
fillColor: [41, 128, 185],
textColor: [255, 255, 255],
fontStyle: 'bold',
fontSize: 10
},
bodyStyles: {
fontSize: 10,
textColor: [0, 0, 0]
},
columnStyles: {
0: { cellWidth: 50, fontStyle: 'bold' },
1: { cellWidth: 140 }
},
margin: { left: 15, right: 15 },
styles: { cellPadding: 4 },
didParseCell: (data: any) => {
if (data.section === 'body' && data.column.index === 1) {
// Aplicar cor verde para "Dentro do Prazo" e vermelho para "Fora do Prazo"
if (data.cell.text[0] === 'Dentro do Prazo') {
data.cell.styles.textColor = [0, 128, 0];
data.cell.styles.fontStyle = 'bold';
} else if (data.cell.text[0] === 'Fora do Prazo') {
data.cell.styles.textColor = [200, 0, 0];
data.cell.styles.fontStyle = 'bold';
}
}
}
});
type JsPDFWithAutoTable2 = jsPDF & {
lastAutoTable?: { finalY: number };
};
const finalYRegistro = (doc as JsPDFWithAutoTable2).lastAutoTable?.finalY ?? yPosition + 10;
yPosition = finalYRegistro + 10;
// Localização em tabela
if (registro.latitude && registro.longitude) {
// Verificar se precisa de nova página
if (yPosition > 200) {
doc.addPage();
yPosition = 20;
}
const localizacaoData: any[][] = [
['Latitude', registro.latitude.toFixed(6)],
['Longitude', registro.longitude.toFixed(6)]
];
if (registro.precisao) {
localizacaoData.push(['Precisão', `${registro.precisao.toFixed(2)} metros`]);
}
if (registro.endereco) {
localizacaoData.push(['Endereço', registro.endereco]);
}
if (registro.cidade) {
localizacaoData.push(['Cidade', registro.cidade]);
}
if (registro.estado) {
localizacaoData.push(['Estado', registro.estado]);
}
if (registro.pais) {
localizacaoData.push(['País', registro.pais]);
}
if (registro.timezone) {
localizacaoData.push(['Fuso Horário', registro.timezone]);
}
doc.setFontSize(12);
doc.setTextColor(41, 128, 185);
doc.setFont('helvetica', 'bold');
doc.text('LOCALIZAÇÃO', 15, yPosition);
yPosition += 8;
autoTable(doc, {
startY: yPosition,
head: [['Campo', 'Valor']],
body: localizacaoData,
theme: 'striped',
headStyles: {
fillColor: [41, 128, 185],
textColor: [255, 255, 255],
fontStyle: 'bold',
fontSize: 10
},
bodyStyles: {
fontSize: 10,
textColor: [0, 0, 0]
},
columnStyles: {
0: { cellWidth: 50, fontStyle: 'bold' },
1: { cellWidth: 140 }
},
margin: { left: 15, right: 15 },
styles: { cellPadding: 4 }
});
type JsPDFWithAutoTable3 = jsPDF & {
lastAutoTable?: { finalY: number };
};
const finalYLocalizacao =
(doc as JsPDFWithAutoTable3).lastAutoTable?.finalY ?? yPosition + 10;
yPosition = finalYLocalizacao + 10;
}
// Validação de GPS e Anti-Spoofing
if (registro.latitude && registro.longitude) {
// Verificar se precisa de nova página
if (yPosition > 200) {
doc.addPage();
yPosition = 20;
}
doc.setFontSize(12);
doc.setTextColor(41, 128, 185);
doc.setFont('helvetica', 'bold');
doc.text('VALIDAÇÃO DE LOCALIZAÇÃO GPS', 15, yPosition);
yPosition += 10;
// Dados do GPS em tabela
const gpsData: any[][] = [];
if (registro.precisao !== null && registro.precisao !== undefined) {
gpsData.push(['Precisão', `${registro.precisao.toFixed(2)} metros`]);
}
if (gpsData.length > 0) {
autoTable(doc, {
startY: yPosition,
head: [['Campo', 'Valor']],
body: gpsData,
theme: 'striped',
headStyles: {
fillColor: [41, 128, 185],
textColor: [255, 255, 255],
fontStyle: 'bold',
fontSize: 10
},
bodyStyles: {
fontSize: 10,
textColor: [0, 0, 0]
},
columnStyles: {
0: { cellWidth: 50, fontStyle: 'bold' },
1: { cellWidth: 140 }
},
margin: { left: 15, right: 15 },
styles: { cellPadding: 4 }
});
type JsPDFWithAutoTable4 = jsPDF & {
lastAutoTable?: { finalY: number };
};
const finalYGps = (doc as JsPDFWithAutoTable4).lastAutoTable?.finalY ?? yPosition + 10;
yPosition = finalYGps + 5;
}
// Confiabilidade em tabela
const confiabilidadeData: any[][] = [];
if (registro.confiabilidadeGPS !== null && registro.confiabilidadeGPS !== undefined) {
const confiabilidadePercent = (registro.confiabilidadeGPS * 100).toFixed(1);
confiabilidadeData.push(['Confiabilidade GPS (Frontend)', `${confiabilidadePercent}%`]);
}
if (
registro.scoreConfiancaBackend !== null &&
registro.scoreConfiancaBackend !== undefined
) {
const scorePercent = (registro.scoreConfiancaBackend * 100).toFixed(1);
confiabilidadeData.push(['Score de Confiança (Backend)', `${scorePercent}%`]);
}
if (confiabilidadeData.length > 0) {
doc.setFontSize(11);
doc.setTextColor(0, 0, 0);
doc.setFont('helvetica', 'bold');
doc.text('Confiabilidade:', 15, yPosition);
yPosition += 8;
autoTable(doc, {
startY: yPosition,
head: [['Campo', 'Valor']],
body: confiabilidadeData,
theme: 'striped',
headStyles: {
fillColor: [60, 60, 60],
textColor: [255, 255, 255],
fontStyle: 'bold',
fontSize: 10
},
bodyStyles: {
fontSize: 10
},
columnStyles: {
0: { cellWidth: 80, fontStyle: 'bold' },
1: { cellWidth: 110 }
},
margin: { left: 15, right: 15 },
styles: { cellPadding: 4 },
didParseCell: (data: any) => {
if (data.section === 'body' && data.column.index === 1) {
// Aplicar cores baseadas nos valores
const valorTexto = data.cell.text[0];
const valorNum = parseFloat(valorTexto.replace('%', ''));
if (!isNaN(valorNum)) {
if (valorNum >= 70) {
data.cell.styles.textColor = [0, 128, 0];
data.cell.styles.fontStyle = 'bold';
} else if (valorNum >= 40) {
data.cell.styles.textColor = [255, 165, 0];
data.cell.styles.fontStyle = 'bold';
} else {
data.cell.styles.textColor = [255, 0, 0];
data.cell.styles.fontStyle = 'bold';
}
}
}
}
});
type JsPDFWithAutoTable5 = jsPDF & {
lastAutoTable?: { finalY: number };
};
const finalYConf = (doc as JsPDFWithAutoTable5).lastAutoTable?.finalY ?? yPosition + 10;
yPosition = finalYConf + 5;
}
// Status de Validação em destaque
if (registro.suspeitaSpoofing !== null && registro.suspeitaSpoofing !== undefined) {
const statusData: any[][] = [];
if (registro.suspeitaSpoofing) {
statusData.push(['Status', '⚠️ MARCAÇÃO SUSPEITA DETECTADA']);
if (registro.motivoSuspeita) {
statusData.push(['Motivo', registro.motivoSuspeita]);
}
} else {
statusData.push(['Status', '✓ Localização validada com sucesso']);
}
if (statusData.length > 0) {
doc.setFontSize(11);
doc.setTextColor(0, 0, 0);
doc.setFont('helvetica', 'bold');
doc.text('Status de Validação:', 15, yPosition);
yPosition += 8;
autoTable(doc, {
startY: yPosition,
head: [['Campo', 'Valor']],
body: statusData,
theme: 'striped',
headStyles: {
fillColor: registro.suspeitaSpoofing ? [200, 0, 0] : [0, 128, 0],
textColor: [255, 255, 255],
fontStyle: 'bold',
fontSize: 10
},
bodyStyles: {
fontSize: 10
},
columnStyles: {
0: { cellWidth: 50, fontStyle: 'bold' },
1: { cellWidth: 140 }
},
margin: { left: 15, right: 15 },
styles: { cellPadding: 4 },
didParseCell: (data: any) => {
if (data.section === 'body' && data.column.index === 1) {
if (registro.suspeitaSpoofing) {
data.cell.styles.textColor = [200, 0, 0];
data.cell.styles.fontStyle = 'bold';
} else {
data.cell.styles.textColor = [0, 128, 0];
data.cell.styles.fontStyle = 'bold';
}
}
}
});
type JsPDFWithAutoTable6 = jsPDF & {
lastAutoTable?: { finalY: number };
};
const finalYStatus =
(doc as JsPDFWithAutoTable6).lastAutoTable?.finalY ?? yPosition + 10;
yPosition = finalYStatus + 5;
}
}
// Avisos de Validação em tabela
if (registro.avisosValidacao && registro.avisosValidacao.length > 0) {
const avisosData = registro.avisosValidacao.map((aviso: string) => ['', aviso]);
doc.setFontSize(11);
doc.setTextColor(0, 0, 0);
doc.setFont('helvetica', 'bold');
doc.text('Avisos de Validação:', 15, yPosition);
yPosition += 8;
autoTable(doc, {
startY: yPosition,
head: [['', 'Aviso']],
body: avisosData,
theme: 'striped',
headStyles: {
fillColor: [255, 165, 0],
textColor: [255, 255, 255],
fontStyle: 'bold',
fontSize: 10
},
bodyStyles: {
fontSize: 10,
textColor: [0, 0, 0]
},
columnStyles: {
0: { cellWidth: 10 },
1: { cellWidth: 180 }
},
margin: { left: 15, right: 15 },
styles: { cellPadding: 4 }
});
type JsPDFWithAutoTable7 = jsPDF & {
lastAutoTable?: { finalY: number };
};
const finalYAvisos = (doc as JsPDFWithAutoTable7).lastAutoTable?.finalY ?? yPosition + 10;
yPosition = finalYAvisos + 5;
}
// Análise de Propriedades GPS em tabela
const propriedadesData: any[][] = [];
let propriedadesGPS = 0;
const propriedadesTotais = 5;
if (
registro.altitude !== null &&
registro.altitude !== undefined &&
registro.altitude !== 0
) {
propriedadesData.push(['Altitude', '✓ Disponível']);
propriedadesGPS++;
} else {
propriedadesData.push(['Altitude', '✗ Não disponível']);
}
if (
registro.altitudeAccuracy !== null &&
registro.altitudeAccuracy !== undefined &&
registro.altitudeAccuracy > 0
) {
propriedadesData.push(['Precisão de Altitude', '✓ Disponível']);
propriedadesGPS++;
} else {
propriedadesData.push(['Precisão de Altitude', '✗ Não disponível']);
}
if (
registro.heading !== null &&
registro.heading !== undefined &&
!isNaN(registro.heading)
) {
propriedadesData.push(['Direção (Heading)', '✓ Disponível']);
propriedadesGPS++;
} else {
propriedadesData.push(['Direção (Heading)', '✗ Não disponível']);
}
if (registro.speed !== null && registro.speed !== undefined && !isNaN(registro.speed)) {
propriedadesData.push(['Velocidade', '✓ Disponível']);
propriedadesGPS++;
} else {
propriedadesData.push(['Velocidade', '✗ Não disponível']);
}
if (
registro.precisao !== null &&
registro.precisao !== undefined &&
registro.precisao < 20
) {
propriedadesData.push(['Precisão GPS', '✓ Alta precisão (< 20m)']);
propriedadesGPS++;
} else if (
registro.precisao !== null &&
registro.precisao !== undefined &&
registro.precisao >= 20 &&
registro.precisao < 100
) {
propriedadesData.push(['Precisão GPS', '⚠ Precisão média (20-100m)']);
propriedadesGPS += 0.5;
} else {
propriedadesData.push(['Precisão GPS', '✗ Baixa precisão (> 100m)']);
}
// Indicador de qualidade GPS
const qualidadeGPS = (propriedadesGPS / propriedadesTotais) * 100;
const qualidadeTexto =
qualidadeGPS >= 80
? 'Alta qualidade (GPS real)'
: qualidadeGPS >= 50
? 'Qualidade média'
: 'Baixa qualidade (possível spoofing)';
const qualidadeCor =
qualidadeGPS >= 80 ? [0, 128, 0] : qualidadeGPS >= 50 ? [255, 165, 0] : [255, 0, 0];
propriedadesData.push(['Qualidade GPS', `${qualidadeTexto} (${qualidadeGPS.toFixed(0)}%)`]);
doc.setFontSize(11);
doc.setTextColor(0, 0, 0);
doc.setFont('helvetica', 'bold');
doc.text('Análise de Propriedades GPS:', 15, yPosition);
yPosition += 8;
autoTable(doc, {
startY: yPosition,
head: [['Propriedade', 'Status']],
body: propriedadesData,
theme: 'striped',
headStyles: {
fillColor: [60, 60, 60],
textColor: [255, 255, 255],
fontStyle: 'bold',
fontSize: 10
},
bodyStyles: {
fontSize: 10
},
columnStyles: {
0: { cellWidth: 80, fontStyle: 'bold' },
1: { cellWidth: 110 }
},
margin: { left: 15, right: 15 },
styles: { cellPadding: 4 },
didParseCell: (data: any) => {
if (data.section === 'body' && data.column.index === 1) {
const texto = data.cell.text[0];
if (texto.includes('✓')) {
data.cell.styles.textColor = [0, 128, 0];
data.cell.styles.fontStyle = 'bold';
} else if (texto.includes('✗')) {
data.cell.styles.textColor = [200, 0, 0];
} else if (texto.includes('⚠')) {
data.cell.styles.textColor = [255, 165, 0];
data.cell.styles.fontStyle = 'bold';
}
// Última linha (Qualidade GPS)
if (data.row.index === propriedadesData.length - 1) {
data.cell.styles.textColor = qualidadeCor;
data.cell.styles.fontStyle = 'bold';
}
}
}
});
type JsPDFWithAutoTable8 = jsPDF & {
lastAutoTable?: { finalY: number };
};
const finalYPropriedades =
(doc as JsPDFWithAutoTable8).lastAutoTable?.finalY ?? yPosition + 10;
yPosition = finalYPropriedades + 10;
}
// Validação de Geofencing (Localização Permitida)
if (registro.latitude && registro.longitude) {
// Verificar se precisa de nova página
if (yPosition > 200) {
doc.addPage();
yPosition = 20;
}
doc.setFontSize(12);
doc.setTextColor(41, 128, 185);
doc.setFont('helvetica', 'bold');
doc.text('VALIDAÇÃO DE LOCALIZAÇÃO PERMITIDA', 15, yPosition);
yPosition += 10;
if (registro.enderecoMarcacaoEsperado || registro.dentroRaioPermitido !== undefined) {
// Buscar dados do endereço esperado se houver ID
let enderecoEsperadoNome = 'Não configurado';
let enderecoEsperadoEndereco = 'Não configurado';
let enderecoEsperadoLatitude: number | null = null;
let enderecoEsperadoLongitude: number | null = null;
if (registro.enderecoMarcacaoEsperado) {
try {
const enderecoEsperado = await client.query(api.enderecosMarcacao.obterEndereco, {
enderecoId: registro.enderecoMarcacaoEsperado
});
if (enderecoEsperado) {
enderecoEsperadoNome = enderecoEsperado.nome;
enderecoEsperadoEndereco = `${enderecoEsperado.endereco}, ${enderecoEsperado.cidade}/${enderecoEsperado.estado}`;
enderecoEsperadoLatitude = enderecoEsperado.latitude;
enderecoEsperadoLongitude = enderecoEsperado.longitude;
}
} catch (error) {
console.warn('Erro ao buscar endereço esperado:', error);
}
}
const geofencingData: any[][] = [
['Endereço Esperado', enderecoEsperadoNome],
['Localização', enderecoEsperadoEndereco]
];
if (enderecoEsperadoLatitude !== null && enderecoEsperadoLongitude !== null) {
geofencingData.push([
'Coordenadas Esperadas',
`${enderecoEsperadoLatitude.toFixed(6)}, ${enderecoEsperadoLongitude.toFixed(6)}`
]);
}
geofencingData.push([
'Coordenadas do Registro',
`${registro.latitude.toFixed(6)}, ${registro.longitude.toFixed(6)}`
]);
if (
registro.distanciaEnderecoEsperado !== null &&
registro.distanciaEnderecoEsperado !== undefined
) {
const distanciaKm = (registro.distanciaEnderecoEsperado / 1000).toFixed(2);
const distanciaMetros = registro.distanciaEnderecoEsperado.toFixed(0);
const distanciaTexto =
registro.distanciaEnderecoEsperado >= 1000
? `${distanciaKm} km (${distanciaMetros} metros)`
: `${distanciaMetros} metros`;
geofencingData.push(['Distância', distanciaTexto]);
}
if (registro.raioToleranciaUsado !== null && registro.raioToleranciaUsado !== undefined) {
const raioKm = (registro.raioToleranciaUsado / 1000).toFixed(2);
const raioMetros = registro.raioToleranciaUsado.toFixed(0);
const raioTexto =
registro.raioToleranciaUsado >= 1000
? `${raioKm} km (${raioMetros} metros)`
: `${raioMetros} metros`;
geofencingData.push(['Raio Permitido', raioTexto]);
} else {
geofencingData.push(['Raio Permitido', 'Não configurado']);
}
// Status da validação
let statusTexto = 'Não validado';
if (registro.dentroRaioPermitido === true) {
statusTexto = '✓ DENTRO DO RAIO PERMITIDO';
} else if (registro.dentroRaioPermitido === false) {
statusTexto = '⚠️ FORA DO RAIO PERMITIDO';
if (
registro.distanciaEnderecoEsperado !== null &&
registro.distanciaEnderecoEsperado !== undefined &&
registro.raioToleranciaUsado !== null &&
registro.raioToleranciaUsado !== undefined
) {
const distanciaExcedente =
registro.distanciaEnderecoEsperado - registro.raioToleranciaUsado;
const distanciaExcedenteKm = (distanciaExcedente / 1000).toFixed(2);
const distanciaExcedenteMetros = distanciaExcedente.toFixed(0);
const excedenteTexto =
distanciaExcedente >= 1000
? `${distanciaExcedenteKm} km além do permitido`
: `${distanciaExcedenteMetros} metros além do permitido`;
geofencingData.push(['Distância Excedente', excedenteTexto]);
}
}
geofencingData.push(['Status', statusTexto]);
autoTable(doc, {
startY: yPosition,
head: [['Campo', 'Valor']],
body: geofencingData,
theme: 'striped',
headStyles: {
fillColor: [41, 128, 185],
textColor: [255, 255, 255],
fontStyle: 'bold',
fontSize: 10
},
bodyStyles: {
fontSize: 10
},
columnStyles: {
0: { cellWidth: 60, fontStyle: 'bold' },
1: { cellWidth: 130 }
},
margin: { left: 15, right: 15 },
styles: { cellPadding: 4 },
didParseCell: (data: any) => {
if (data.section === 'body' && data.column.index === 1) {
const texto = data.cell.text[0];
if (texto.includes('✓ DENTRO')) {
data.cell.styles.textColor = [0, 128, 0];
data.cell.styles.fontStyle = 'bold';
} else if (texto.includes('⚠️ FORA')) {
data.cell.styles.textColor = [200, 0, 0];
data.cell.styles.fontStyle = 'bold';
}
}
}
});
type JsPDFWithAutoTable9 = jsPDF & {
lastAutoTable?: { finalY: number };
};
const finalYGeofencing =
(doc as JsPDFWithAutoTable9).lastAutoTable?.finalY ?? yPosition + 10;
yPosition = finalYGeofencing + 5;
// Observação se fora do raio
if (registro.dentroRaioPermitido === false) {
doc.setFontSize(9);
doc.setTextColor(100, 100, 100);
const observacaoLines = doc.splitTextToSize(
'O registro foi realizado fora da área permitida de marcação de ponto. Verifique se o funcionário possui autorização para trabalho remoto ou deslocamento.',
170
);
doc.text(observacaoLines, 20, yPosition);
yPosition += observacaoLines.length * 4 + 5;
doc.setFontSize(10);
doc.setTextColor(0, 0, 0);
}
yPosition += 5;
} else {
doc.setFontSize(10);
doc.setTextColor(100, 100, 100);
doc.text(
'Validação de localização permitida não configurada para este registro.',
15,
yPosition
);
yPosition += 8;
doc.setTextColor(0, 0, 0);
}
}
// Dados Técnicos
// Verificar se precisa de nova página
if (yPosition > 200) {
doc.addPage();
yPosition = 20;
}
doc.setFontSize(12);
doc.setTextColor(41, 128, 185);
doc.setFont('helvetica', 'bold');
doc.text('DADOS TÉCNICOS', 15, yPosition);
yPosition += 10;
// Consolidar todos os dados técnicos em uma única tabela
const dadosTecnicosData: any[][] = [];
// Informações de Rede
if (registro.ipAddress || registro.ipPublico || registro.ipLocal) {
if (registro.ipAddress) {
dadosTecnicosData.push(['IP', registro.ipAddress]);
}
if (registro.ipPublico) {
dadosTecnicosData.push(['IP Público', registro.ipPublico]);
}
if (registro.ipLocal) {
dadosTecnicosData.push(['IP Local', registro.ipLocal]);
}
}
// Informações do Navegador
if (registro.browser || registro.userAgent) {
if (registro.browser) {
dadosTecnicosData.push([
'Navegador',
`${registro.browser}${registro.browserVersion ? ` ${registro.browserVersion}` : ''}`
]);
}
if (registro.engine) {
dadosTecnicosData.push(['Engine', registro.engine]);
}
if (registro.userAgent) {
dadosTecnicosData.push(['User Agent', registro.userAgent]);
}
}
// Informações do Sistema
if (registro.sistemaOperacional || registro.arquitetura) {
if (registro.sistemaOperacional) {
dadosTecnicosData.push([
'Sistema Operacional',
`${registro.sistemaOperacional}${registro.osVersion ? ` ${registro.osVersion}` : ''}`
]);
}
if (registro.arquitetura) {
dadosTecnicosData.push(['Arquitetura', registro.arquitetura]);
}
if (registro.plataforma) {
dadosTecnicosData.push(['Plataforma', registro.plataforma]);
}
}
// Informações do Dispositivo
if (registro.deviceType || registro.screenResolution) {
if (registro.deviceType) {
dadosTecnicosData.push(['Tipo de Dispositivo', registro.deviceType]);
}
if (registro.deviceModel) {
dadosTecnicosData.push(['Modelo', registro.deviceModel]);
}
if (registro.screenResolution) {
dadosTecnicosData.push(['Resolução', registro.screenResolution]);
}
if (registro.coresTela) {
dadosTecnicosData.push(['Cores da Tela', registro.coresTela]);
}
if (registro.isMobile || registro.isTablet || registro.isDesktop) {
const tipoDispositivo = registro.isMobile
? 'Mobile'
: registro.isTablet
? 'Tablet'
: 'Desktop';
dadosTecnicosData.push(['Categoria', tipoDispositivo]);
}
if (registro.idioma) {
dadosTecnicosData.push(['Idioma', registro.idioma]);
}
if (registro.connectionType) {
dadosTecnicosData.push(['Tipo de Conexão', registro.connectionType]);
}
if (registro.memoryInfo) {
dadosTecnicosData.push(['Memória', registro.memoryInfo]);
}
}
if (dadosTecnicosData.length > 0) {
autoTable(doc, {
startY: yPosition,
head: [['Campo', 'Valor']],
body: dadosTecnicosData,
theme: 'striped',
headStyles: {
fillColor: [60, 60, 60],
textColor: [255, 255, 255],
fontStyle: 'bold',
fontSize: 10
},
bodyStyles: {
fontSize: 9,
textColor: [0, 0, 0]
},
columnStyles: {
0: { cellWidth: 60, fontStyle: 'bold' },
1: { cellWidth: 130 }
},
margin: { left: 15, right: 15 },
styles: { cellPadding: 3 }
});
type JsPDFWithAutoTable10 = jsPDF & {
lastAutoTable?: { finalY: number };
};
const finalYTecnicos =
(doc as JsPDFWithAutoTable10).lastAutoTable?.finalY ?? yPosition + 10;
yPosition = finalYTecnicos + 10;
}
// Imagem capturada (se disponível)
if (registro.imagemUrl) {
yPosition += 10;
// Verificar se precisa de nova página
if (yPosition > 200) {
doc.addPage();
yPosition = 20;
}
doc.setFont('helvetica', 'bold');
doc.text('FOTO CAPTURADA', 105, yPosition, { align: 'center' });
doc.setFont('helvetica', 'normal');
yPosition += 10;
try {
// Carregar imagem usando fetch para evitar problemas de CORS
const response = await fetch(registro.imagemUrl);
if (!response.ok) {
throw new Error('Erro ao carregar imagem');
}
const blob = await response.blob();
const reader = new FileReader();
// Converter blob para base64
const base64 = await new Promise<string>((resolve, reject) => {
reader.onloadend = () => {
if (typeof reader.result === 'string') {
resolve(reader.result);
} else {
reject(new Error('Erro ao converter imagem'));
}
};
reader.onerror = () => reject(new Error('Erro ao ler imagem'));
reader.readAsDataURL(blob);
});
// Criar elemento de imagem para obter dimensões
const img = new Image();
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = () => reject(new Error('Erro ao processar imagem'));
img.src = base64;
setTimeout(() => reject(new Error('Timeout ao processar imagem')), 10000);
});
// Calcular dimensões para caber na página (largura máxima 80mm, manter proporção)
const maxWidth = 80;
const maxHeight = 60;
let imgWidth = img.width;
let imgHeight = img.height;
const aspectRatio = imgWidth / imgHeight;
if (imgWidth > maxWidth || imgHeight > maxHeight) {
if (aspectRatio > 1) {
// Imagem horizontal
imgWidth = maxWidth;
imgHeight = maxWidth / aspectRatio;
} else {
// Imagem vertical
imgHeight = maxHeight;
imgWidth = maxHeight * aspectRatio;
}
}
// Centralizar imagem
const xPosition = (doc.internal.pageSize.getWidth() - imgWidth) / 2;
// Verificar se cabe na página atual
if (yPosition + imgHeight > doc.internal.pageSize.getHeight() - 20) {
doc.addPage();
yPosition = 20;
}
// Adicionar imagem ao PDF usando base64
doc.addImage(base64, 'JPEG', xPosition, yPosition, imgWidth, imgHeight);
yPosition += imgHeight + 10;
} catch (error) {
console.warn('Erro ao adicionar imagem ao PDF:', error);
doc.setFontSize(10);
doc.text('Foto não disponível para impressão', 105, yPosition, {
align: 'center'
});
yPosition += 6;
}
}
// Rodapé melhorado
const pageCount = doc.getNumberOfPages();
for (let i = 1; i <= pageCount; i++) {
doc.setPage(i);
// Linha decorativa no rodapé
doc.setDrawColor(200, 200, 200);
doc.setLineWidth(0.3);
doc.line(
15,
doc.internal.pageSize.getHeight() - 20,
195,
doc.internal.pageSize.getHeight() - 20
);
// Texto do rodapé
doc.setFontSize(8);
doc.setTextColor(100, 100, 100);
doc.setFont('helvetica', 'normal');
const dataGeracao = new Date().toLocaleDateString('pt-BR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
doc.text(
`SGSE - Sistema de Gerenciamento de Secretaria de Esportes`,
15,
doc.internal.pageSize.getHeight() - 12,
{ align: 'left' }
);
doc.text(
`Gerado em: ${dataGeracao} | Página ${i} de ${pageCount}`,
195,
doc.internal.pageSize.getHeight() - 12,
{ align: 'right' }
);
}
// Salvar
const nomeArquivo = `detalhes-ponto-${registro.data}-${registro.hora.toString().padStart(2, '0')}${registro.minuto.toString().padStart(2, '0')}.pdf`;
doc.save(nomeArquivo);
} catch (error) {
console.error('Erro ao gerar PDF detalhado:', error);
alert('Erro ao gerar relatório detalhado. Tente novamente.');
}
}
</script>
<div class="container mx-auto max-w-7xl px-4 py-6">
<!-- Header -->
<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>
<!-- Cards de Estatísticas -->
{#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}
<!-- Gráfico de Estatísticas -->
<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 estatisticasQuery === undefined || estatisticasQuery?.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 estatisticasQuery?.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">
{estatisticasQuery.error?.message ||
String(estatisticasQuery.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>
<!-- Filtros -->
<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">
<div class="flex items-center gap-3">
<div class="bg-secondary/10 rounded-lg p-2">
<Filter class="text-secondary h-5 w-5" strokeWidth={2.5} />
</div>
<h2 class="card-title mb-0 text-2xl">Filtros de Busca</h2>
</div>
<div class="flex items-center gap-3">
<button
type="button"
class="btn btn-outline btn-secondary gap-2 shadow-md transition-all hover:shadow-lg"
onclick={limparFiltros}
>
<Filter class="h-4 w-4" />
Limpar Filtros
</button>
<button
type="button"
class="btn btn-primary gap-2 shadow-md transition-all hover:shadow-lg"
onclick={exportarCSV}
disabled={registrosFiltrados.length === 0}
>
<Download class="h-4 w-4" />
Exportar CSV
</button>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-5">
<div class="form-control">
<label class="label" for="data-inicio">
<span class="label-text font-semibold">Data Início</span>
</label>
<input
id="data-inicio"
type="date"
bind:value={dataInicio}
class="input input-bordered input-primary focus:input-primary focus:ring-primary/20 focus:ring-2"
/>
</div>
<div class="form-control">
<label class="label" for="data-fim">
<span class="label-text font-semibold">Data Fim</span>
</label>
<input
id="data-fim"
type="date"
bind:value={dataFim}
class="input input-bordered input-primary focus:input-primary focus:ring-primary/20 focus:ring-2"
/>
</div>
<div class="form-control">
<label class="label" for="funcionario">
<span class="label-text font-semibold">Funcionário</span>
</label>
<select
id="funcionario"
bind:value={funcionarioIdFiltro}
class="select select-bordered select-primary focus:select-primary focus:ring-primary/20 focus:ring-2"
>
<option value="">Todos os funcionários</option>
{#each funcionarios as funcionario}
<option value={funcionario._id}>{funcionario.nome}</option>
{/each}
</select>
</div>
<div class="form-control">
<label class="label" for="status">
<span class="label-text font-semibold">Status</span>
</label>
<select
id="status"
bind:value={statusFiltro}
class="select select-bordered select-primary focus:select-primary focus:ring-primary/20 focus:ring-2"
>
<option value="todos">Todos</option>
<option value="dentro">Dentro do Prazo</option>
<option value="fora">Fora do Prazo</option>
</select>
</div>
<div class="form-control">
<label class="label" for="localizacao">
<span class="label-text font-semibold">Localização</span>
</label>
<select
id="localizacao"
bind:value={localizacaoFiltro}
class="select select-bordered select-primary focus:select-primary focus:ring-primary/20 focus:ring-2"
>
<option value="todos">Todas</option>
<option value="dentro">Dentro do Raio</option>
<option value="fora">Fora do Raio</option>
</select>
</div>
</div>
<!-- Indicador de registros filtrados -->
{#if statusFiltro !== 'todos' || localizacaoFiltro !== 'todos'}
<div class="bg-info/10 border-info/20 mt-4 rounded-lg border p-3">
<p class="text-base-content/80 text-sm">
<strong>{registrosFiltrados.length}</strong> registro(s) encontrado(s) com os filtros
aplicados
{#if registros.length !== registrosFiltrados.length}
<span class="text-base-content/60 text-xs">
(de {registros.length} total)
</span>
{/if}
</p>
</div>
{/if}
</div>
</div>
<!-- Lista de Registros -->
<div class="card bg-base-100/90 border-base-300 border shadow-xl backdrop-blur-sm">
<div class="card-body">
<div class="mb-6 flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
<div class="flex items-center gap-3">
<div class="bg-primary/10 rounded-lg p-2">
<Clock class="text-primary h-6 w-6" strokeWidth={2.5} />
</div>
<h2 class="card-title mb-0 text-2xl">Registros de Ponto</h2>
</div>
<!-- Exibição dos Filtros Selecionados -->
{#if funcionarioIdFiltro || dataInicio || dataFim}
<div class="flex flex-wrap items-center gap-2">
{#if funcionarioIdFiltro && funcionarioSelecionadoNome}
<div class="badge badge-primary badge-lg gap-2 px-4 py-3">
<Users class="h-4 w-4" />
{funcionarioSelecionadoNome}
</div>
{/if}
{#if dataInicio}
<div class="badge badge-info badge-lg gap-2 px-4 py-3">
<Clock class="h-4 w-4" />
De: {formatarDataDDMMAAAA(dataInicio)}
</div>
{/if}
{#if dataFim}
<div class="badge badge-info badge-lg gap-2 px-4 py-3">
<Clock class="h-4 w-4" />
Até: {formatarDataDDMMAAAA(dataFim)}
</div>
{/if}
</div>
{/if}
</div>
{#if registrosQuery === undefined || registrosQuery?.isLoading}
<div
class="bg-base-200/50 border-base-300 flex flex-col items-center justify-center rounded-xl border py-16"
>
<span class="loading loading-spinner loading-lg text-primary mb-4"></span>
<span class="text-base-content/70 font-medium">Carregando registros...</span>
<span class="text-base-content/50 mt-2 text-sm">Aguarde um momento</span>
</div>
{:else if registrosQuery?.error}
<div class="alert alert-error border-error/50 border-2 shadow-lg">
<XCircle class="h-6 w-6" />
<div>
<h3 class="font-bold">Erro ao carregar registros</h3>
<div class="mt-1 text-sm">
{registrosQuery.error?.message || String(registrosQuery.error) || 'Erro desconhecido'}
</div>
</div>
</div>
{:else if !registrosQuery?.data}
<div class="alert alert-warning border-warning/50 border-2 shadow-lg">
<Clock class="h-6 w-6" />
<span class="font-medium">Aguardando dados da consulta...</span>
</div>
{:else if registros.length === 0}
<div
class="alert alert-info border-info/50 from-info/10 to-info/5 rounded-xl border-2 bg-gradient-to-r shadow-lg"
>
<FileText class="text-info h-6 w-6" />
<div class="flex-1">
<h3 class="text-base-content font-bold">Nenhum registro encontrado</h3>
<div class="mt-2 text-sm opacity-80">
<p>
Período: <span class="font-semibold"
>{formatarDataDDMMAAAA(dataInicio)} até {formatarDataDDMMAAAA(dataFim)}</span
>
</p>
{#if funcionarioIdFiltro && funcionarioSelecionadoNome}
<p class="mt-1">
Funcionário: <span class="font-semibold">{funcionarioSelecionadoNome}</span>
</p>
{/if}
<p class="text-base-content/60 mt-2">
Tente ajustar os filtros para encontrar registros.
</p>
</div>
</div>
</div>
{:else if registrosAgrupados.length === 0}
<div
class="alert alert-warning border-warning/50 from-warning/10 to-warning/5 rounded-xl border-2 bg-gradient-to-r shadow-lg"
>
<FileText class="text-warning h-6 w-6" />
<div class="flex-1">
<h3 class="font-bold">Registros encontrados, mas não foi possível agrupá-los</h3>
<div class="mt-2 text-sm opacity-80">
Total de registros: <span class="font-semibold">{registros.length}</span>
</div>
</div>
</div>
{:else}
<div class="space-y-6">
{#each registrosAgrupados as grupo}
<div
class="card from-base-100 to-base-200/50 border-base-300 border bg-gradient-to-br shadow-lg transition-all duration-300 hover:shadow-xl"
>
<div class="card-body p-6">
<!-- Cabeçalho melhorado -->
<div class="border-base-300 mb-6 border-b pb-4">
<div
class="flex flex-col items-start justify-between gap-4 lg:flex-row lg:items-center"
>
<div class="flex-1">
<div class="mb-2 flex items-center gap-3">
<div class="bg-primary/10 rounded-lg p-2">
<Users class="text-primary h-5 w-5" strokeWidth={2.5} />
</div>
<div>
<h3 class="text-base-content text-lg font-bold">
{grupo.funcionario?.nome || 'Funcionário não encontrado'}
</h3>
{#if grupo.funcionario?.matricula}
<p class="text-base-content/70 mt-1 text-sm">
<span class="font-medium">Matrícula:</span>
<span class="font-semibold">{grupo.funcionario.matricula}</span>
</p>
{/if}
</div>
</div>
{#if grupo.funcionario?.descricaoCargo}
<p class="text-base-content/60 ml-11 text-sm font-medium">
{grupo.funcionario.descricaoCargo}
</p>
{/if}
</div>
<div class="flex flex-wrap items-center gap-3">
<!-- Banco de Horas -->
{#key grupo.funcionarioId}
{@const bancoHorasQuery = useQuery(api.pontos.obterBancoHorasFuncionario, {
funcionarioId: grupo.funcionarioId
})}
{@const bancoHoras = bancoHorasQuery?.data}
{@const saldoAcumulado = bancoHoras?.saldoAcumuladoMinutos ?? 0}
{@const saldoPositivo = saldoAcumulado >= 0}
{#if bancoHoras}
<div
class="rounded-xl border-2 p-4 shadow-md transition-all hover:scale-105 {saldoPositivo
? 'border-success/50 from-success/10 to-success/5 bg-gradient-to-br'
: 'border-error/50 from-error/10 to-error/5 bg-gradient-to-br'}"
>
<div class="flex items-center gap-3">
<div
class="p-2 {saldoPositivo
? 'bg-success/20'
: 'bg-error/20'} rounded-lg"
>
{#if saldoPositivo}
<TrendingUp class="text-success h-5 w-5" strokeWidth={2.5} />
{:else}
<TrendingDown class="text-error h-5 w-5" strokeWidth={2.5} />
{/if}
</div>
<div>
<p class="mb-1 text-xs font-semibold opacity-70">Banco de Horas</p>
<p
class="text-xl font-bold {saldoPositivo
? 'text-success'
: 'text-error'}"
>
{formatarSaldoHoras(saldoAcumulado)}
</p>
</div>
</div>
</div>
{/if}
{/key}
<button
class="btn btn-primary gap-2 shadow-md transition-all hover:shadow-lg"
onclick={() => abrirModalImpressao(grupo.funcionarioId)}
>
<Printer class="h-4 w-4" />
Imprimir Ficha
</button>
</div>
</div>
</div>
<div
class="border-base-300 bg-base-100/50 max-h-[600px] overflow-x-auto overflow-y-auto rounded-xl border shadow-inner"
>
<table class="table-zebra table w-full">
<thead
class="from-base-300/95 to-base-200/95 sticky top-0 z-10 bg-gradient-to-r shadow-md backdrop-blur-sm"
>
<tr>
<th
class="text-base-content border-base-400 border-b text-sm font-bold whitespace-nowrap"
>Data</th
>
<th
class="text-base-content border-base-400 border-b text-sm font-bold whitespace-nowrap"
>Tipo</th
>
<th
class="text-base-content border-base-400 border-b text-sm font-bold whitespace-nowrap"
>Horário</th
>
<th
class="text-base-content border-base-400 border-b text-sm font-bold whitespace-nowrap"
>Saldo Parcial</th
>
<th
class="text-base-content border-base-400 border-b text-sm font-bold whitespace-nowrap"
>Saldo Diário</th
>
<th
class="text-base-content border-base-400 border-b text-sm font-bold whitespace-nowrap"
>Localização</th
>
<th
class="text-base-content border-base-400 border-b text-sm font-bold whitespace-nowrap"
>Status</th
>
<th
class="text-base-content border-base-400 border-b text-sm font-bold whitespace-nowrap"
>Ações</th
>
</tr>
</thead>
<tbody>
{#each Object.values(grupo.registrosPorData) as grupoData, dataIndex}
{@const totalRegistros = grupoData.registros.length}
{@const dataFormatada = formatarDataDDMMAAAA(grupoData.data)}
{@const saldosParciais = calcularSaldosParciais(grupoData.registros)}
{@const isUltimoDia =
dataIndex === Object.values(grupo.registrosPorData).length - 1}
{#each grupoData.registros as registro, index}
{@const saldoParcial = saldosParciais.get(index)}
<tr
class="hover:bg-base-200/50 transition-colors {dataIndex % 2 === 0
? 'bg-base-100/40'
: 'bg-base-50/60'} {!isUltimoDia && index === totalRegistros - 1
? 'border-base-300 border-b-4'
: ''}"
>
<td class="text-sm font-semibold whitespace-nowrap">{dataFormatada}</td>
<td class="whitespace-nowrap">
<span class="badge badge-outline badge-sm text-xs font-medium">
{config
? getTipoRegistroLabel(registro.tipo, {
nomeEntrada: config.nomeEntrada,
nomeSaidaAlmoco: config.nomeSaidaAlmoco,
nomeRetornoAlmoco: config.nomeRetornoAlmoco,
nomeSaida: config.nomeSaida
})
: getTipoRegistroLabel(registro.tipo)}
</span>
</td>
<td class="font-mono text-sm font-medium whitespace-nowrap"
>{formatarHoraPonto(registro.hora, registro.minuto)}</td
>
<td class="whitespace-nowrap">
{#if saldoParcial}
<span
class="badge badge-info badge-sm text-xs font-semibold shadow-sm"
>
Par {saldoParcial.parNumero}: +{saldoParcial.horas}h {saldoParcial.minutos}min
</span>
{:else}
<span class="text-base-content/30 text-sm">-</span>
{/if}
</td>
{#if index === 0}
<td class="whitespace-nowrap" rowspan={totalRegistros}>
{#if grupoData.saldoDiarioComparativo}
<SaldoDiarioComparativoBadge
saldo={grupoData.saldoDiarioComparativo}
size="md"
/>
{:else if grupoData.saldoDiario}
<SaldoDiarioBadge saldo={grupoData.saldoDiario} size="md" />
{:else}
<span class="badge badge-ghost badge-lg">-</span>
{/if}
</td>
{/if}
<td class="whitespace-nowrap">
<LocalizacaoIcon dentroRaioPermitido={registro.dentroRaioPermitido} />
</td>
<td class="whitespace-nowrap">
<span
class="badge badge-sm font-semibold {registro.dentroDoPrazo
? 'badge-success shadow-sm'
: 'badge-error shadow-sm'}"
>
{registro.dentroDoPrazo ? '✓ Dentro do Prazo' : '✗ Fora do Prazo'}
</span>
</td>
<td class="whitespace-nowrap">
<button
class="btn btn-sm btn-outline btn-primary hover:btn-primary gap-2 text-xs transition-all hover:shadow-md"
onclick={() => abrirModalDetalhes(registro._id)}
title="Ver Detalhes"
>
<FileText class="h-3 w-3" />
<span class="text-xs">Detalhes</span>
</button>
</td>
</tr>
{/each}
{/each}
</tbody>
</table>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>
{#if mostrarModalImpressao && funcionarioParaImprimir}
<PrintPontoModal
funcionarioId={funcionarioParaImprimir}
onClose={() => {
mostrarModalImpressao = false;
funcionarioParaImprimir = '';
}}
onGenerate={gerarPDFComSelecao}
/>
{/if}
<!-- Modal de Detalhes do Registro -->
{#if mostrarModalDetalhes && registroDetalhesId}
{@const registroDetalhesQuery = useQuery(
api.pontos.obterRegistro,
registroDetalhesId ? { registroId: registroDetalhesId } : 'skip'
)}
{@const registroDetalhes = registroDetalhesQuery?.data}
<dialog
class="modal modal-open"
onclick={(e) => e.target === e.currentTarget && fecharModalDetalhes()}
>
<div
class="modal-box flex max-h-[90vh] max-w-4xl flex-col overflow-hidden"
onclick={(e) => e.stopPropagation()}
>
<div
class="border-base-300 mb-4 flex flex-shrink-0 items-center justify-between border-b pb-4"
>
<h3 class="text-xl font-bold">Detalhes do Registro de Ponto</h3>
<button class="btn btn-sm btn-circle btn-ghost" onclick={fecharModalDetalhes}>✕</button>
</div>
<div class="flex-1 overflow-y-auto pr-2">
{#if registroDetalhesQuery === undefined || registroDetalhesQuery?.isLoading}
<div class="flex items-center justify-center py-8">
<span class="loading loading-spinner loading-lg text-primary"></span>
<span class="ml-4">Carregando detalhes...</span>
</div>
{:else if registroDetalhesQuery?.error}
<div class="alert alert-error">
<XCircle class="h-6 w-6" />
<div>
<h3 class="font-bold">Erro ao carregar detalhes</h3>
<div class="mt-1 text-sm">
{registroDetalhesQuery.error?.message ||
String(registroDetalhesQuery.error) ||
'Erro desconhecido'}
</div>
</div>
</div>
{:else if !registroDetalhes}
<div class="alert alert-warning">
<FileText class="h-6 w-6" />
<span>Registro não encontrado</span>
</div>
{:else}
<!-- Informações Básicas -->
<div class="card bg-base-200 mb-4">
<div class="card-body">
<h4 class="mb-2 font-bold">Informações do Registro</h4>
{#if registroDetalhes.funcionario}
<p><strong>Funcionário:</strong> {registroDetalhes.funcionario.nome}</p>
{#if registroDetalhes.funcionario.matricula}
<p><strong>Matrícula:</strong> {registroDetalhes.funcionario.matricula}</p>
{/if}
{/if}
<p><strong>Data:</strong> {formatarDataDDMMAAAA(registroDetalhes.data)}</p>
<p>
<strong>Horário:</strong>
{formatarHoraPonto(registroDetalhes.hora, registroDetalhes.minuto)}
</p>
<p>
<strong>Status:</strong>
<span
class="badge {registroDetalhes.dentroDoPrazo ? 'badge-success' : 'badge-error'}"
>{registroDetalhes.dentroDoPrazo ? 'Dentro do Prazo' : 'Fora do Prazo'}</span
>
</p>
</div>
</div>
<!-- Localização GPS -->
{#if registroDetalhes.latitude !== undefined && registroDetalhes.longitude !== undefined}
<div class="card bg-base-200 mb-4">
<div class="card-body">
<h4 class="mb-2 font-bold">Localização GPS</h4>
<p><strong>Latitude:</strong> {registroDetalhes.latitude.toFixed(6)}</p>
<p><strong>Longitude:</strong> {registroDetalhes.longitude.toFixed(6)}</p>
{#if registroDetalhes.precisao !== undefined}
<p><strong>Precisão:</strong> {registroDetalhes.precisao.toFixed(2)}m</p>
{/if}
{#if registroDetalhes.endereco || registroDetalhes.cidade}
<p>
<strong>Endereço:</strong>
{registroDetalhes.endereco || ''}
{registroDetalhes.cidade ? `, ${registroDetalhes.cidade}` : ''}
{registroDetalhes.estado ? ` - ${registroDetalhes.estado}` : ''}
</p>
{/if}
{#if registroDetalhes.confiabilidadeGPS !== undefined}
<p>
<strong>Confiabilidade GPS:</strong>
{(registroDetalhes.confiabilidadeGPS * 100).toFixed(1)}%
</p>
{/if}
{#if registroDetalhes.scoreConfiancaBackend !== undefined}
<p>
<strong>Score de Confiança:</strong>
{(registroDetalhes.scoreConfiancaBackend * 100).toFixed(1)}%
</p>
{/if}
</div>
</div>
{/if}
<!-- Dados de Sensores (Acelerômetro) -->
{#if registroDetalhes.acelerometroX !== undefined || registroDetalhes.sensorDisponivel !== undefined}
<div class="card bg-base-200 mb-4">
<div class="card-body">
<h4 class="mb-2 font-bold">Dados de Sensores</h4>
{#if registroDetalhes.sensorDisponivel === false && registroDetalhes.isDesktop !== true}
<p class="text-warning">
<strong>Sensor:</strong> Não disponível neste dispositivo
</p>
{:else if registroDetalhes.permissaoSensorNegada === true}
<p class="text-error"><strong>Sensor:</strong> Permissão negada</p>
{:else if registroDetalhes.acelerometroX !== undefined}
<p><strong>Sensor:</strong> Disponível</p>
<p>
<strong>Acelerômetro X:</strong>
{registroDetalhes.acelerometroX.toFixed(3)} m/s²
</p>
{#if registroDetalhes.acelerometroY !== undefined}
<p>
<strong>Acelerômetro Y:</strong>
{registroDetalhes.acelerometroY.toFixed(3)} m/s²
</p>
{/if}
{#if registroDetalhes.acelerometroZ !== undefined}
<p>
<strong>Acelerômetro Z:</strong>
{registroDetalhes.acelerometroZ.toFixed(3)} m/s²
</p>
{/if}
{#if registroDetalhes.magnitudeMovimento !== undefined}
<p>
<strong>Magnitude:</strong>
{registroDetalhes.magnitudeMovimento.toFixed(3)} m/s²
</p>
{/if}
{#if registroDetalhes.movimentoDetectado !== undefined}
<p>
<strong>Movimento Detectado:</strong>
<span
class="badge {registroDetalhes.movimentoDetectado
? 'badge-success'
: 'badge-warning'}"
>{registroDetalhes.movimentoDetectado ? 'Sim' : 'Não'}</span
>
</p>
{/if}
{#if registroDetalhes.variacaoAcelerometro !== undefined}
<p>
<strong>Variação:</strong>
{registroDetalhes.variacaoAcelerometro.toFixed(6)}
</p>
{/if}
{:else if registroDetalhes.isDesktop === true}
<p class="text-info">
<strong>Sensor:</strong> Não disponível em desktop (normal)
</p>
{/if}
</div>
</div>
{/if}
{/if}
</div>
<div class="border-base-300 mt-4 flex flex-shrink-0 justify-end gap-2 border-t pt-4">
{#if registroDetalhes}
<button
class="btn btn-primary gap-2"
onclick={() => imprimirDetalhesRegistro(registroDetalhesId)}
>
<Printer class="h-4 w-4" />
Imprimir PDF
</button>
{/if}
<button class="btn btn-outline" onclick={fecharModalDetalhes}>Fechar</button>
</div>
</div>
<form method="dialog" class="modal-backdrop" onclick={fecharModalDetalhes}></form>
</dialog>
{/if}