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

5034 lines
176 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 {
Clock,
Filter,
Download,
Printer,
BarChart3,
Users,
CheckCircle2,
XCircle,
TrendingUp,
TrendingDown,
FileText,
X,
Calendar
} from 'lucide-svelte';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { formatarHoraPonto, getTipoRegistroLabel, formatarDataDDMMAAAA } from '$lib/utils/ponto';
import { type SectionsPDF } from '$lib/utils/fichaPontoPDF';
// Importar módulos extraídos
import {
formatarDataParaExibicao,
formatarDataParaBackend,
formatarSaldoHoras
} from '$lib/utils/ponto/formatacao';
import { calcularSaldosParciais } from '$lib/utils/ponto/calculos';
import { agruparRegistrosPorFuncionario } from '$lib/utils/ponto/processamento';
import { gerarPDFComSelecao } from '$lib/utils/ponto/pdf/geradorPDF';
import { imprimirDetalhesRegistro } from '$lib/utils/ponto/pdf/geradorDetalhesPDF';
import { maskDate, validateDate, onlyDigits } from '$lib/utils/masks';
import LocalizacaoIcon from '$lib/components/ponto/LocalizacaoIcon.svelte';
import SaldoDiarioBadge from '$lib/components/ponto/SaldoDiarioBadge.svelte';
import SaldoDiarioComparativoBadge from '$lib/components/ponto/SaldoDiarioComparativoBadge.svelte';
import logoGovPE from '$lib/assets/logo_governo_PE.png';
import PrintPontoModal from '$lib/components/ponto/PrintPontoModal.svelte';
import { toast } from 'svelte-sonner';
import { Chart, registerables } from 'chart.js';
import Papa from 'papaparse';
import { onDestroy, onMount } from 'svelte';
import { useConvexClient, useQuery } from 'convex-svelte';
const client = useConvexClient();
// ============================================
// INTERFACES TYPESCRIPT
// ============================================
// Tipos importados de $lib/utils/ponto/tipos
// Estados
// Expandir período padrão para últimos 30 dias para facilitar visualização
const hoje = new Date();
const trintaDiasAtras = new Date(hoje.getTime() - 30 * 24 * 60 * 60 * 1000);
// Funções de formatação importadas de $lib/utils/ponto/formatacao
// Wrapper para formatarDataParaBackend que precisa de onlyDigits e validateDate
function formatarDataParaBackendWrapper(data: string): string {
return formatarDataParaBackend(data, onlyDigits, validateDate);
}
let dataInicioInterno = $state(trintaDiasAtras.toISOString().split('T')[0]!);
let dataFimInterno = $state(hoje.toISOString().split('T')[0]!);
// Valores para exibição (dd/mm/yyyy)
let dataInicioExibicao = $derived(formatarDataParaExibicao(dataInicioInterno));
let dataFimExibicao = $derived(formatarDataParaExibicao(dataFimInterno));
// Valores para backend (yyyy-mm-dd) - derivados dos valores internos
const dataInicio = $derived(dataInicioInterno);
const dataFim = $derived(dataFimInterno);
let funcionarioIdFiltro = $state<Id<'funcionarios'> | ''>('');
let statusFiltro = $state<'todos' | 'dentro' | 'fora'>('todos');
let localizacaoFiltro = $state<'todos' | 'dentro' | 'fora'>('todos');
// eslint-disable-next-line @typescript-eslint/no-unused-vars
let carregando = $state(false);
let mostrarModalImpressao = $state(false);
let funcionarioParaImprimir = $state<Id<'funcionarios'> | ''>('');
let mostrarModalDetalhes = $state(false);
let registroDetalhesId = $state<Id<'registrosPonto'> | ''>('');
// Função para abrir modal de impressão
const abrirModalImpressao = (funcionarioId: Id<'funcionarios'>) => {
funcionarioParaImprimir = funcionarioId;
mostrarModalImpressao = true;
};
let chartCanvas = $state<HTMLCanvasElement | undefined>(undefined);
let chartInstance: Chart | null = null;
// Parâmetros reativos para queries
// Nota: Apenas filtros de data são aplicados no backend para performance
// Filtros de funcionário, status e localização são aplicados no frontend
const registrosParams = $derived({
dataInicio,
dataFim
// Removido funcionarioId para carregar todos os registros e filtrar no frontend
});
let estatisticasParams = $derived({
dataInicio,
dataFim,
funcionarioId:
funcionarioIdFiltro && funcionarioIdFiltro !== '' ? funcionarioIdFiltro : undefined
});
// Queries
const funcionariosQuery = useQuery(api.funcionarios.getAll, {});
// useQuery do Convex-Svelte lida corretamente com valores $derived reativos
// svelte-ignore state_referenced_locally
const registrosQuery = useQuery(api.pontos.listarRegistrosPeriodo, registrosParams);
// svelte-ignore state_referenced_locally
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);
// Obter nome do funcionário selecionado
const funcionarioSelecionadoNome = $derived.by(() => {
if (!funcionarioIdFiltro) return null;
return funcionarios.find((f) => f._id === funcionarioIdFiltro)?.nome || null;
});
const config = $derived(configQuery?.data);
// Debug: Log dos dados recebidos
$effect(() => {
if (registrosQuery !== undefined) {
const params = registrosParams;
console.log('[Frontend] registrosQuery:', {
isLoading: registrosQuery?.isLoading,
error: registrosQuery?.error,
dataLength: registrosQuery?.data?.length ?? 0,
params
});
}
if (registros && registros.length > 0) {
console.log('[Frontend] Primeiros registros:', registros.slice(0, 3));
}
});
// Dados do gráfico baseados nas estatísticas
let 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;
if (value === null || value === undefined) {
return `${label}: 0 (0.0%)`;
}
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(() => {
Chart.register(...registerables);
// 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
let registrosFiltrados = $derived.by(() => {
if (!registros || registros.length === 0) return [];
let resultado = [...registros];
// Filtro de funcionário (aplicado no frontend para garantir que funcione corretamente)
if (funcionarioIdFiltro && funcionarioIdFiltro !== '') {
resultado = resultado.filter((r) => {
return r.funcionarioId === funcionarioIdFiltro;
});
}
// 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 usando função extraída
const registrosAgrupados = $derived.by(() => {
return agruparRegistrosPorFuncionario(registrosFiltrados, config || undefined);
});
// Código antigo de agrupamento (comentado para referência)
/*
const registrosAgrupadosOld = $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
let funcionariosComBancoHoras = $derived.by(() => {
return registrosAgrupados.map((grupo) => grupo.funcionarioId);
});
// Funções de formatação importadas de $lib/utils/ponto/formatacao
// Usar função centralizada formatarDataDDMMAAAA da lib/utils/ponto.ts
// funcionarioSelecionadoNome movido para cima, logo após a definição de funcionarios
// Funções de cálculo importadas de $lib/utils/ponto/calculos
// calcularSaldosParciais, calcularSaldoDiario, calcularSaldosPorPar, calcularSaldoComparativoPorPar
// Funções importadas de $lib/utils/ponto/processamento e $lib/utils/ponto/validacao
// gerarDiasPeriodo, gerarRegistrosEsperados, registroFoiMarcado
// Função abrirModalImpressao movida para cima, próximo às declarações de estado
// ============================================
// FUNÇÕES AUXILIARES DE FORMATAÇÃO
// ============================================
// Funções formatarMinutos e formatarHoras importadas de $lib/utils/ponto/formatacao
// Função validarPeriodo importada de $lib/utils/ponto/validacao
// ============================================
// FUNÇÃO DE PROCESSAMENTO DE DADOS PARA FICHA
// ============================================
// Função processarDadosFichaPonto importada de $lib/utils/ponto/processamento
// A função abaixo foi movida para o módulo e está comentada para referência
/*
async function processarDadosFichaPontoOld(
funcionarioId: Id<'funcionarios'>,
dataInicio: string,
dataFim: string
): Promise<{
dias: DiaFichaPonto[];
resumo: ResumoPeriodo;
config: {
horarioEntrada: string;
horarioSaidaAlmoco: string;
horarioRetornoAlmoco: string;
horarioSaida: string;
};
}> {
// Buscar todos os dados necessários
const [
registrosFuncionario,
atestadosLicencas,
ausenciasTodas,
ajustes,
inconsistencias,
homologacoes,
dispensas,
config
] = await Promise.all([
client.query(api.pontos.listarRegistrosPeriodo, {
funcionarioId,
dataInicio,
dataFim
}),
client.query(api.atestadosLicencas.listarPorFuncionario, {
funcionarioId
}),
client.query(api.ausencias.listarTodas, {}),
client.query(api.pontos.listarAjustesBancoHoras, {
funcionarioId
}),
client.query(api.pontos.listarInconsistenciasBancoHoras, {}),
client.query(api.pontos.listarHomologacoes, {
funcionarioId
}),
client.query(api.pontos.listarDispensas, {
funcionarioId,
apenasAtivas: false
}),
client.query(api.configuracaoPonto.obterConfiguracao, {})
]);
const atestados = atestadosLicencas?.atestados || [];
const licencas = atestadosLicencas?.licencas || [];
const ausencias = (ausenciasTodas || []).filter((a) => a.funcionarioId === funcionarioId);
if (!config) {
throw new Error('Configuração de ponto não encontrada');
}
// Filtrar dados pelo período
const dataInicioObj = new Date(dataInicio + 'T00:00:00');
const dataFimObj = new Date(dataFim + 'T23:59:59');
const atestadosPeriodo = (atestados || []).filter((a) => {
const inicio = new Date(a.dataInicio);
const fim = new Date(a.dataFim);
return inicio <= dataFimObj && fim >= dataInicioObj;
});
const ausenciasPeriodo = (ausencias || []).filter((a) => {
const inicio = new Date(a.dataInicio);
const fim = new Date(a.dataFim);
return inicio <= dataFimObj && fim >= dataInicioObj;
});
const licencasPeriodo = (licencas || []).filter((l) => {
const inicio = new Date(l.dataInicio);
const fim = new Date(l.dataFim);
return inicio <= dataFimObj && fim >= dataInicioObj;
});
const ajustesPeriodo = (ajustes || []).filter((a) => {
const dataAjuste = new Date(a.dataAplicacao);
return dataAjuste >= dataInicioObj && dataAjuste <= dataFimObj;
});
const inconsistenciasPeriodo = (inconsistencias || []).filter((i) => {
if (i.funcionarioId !== funcionarioId) return false;
const dataInconsistencia = new Date(i.dataDetectada);
return dataInconsistencia >= dataInicioObj && dataInconsistencia <= dataFimObj;
});
const dataInicioTimestamp = dataInicioObj.getTime();
const dataFimTimestamp = dataFimObj.getTime();
const homologacoesPeriodo = (homologacoes || []).filter((h) => {
return h.criadoEm >= dataInicioTimestamp && h.criadoEm <= dataFimTimestamp;
});
const dispensasPeriodo = (dispensas || []).filter((d) => {
const dispensaInicio = new Date(d.dataInicio + 'T00:00:00');
const dispensaFim = new Date(d.dataFim + 'T23:59:59');
return dispensaInicio <= dataFimObj && dispensaFim >= dataInicioObj;
});
// Gerar todos os dias do período
const diasPeriodo = gerarDiasPeriodo(dataInicio, dataFim);
const diasProcessados: DiaFichaPonto[] = [];
// Agrupar registros por data
const registrosPorData: Record<string, RegistroPonto[]> = {};
for (const r of registrosFuncionario || []) {
if (!registrosPorData[r.data]) {
registrosPorData[r.data] = [];
}
registrosPorData[r.data]!.push(r);
}
// Processar cada dia
for (const data of diasPeriodo) {
const dataObj = new Date(data);
const regsReais = registrosPorData[data] || [];
const regsEsperados = gerarRegistrosEsperados(data, config);
// Verificar atestado
const atestadoDia =
atestadosPeriodo.find((a) => {
const inicio = new Date(a.dataInicio);
const fim = new Date(a.dataFim);
return dataObj >= inicio && dataObj <= fim;
}) || null;
// Verificar ausência
const ausenciaDia =
ausenciasPeriodo.find((a) => {
const inicio = new Date(a.dataInicio);
const fim = new Date(a.dataFim);
return dataObj >= inicio && dataObj <= fim;
}) || null;
// Verificar licença
const licencaDia =
licencasPeriodo.find((l) => {
const inicio = new Date(l.dataInicio);
const fim = new Date(l.dataFim);
return dataObj >= inicio && dataObj <= fim;
}) || null;
// Verificar ajustes do dia
const ajustesDia = ajustesPeriodo.filter((a) => a.dataAplicacao === data);
// Verificar inconsistências do dia
const inconsistenciasDia = inconsistenciasPeriodo.filter((i) => i.dataDetectada === data);
// Verificar homologações do dia
const homologacoesDia = homologacoesPeriodo.filter((h) => {
if (h.registroId) {
const registro = regsReais.find((r) => r._id === h.registroId);
return registro !== undefined;
}
return false;
});
// Verificar dispensa
const dispensaDia =
dispensasPeriodo.find((d) => {
const dispensaInicio = new Date(d.dataInicio + 'T00:00:00');
const dispensaFim = new Date(d.dataFim + 'T23:59:59');
return dataObj >= dispensaInicio && dataObj <= dispensaFim;
}) || null;
// Calcular saldo diário
const regsReaisOrdenados = [...regsReais].sort((a, b) => {
if (a.hora !== b.hora) return a.hora - b.hora;
return a.minuto - b.minuto;
});
const saldosComparativosPorPar = calcularSaldoComparativoPorPar(regsReaisOrdenados, config);
let saldoDiario: SaldoDiario | null = null;
let saldoDiarioTotalDiferencaMinutos = 0;
let saldoDiarioTotalTrabalhadoMinutos = 0;
let saldoDiarioTotalEsperadoMinutos = 0;
// Somar saldos dos pares
const paresProcessados = new Set<number>();
for (const [, saldo] of saldosComparativosPorPar.entries()) {
if (!paresProcessados.has(saldo.parIndex)) {
saldoDiarioTotalDiferencaMinutos += saldo.diferencaMinutos;
saldoDiarioTotalTrabalhadoMinutos += saldo.trabalhadoMinutos;
saldoDiarioTotalEsperadoMinutos += saldo.esperadoMinutos;
paresProcessados.add(saldo.parIndex);
}
}
// Calcular saldo para pares não marcados
const todosRegistros: Array<{ tipo: string; hora: number; minuto: number; real: boolean }> =
[];
for (const reg of regsReais) {
todosRegistros.push({
tipo: reg.tipo,
hora: reg.hora,
minuto: reg.minuto,
real: true
});
}
for (const regEsperado of regsEsperados) {
if (!registroFoiMarcado(regEsperado, regsReais)) {
todosRegistros.push({
tipo: regEsperado.tipo,
hora: regEsperado.hora,
minuto: regEsperado.minuto,
real: false
});
}
}
// Identificar pares não marcados e calcular saldo negativo
for (let i = 0; i < todosRegistros.length; i++) {
const reg = todosRegistros[i];
if ((reg.tipo === 'entrada' || reg.tipo === 'retorno_almoco') && !reg.real) {
const tipoSaidaEsperado = reg.tipo === 'entrada' ? 'saida_almoco' : 'saida';
const saidaEsperada = todosRegistros.find((r, idx) => {
if (idx <= i) return false;
if (r.tipo !== tipoSaidaEsperado || r.real) return false;
const minutosEntrada = reg.hora * 60 + reg.minuto;
const minutosSaidaEsperada = r.hora * 60 + r.minuto;
const temRegistroRealNoIntervalo = regsReais.some((real) => {
if (real.tipo !== tipoSaidaEsperado) return false;
const minutosReal = real.hora * 60 + real.minuto;
return minutosReal >= minutosEntrada && minutosReal < minutosSaidaEsperada;
});
return !temRegistroRealNoIntervalo;
});
if (saidaEsperada) {
let esperadoMinutos: number;
if (reg.tipo === 'entrada') {
const [horaEntradaEsperada, minutoEntradaEsperado] = config.horarioEntrada
.split(':')
.map(Number);
const [horaSaidaAlmocoEsperada, minutoSaidaAlmocoEsperado] = config.horarioSaidaAlmoco
.split(':')
.map(Number);
const minutosEntradaEsperada = horaEntradaEsperada * 60 + minutoEntradaEsperado;
const minutosSaidaEsperadaConfig =
horaSaidaAlmocoEsperada * 60 + minutoSaidaAlmocoEsperado;
esperadoMinutos = minutosSaidaEsperadaConfig - minutosEntradaEsperada;
if (esperadoMinutos < 0) esperadoMinutos += 24 * 60;
} else {
const [horaRetornoAlmocoEsperado, minutoRetornoAlmocoEsperado] =
config.horarioRetornoAlmoco.split(':').map(Number);
const [horaSaidaEsperada, minutoSaidaEsperado] = config.horarioSaida
.split(':')
.map(Number);
const minutosEntradaEsperada =
horaRetornoAlmocoEsperado * 60 + minutoRetornoAlmocoEsperado;
const minutosSaidaEsperadaConfig = horaSaidaEsperada * 60 + minutoSaidaEsperado;
esperadoMinutos = minutosSaidaEsperadaConfig - minutosEntradaEsperada;
if (esperadoMinutos < 0) esperadoMinutos += 24 * 60;
}
saldoDiarioTotalDiferencaMinutos -= esperadoMinutos;
saldoDiarioTotalEsperadoMinutos += esperadoMinutos;
}
}
}
// Aplicar ajustes manuais
for (const ajuste of ajustesDia) {
if (ajuste.tipo === 'abonar') {
saldoDiarioTotalDiferencaMinutos += ajuste.valorMinutos;
} else if (ajuste.tipo === 'descontar') {
saldoDiarioTotalDiferencaMinutos -= ajuste.valorMinutos;
}
}
// Calcular diferença final
const diferencaDiariaCorrigida =
saldoDiarioTotalTrabalhadoMinutos - saldoDiarioTotalEsperadoMinutos;
saldoDiario = {
diferencaMinutos: diferencaDiariaCorrigida,
trabalhadoMinutos: saldoDiarioTotalTrabalhadoMinutos,
esperadoMinutos: saldoDiarioTotalEsperadoMinutos
};
// Determinar tipo de dia
let tipoDia: TipoDia = 'normal';
let computado = true;
if (dispensaDia) {
tipoDia = 'nao_computado';
computado = false;
} else if (licencaDia) {
tipoDia = 'licenca';
computado = false;
} else if (atestadoDia) {
tipoDia = 'atestado';
computado = false;
} else if (ausenciaDia) {
tipoDia = 'ausencia';
computado = false;
} else if (ajustesDia.some((a) => a.tipo === 'abonar' && a.valorMinutos >= 240)) {
tipoDia = 'abonado';
}
if (inconsistenciasDia.length > 0) {
tipoDia = 'inconsistente';
}
diasProcessados.push({
data,
dataFormatada: formatarDataDDMMAAAA(data),
tipoDia,
registros: regsReais,
registrosEsperados: regsEsperados,
saldoDiario,
saldoAcumulado: 0, // Será calculado depois
atestado: atestadoDia
? {
_id: atestadoDia._id,
tipo: atestadoDia.tipo,
dataInicio: atestadoDia.dataInicio,
dataFim: atestadoDia.dataFim,
motivo: atestadoDia.observacoes
}
: null,
ausencia: ausenciaDia
? {
_id: ausenciaDia._id,
motivo: ausenciaDia.motivo,
dataInicio: ausenciaDia.dataInicio,
dataFim: ausenciaDia.dataFim,
status: ausenciaDia.status
}
: null,
licenca: licencaDia
? {
_id: licencaDia._id,
tipo: licencaDia.tipo || 'licenca',
dataInicio: licencaDia.dataInicio,
dataFim: licencaDia.dataFim
}
: null,
ajustes: ajustesDia.map((a) => ({
_id: a._id,
tipo: a.tipo,
valorMinutos: a.valorMinutos,
motivoDescricao: a.motivoDescricao,
gestorId: a.gestorId
})),
inconsistencias: inconsistenciasDia.map((i) => ({
_id: i._id,
tipo: i.tipo,
descricao: i.descricao,
dataDetectada: i.dataDetectada,
status: i.status,
resolvidoPor: i.resolvidoPor,
resolvidoEm: i.resolvidoEm
})),
homologacoes: homologacoesDia.map((h) => ({
_id: h._id,
motivoDescricao: h.motivoDescricao,
gestorId: h.gestorId
})),
dispensa: dispensaDia
? {
_id: dispensaDia._id,
motivo: dispensaDia.motivo,
dataInicio: dispensaDia.dataInicio,
dataFim: dispensaDia.dataFim,
ativo: dispensaDia.ativo
}
: null,
computado
});
}
// Calcular saldo acumulado para cada dia
// TODO: Buscar saldo inicial do backend se necessário
let saldoAcumulado = 0; // Saldo inicial (será 0 por enquanto, pode ser buscado do backend)
for (const dia of diasProcessados) {
if (dia.computado && dia.saldoDiario) {
saldoAcumulado += dia.saldoDiario.diferencaMinutos;
}
dia.saldoAcumulado = saldoAcumulado;
}
// Calcular resumo com formatações
const totalHorasTrabalhadas = diasProcessados
.filter((d) => d.computado)
.reduce((acc, d) => acc + (d.saldoDiario?.trabalhadoMinutos || 0), 0);
const totalHorasEsperadas = diasProcessados
.filter((d) => d.computado)
.reduce((acc, d) => acc + (d.saldoDiario?.esperadoMinutos || 0), 0);
const diferencaTotal = diasProcessados
.filter((d) => d.computado)
.reduce((acc, d) => acc + (d.saldoDiario?.diferencaMinutos || 0), 0);
const saldoPeriodo = diferencaTotal;
const saldoFinal = diasProcessados.length > 0 ? diasProcessados[diasProcessados.length - 1]!.saldoAcumulado : 0;
const resumo: ResumoPeriodo = {
totalDias: diasProcessados.length,
diasTrabalhados: diasProcessados.filter((d) => d.computado && d.registros.length > 0).length,
diasComAtestado: diasProcessados.filter((d) => d.atestado !== null).length,
diasAusentes: diasProcessados.filter((d) => d.ausencia !== null).length,
diasComLicenca: diasProcessados.filter((d) => d.licenca !== null).length,
diasAbonados: diasProcessados.filter((d) => d.tipoDia === 'abonado').length,
diasNaoComputados: diasProcessados.filter((d) => !d.computado).length,
diasComInconsistencia: diasProcessados.filter((d) => d.inconsistencias.length > 0).length,
totalHorasTrabalhadas,
totalHorasEsperadas,
diferencaTotal,
saldoInicial: 0, // TODO: Buscar do backend
saldoFinal,
saldoPeriodo,
totalInconsistencias: inconsistenciasPeriodo.length,
saldoInicialFormatado: formatarMinutos(0),
saldoPeriodoFormatado: formatarMinutos(saldoPeriodo),
saldoFinalFormatado: formatarMinutos(saldoFinal),
totalHorasTrabalhadasFormatado: formatarHoras(totalHorasTrabalhadas),
totalHorasEsperadasFormatado: formatarHoras(totalHorasEsperadas),
diferencaTotalFormatado: formatarMinutos(diferencaTotal)
};
return {
dias: diasProcessados,
resumo,
config
};
}
*/
// Função para limpar todos os filtros
function limparFiltros() {
const hoje = new Date();
const trintaDiasAtras = new Date(hoje.getTime() - 30 * 24 * 60 * 60 * 1000);
const dataInicioStr = trintaDiasAtras.toISOString().split('T')[0]!;
const dataFimStr = hoje.toISOString().split('T')[0]!;
dataInicioInterno = dataInicioStr;
dataFimInterno = dataFimStr;
dataInicioExibicao = formatarDataParaExibicao(dataInicioStr);
dataFimExibicao = formatarDataParaExibicao(dataFimStr);
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: ';'
});
// 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.');
}
}
// Wrapper para a função importada gerarPDFComSelecao
async function gerarPDFComSelecaoWrapper(sections: SectionsPDF) {
if (!funcionarioParaImprimir) return;
const funcionarioId = funcionarioParaImprimir;
await gerarPDFComSelecao(
client,
sections,
funcionarioId,
dataInicio,
dataFim,
funcionarios,
logoGovPE,
(message: string) => toast.error(message),
() => {
mostrarModalImpressao = false;
funcionarioParaImprimir = '';
toast.success('PDF gerado com sucesso!');
},
(value: boolean) => {
carregando = value;
}
);
}
// Função antiga comentada (foi movida para módulo)
/*
async function gerarPDFComSelecaoOld(sections: SectionsPDF) {
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;
}
// Validar período
const validacaoPeriodo = validarPeriodo(dataInicio, dataFim);
if (!validacaoPeriodo.valido) {
toast.error(validacaoPeriodo.erro || 'Período inválido');
return;
}
const funcionario = funcionarios.find((f) => f._id === funcionarioId);
if (!funcionario) {
toast.error('Funcionário não encontrado');
return;
}
try {
// Início do bloco try para geração de PDF
carregando = true;
// Processar todos os dados necessários
const {
dias,
resumo,
config: configPonto
} = await processarDadosFichaPonto(client, funcionarioId, dataInicio, dataFim);
if (dias.length === 0) {
toast.error('Nenhum dado encontrado para este funcionário no período selecionado');
carregando = false;
return;
}
const doc = new jsPDF();
// Logo e cabeçalho
let yPosition = await adicionarLogo(doc, logoGovPE);
yPosition = adicionarCabecalho(doc, yPosition);
// Dados do Funcionário
if (sections.dadosFuncionario) {
yPosition = adicionarDadosFuncionario(doc, yPosition, funcionario, dataInicio, dataFim);
}
// Resumo do Período
yPosition = adicionarResumoPeriodo(doc, yPosition, resumo, formatarHoras, formatarMinutos);
// Saldos do Período
yPosition = adicionarSaldosPeriodo(doc, yPosition, resumo, formatarMinutos);
// Legenda
yPosition = adicionarLegenda(doc, yPosition);
// ============================================
// SEÇÃO: TABELA PRINCIPAL DE REGISTROS
// ============================================
if (sections.registrosPonto) {
if (yPosition > 250) {
doc.addPage();
yPosition = 20;
}
doc.setFontSize(14);
doc.setTextColor(41, 128, 185);
doc.setFont('helvetica', 'bold');
doc.text('REGISTROS DE PONTO', 15, yPosition);
yPosition += 10;
// Função auxiliar para obter cor de fundo baseada no tipo de dia
const obterCorFundoTipoDia = (tipoDia: TipoDia): number[] => {
switch (tipoDia) {
case 'atestado':
return [230, 240, 255]; // Azul claro
case 'ausencia':
return [255, 255, 230]; // Amarelo claro
case 'abonado':
return [230, 255, 230]; // Verde claro
case 'nao_computado':
return [240, 240, 240]; // Cinza claro
case 'inconsistente':
return [255, 240, 230]; // Laranja claro
default:
return [255, 255, 255]; // Branco
}
};
// Função auxiliar para obter ícone do tipo de dia
const obterIconeTipoDia = (dia: DiaFichaPonto): string => {
if (dia.atestado) return '🏥';
if (dia.ausencia) return '🚫';
if (dia.licenca) return '📋';
if (dia.tipoDia === 'abonado') return '✅';
if (dia.tipoDia === 'nao_computado') return '⏸';
if (dia.inconsistencias.length > 0) return '⚠';
return '';
};
// Função auxiliar para obter texto do tipo de dia
const obterTextoTipoDia = (dia: DiaFichaPonto): string => {
if (dia.atestado) return 'Atestado';
if (dia.ausencia) return 'Ausência';
if (dia.licenca) return 'Licença';
if (dia.tipoDia === 'abonado') return 'Abonado';
if (dia.tipoDia === 'nao_computado') return 'Não Computado';
if (dia.inconsistencias.length > 0) return 'Inconsistente';
return 'Normal';
};
// Função auxiliar para formatar saldo
const formatarSaldo = (saldo: SaldoDiario | null): string => {
if (!saldo) return '-';
const horas = Math.floor(Math.abs(saldo.diferencaMinutos) / 60);
const minutos = Math.abs(saldo.diferencaMinutos) % 60;
const sinal = saldo.diferencaMinutos >= 0 ? '+' : '-';
return `${sinal}${horas}h ${minutos}min`;
};
const tableData: Array<
Array<
| string
| {
content: string;
styles?: { fillColor?: number[]; textColor?: number[]; fontStyle?: string };
}
>
> = [];
// Processar cada dia usando os dados já processados
for (const dia of dias) {
const corFundo = obterCorFundoTipoDia(dia.tipoDia);
const iconeTipoDia = obterIconeTipoDia(dia);
const textoTipoDia = obterTextoTipoDia(dia);
// 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 dia.registros) {
todosRegistros.push({
tipo: reg.tipo,
hora: reg.hora,
minuto: reg.minuto,
real: true,
dentroDoPrazo: reg.dentroDoPrazo
});
}
// Adicionar registros esperados não marcados
for (const regEsperado of dia.registrosEsperados) {
if (!registroFoiMarcado(regEsperado, dia.registros)) {
todosRegistros.push({
tipo: regEsperado.tipo,
hora: regEsperado.hora,
minuto: regEsperado.minuto,
real: false
});
}
}
// Ordenar registros por hora e minuto
todosRegistros.sort((a, b) => {
if (a.hora !== b.hora) return a.hora - b.hora;
return a.minuto - b.minuto;
});
// Criar linhas da tabela para cada registro
for (let i = 0; i < todosRegistros.length; i++) {
const reg = todosRegistros[i];
const linha: Array<
| string
| {
content: string;
styles?: { fillColor?: number[]; textColor?: number[]; fontStyle?: string };
}
> = [];
// Coluna Data (apenas na primeira linha do dia)
if (i === 0) {
linha.push({
content: dia.dataFormatada,
styles: { fillColor: corFundo, fontStyle: 'bold' }
});
} else {
linha.push('');
}
// Coluna Tipo de Dia (apenas na primeira linha)
if (i === 0) {
linha.push({
content: `${iconeTipoDia} ${textoTipoDia}`,
styles: { fillColor: corFundo }
});
} else {
linha.push('');
}
// Coluna Tipo de Registro
const tipoLabel = configPonto
? getTipoRegistroLabel(
reg.tipo as 'entrada' | 'saida_almoco' | 'retorno_almoco' | 'saida',
{
nomeEntrada: configPonto.nomeEntrada,
nomeSaidaAlmoco: configPonto.nomeSaidaAlmoco,
nomeRetornoAlmoco: configPonto.nomeRetornoAlmoco,
nomeSaida: configPonto.nomeSaida
}
)
: getTipoRegistroLabel(reg.tipo as 'entrada' | 'saida_almoco' | 'retorno_almoco' | 'saida');
linha.push({
content: `${reg.real ? '✓' : '✗'} ${tipoLabel}`,
styles: {
textColor: reg.real ? [0, 128, 0] : [200, 0, 0], // Verde se marcado, vermelho se não
fillColor: corFundo
}
});
// Coluna Horário
linha.push({
content: formatarHoraPonto(reg.hora, reg.minuto),
styles: {
textColor: reg.real ? [0, 0, 0] : [200, 0, 0], // Preto se marcado, vermelho se não
fillColor: corFundo
}
});
// Coluna Saldo Diário (apenas na primeira linha do dia, se houver saldo)
if (sections.saldoDiario) {
if (i === 0 && dia.saldoDiario) {
const saldoFormatado = formatarSaldo(dia.saldoDiario);
const saldoPositivo = dia.saldoDiario.diferencaMinutos > 0;
const saldoNegativo = dia.saldoDiario.diferencaMinutos < 0;
linha.push({
content: saldoFormatado,
styles: {
textColor: saldoPositivo ? [0, 128, 0] : saldoNegativo ? [200, 0, 0] : [0, 0, 0],
fontStyle: saldoPositivo || saldoNegativo ? 'bold' : 'normal',
fillColor: corFundo
}
});
} else {
linha.push('');
}
}
// Coluna Observações (apenas na primeira linha)
if (i === 0) {
const observacoes: string[] = [];
if (dia.atestado) {
observacoes.push(`Atestado: ${dia.atestado.motivo}`);
}
if (dia.ausencia) {
observacoes.push(`Ausência: ${dia.ausencia.motivo}`);
}
if (dia.licenca) {
observacoes.push(`Licença: ${dia.licenca.tipo}`);
}
if (dia.dispensa) {
observacoes.push(`Dispensa: ${dia.dispensa.motivo}`);
}
if (dia.inconsistencias.length > 0) {
observacoes.push(
`⚠ ${dia.inconsistencias.length} inconsistência(ões): ${dia.inconsistencias.map((inc) => inc.descricao).join('; ')}`
);
}
if (dia.ajustes.length > 0) {
observacoes.push(
`Ajustes: ${dia.ajustes.map((a) => `${a.tipo} ${Math.floor(a.valorMinutos / 60)}h ${a.valorMinutos % 60}min`).join(', ')}`
);
}
linha.push({
content: observacoes.join(' | ') || '-',
styles: { fillColor: corFundo, fontSize: 8 }
});
} else {
linha.push('');
}
// Coluna Status (Dentro do Prazo)
linha.push(
reg.real
? reg.dentroDoPrazo !== undefined
? reg.dentroDoPrazo
? 'Sim'
: 'Não'
: 'Não marcado'
: 'Não marcado'
);
tableData.push(linha);
}
}
// Cabeçalhos da tabela
const headers = ['Data', 'Tipo de Dia', 'Tipo', 'Horário'];
if (sections.saldoDiario) {
headers.push('Saldo Diário');
}
headers.push('Observações', 'Dentro do Prazo');
// Salvar a posição Y antes da tabela
const yPosAntesTabela = yPosition;
// Gerar tabela usando os dados processados
autoTable(doc, {
startY: yPosition,
head: [headers],
body: tableData,
theme: 'grid',
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
styles: { fontSize: 9 },
didParseCell: function (data) {
// Aplicar cores baseadas no tipo de dia e status dos registros
if (data.row.raw) {
const rowData = data.row.raw as Array<
| string
| {
content: string;
styles?: { fillColor?: number[]; textColor?: number[]; fontStyle?: string };
}
>;
// Aplicar estilos de células que têm objetos com styles
if (rowData[data.column.index] && typeof rowData[data.column.index] === 'object') {
const cellData = rowData[data.column.index] as {
content: string;
styles?: { fillColor?: number[]; textColor?: number[]; fontStyle?: string };
};
if (cellData.styles) {
if (cellData.styles.fillColor) {
data.cell.styles.fillColor = cellData.styles.fillColor;
}
if (cellData.styles.textColor) {
data.cell.styles.textColor = cellData.styles.textColor;
}
if (cellData.styles.fontStyle) {
data.cell.styles.fontStyle = cellData.styles.fontStyle;
}
}
}
}
}
});
type JsPDFWithAutoTableRegistros = jsPDF & {
lastAutoTable?: { finalY: number };
};
const finalYRegistros =
(doc as JsPDFWithAutoTableRegistros).lastAutoTable?.finalY ?? yPosition + 10;
yPosition = finalYRegistros + 10;
// 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';
// Buscar a saída esperada que também não foi marcada
// IMPORTANTE: Garantir que estamos pegando a saída esperada correta do par,
// não confundindo com saídas esperadas de outros pares ou registros reais
const saidaEsperada = todosRegistros.find((r, idx) => {
// Deve estar após a entrada atual
if (idx <= i) return false;
// Deve ser do tipo esperado e não real
if (r.tipo !== tipoSaidaEsperado || r.real) return false;
// Verificar se há algum registro real do mesmo tipo entre a entrada e esta saída
// Se houver, esta não é a saída esperada do par não marcado
const minutosEntrada = reg.hora * 60 + reg.minuto;
const minutosSaidaEsperada = r.hora * 60 + r.minuto;
// Verificar se há registro real do mesmo tipo no intervalo entre entrada e saída esperada
const temRegistroRealNoIntervalo = regsReais.some((real) => {
if (real.tipo !== tipoSaidaEsperado) return false;
const minutosReal = real.hora * 60 + real.minuto;
// Se o registro real está entre a entrada e a saída esperada, não é o par correto
return minutosReal >= minutosEntrada && minutosReal < minutosSaidaEsperada;
});
// Se não há registro real no intervalo, esta é a saída esperada correta
return !temRegistroRealNoIntervalo;
});
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 (usando a mesma lógica melhorada)
const indexSaidaEsperada = todosRegistros.findIndex((r, idx) => {
if (idx <= i) return false;
if (r.tipo !== tipoSaidaEsperado || r.real) return false;
// Verificar se há registro real no intervalo
const minutosEntrada = reg.hora * 60 + reg.minuto;
const minutosSaidaEsperada = r.hora * 60 + r.minuto;
const temRegistroRealNoIntervalo = regsReais.some((real) => {
if (real.tipo !== tipoSaidaEsperado) return false;
const minutosReal = real.hora * 60 + real.minuto;
return minutosReal >= minutosEntrada && minutosReal < minutosSaidaEsperada;
});
return !temRegistroRealNoIntervalo;
});
// 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: Array<string | { content: string; styles?: { fillColor?: number[]; textColor?: number[]; fontStyle?: string } }> = [
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';
// Verificar se a saída esperada existe usando a mesma lógica melhorada
const saidaEsperadaExiste = todosRegistros.some((r, idx) => {
if (idx <= i) return false;
if (r.tipo !== tipoSaidaEsperado || r.real) return false;
// Verificar se há registro real no intervalo
const minutosEntrada = reg.hora * 60 + reg.minuto;
const minutosSaidaEsperada = r.hora * 60 + r.minuto;
const temRegistroRealNoIntervalo = regsReais.some((real) => {
if (real.tipo !== tipoSaidaEsperado) return false;
const minutosReal = real.hora * 60 + real.minuto;
return minutosReal >= minutosEntrada && minutosReal < minutosSaidaEsperada;
});
return !temRegistroRealNoIntervalo;
});
if (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: Array<[string, string]> = [
['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;
}
}
}
// ============================================
// SEÇÃO: DETALHAMENTO DE INCONSISTÊNCIAS
// ============================================
const todasInconsistencias = dias.flatMap((dia) =>
dia.inconsistencias.map((inc) => ({
...inc,
data: dia.data,
dataFormatada: dia.dataFormatada
}))
);
if (todasInconsistencias.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('DETALHAMENTO DE INCONSISTÊNCIAS', 15, yPosition);
doc.setFont('helvetica', 'normal');
doc.setTextColor(0, 0, 0);
yPosition += 10;
const inconsistenciasData = todasInconsistencias.map((inc) => {
const statusLabel =
inc.status === 'resolvida'
? 'Resolvida'
: inc.status === 'ignorada'
? 'Ignorada'
: 'Pendente';
const tipoLabel =
inc.tipo === 'ponto_com_atestado'
? 'Ponto com Atestado'
: inc.tipo === 'ponto_com_licenca'
? 'Ponto com Licença'
: inc.tipo === 'ponto_com_ausencia'
? 'Ponto com Ausência'
: inc.tipo === 'registro_duplicado'
? 'Registro Duplicado'
: inc.tipo === 'sequencia_invalida'
? 'Sequência Inválida'
: inc.tipo === 'saldo_inconsistente'
? 'Saldo Inconsistente'
: inc.tipo;
return [
inc.dataFormatada,
tipoLabel,
inc.descricao,
statusLabel,
inc.resolvidoEm
? `Resolvido em ${formatarDataDDMMAAAA(inc.resolvidoEm.toString())}`
: '-'
];
});
autoTable(doc, {
startY: yPosition,
head: [['Data', 'Tipo', 'Descrição', 'Status', 'Resolução']],
body: inconsistenciasData,
theme: 'grid',
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
styles: { fontSize: 9 },
columnStyles: {
0: { cellWidth: 30 },
1: { cellWidth: 40 },
2: { cellWidth: 60, overflow: 'linebreak' },
3: { cellWidth: 30 },
4: { cellWidth: 30 }
},
didParseCell: function (data) {
// Colorir status
if (data.column.index === 3) {
const status = data.cell.text[0] as string;
if (status === 'Pendente') {
data.cell.styles.textColor = [255, 140, 0]; // Laranja
data.cell.styles.fontStyle = 'bold';
} else if (status === 'Resolvida') {
data.cell.styles.textColor = [0, 128, 0]; // Verde
} else if (status === 'Ignorada') {
data.cell.styles.textColor = [128, 128, 128]; // Cinza
}
}
}
});
const lastPageInconsistencias = doc.getNumberOfPages();
doc.setPage(lastPageInconsistencias);
const finalYInconsistencias = (doc as { lastAutoTable?: { finalY: number } })
.lastAutoTable?.finalY;
if (finalYInconsistencias) {
yPosition = finalYInconsistencias + 10;
} else {
yPosition += inconsistenciasData.length * 7 + 10;
}
}
// ============================================
// SEÇÃO: AJUSTES DE BANCO DE HORAS
// ============================================
const todosAjustes = dias.flatMap((dia) =>
dia.ajustes.map((ajuste) => ({
...ajuste,
data: dia.data,
dataFormatada: dia.dataFormatada
}))
);
if (todosAjustes.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('AJUSTES DE BANCO DE HORAS', 15, yPosition);
doc.setFont('helvetica', 'normal');
doc.setTextColor(0, 0, 0);
yPosition += 10;
const ajustesData = todosAjustes.map((ajuste) => {
const tipoLabel =
ajuste.tipo === 'abonar'
? 'Abonar'
: ajuste.tipo === 'descontar'
? 'Descontar'
: 'Compensar';
const horas = Math.floor(Math.abs(ajuste.valorMinutos) / 60);
const minutos = Math.abs(ajuste.valorMinutos) % 60;
const sinal = ajuste.valorMinutos >= 0 ? '+' : '-';
const valorFormatado = `${sinal}${horas}h ${minutos}min`;
return [
ajuste.dataFormatada,
tipoLabel,
valorFormatado,
ajuste.motivoDescricao || '-',
ajuste.gestorId ? 'Gestor' : 'Automático'
];
});
autoTable(doc, {
startY: yPosition,
head: [['Data', 'Tipo', 'Valor', 'Motivo', 'Autor']],
body: ajustesData,
theme: 'grid',
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
styles: { fontSize: 9 },
columnStyles: {
0: { cellWidth: 30 },
1: { cellWidth: 30 },
2: { cellWidth: 30 },
3: { cellWidth: 50, overflow: 'linebreak' },
4: { cellWidth: 30 }
},
didParseCell: function (data) {
// Colorir valores
if (data.column.index === 2) {
const valor = data.cell.text[0] as string;
if (valor.startsWith('+')) {
data.cell.styles.textColor = [0, 128, 0]; // Verde
data.cell.styles.fontStyle = 'bold';
} else if (valor.startsWith('-')) {
data.cell.styles.textColor = [200, 0, 0]; // Vermelho
data.cell.styles.fontStyle = 'bold';
}
}
}
});
const lastPageAjustes = doc.getNumberOfPages();
doc.setPage(lastPageAjustes);
const finalYAjustes = (doc as { lastAutoTable?: { finalY: number } }).lastAutoTable?.finalY;
if (finalYAjustes) {
yPosition = finalYAjustes + 10;
} else {
yPosition += ajustesData.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é
adicionarRodape(doc);
// 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);
// Mensagens de erro mais específicas
if (errorMessage.includes('Configuração de ponto não encontrada')) {
toast.error('Configuração de ponto não encontrada. Entre em contato com o administrador.');
} else if (errorMessage.includes('Nenhum dado encontrado')) {
toast.error('Nenhum dado encontrado para este funcionário no período selecionado.');
} else if (errorMessage.includes('timeout') || errorMessage.includes('Timeout')) {
toast.error('Tempo de geração excedido. Tente um período menor (máximo 90 dias).');
} else {
toast.error(`Erro ao gerar ficha de ponto: ${errorMessage}`);
}
} finally {
carregando = false;
}
}
*/
function abrirModalDetalhes(registroId: Id<'registrosPonto'>) {
if (!registroId) {
console.error('Erro: registroId inválido');
return;
}
registroDetalhesId = registroId;
mostrarModalDetalhes = true;
}
function fecharModalDetalhes() {
mostrarModalDetalhes = false;
registroDetalhesId = '';
}
// Wrapper para a função importada imprimirDetalhesRegistro
async function imprimirDetalhesRegistroWrapper(registroId: Id<'registrosPonto'>) {
await imprimirDetalhesRegistro(client, registroId, logoGovPE, (message: string) =>
toast.error(message)
);
}
// Função antiga comentada (foi movida para módulo)
/*
async function imprimirDetalhesRegistroOld(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: Array<[string, string]> = [];
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: Array<[string, string]> = [
['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 (Servidor interno)']
];
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: Array<[string, string]> = [
['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: Array<[string, string]> = [];
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: Array<[string, string]> = [];
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: Array<[string, string]> = [];
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: Array<[string, string]> = [];
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: Array<[string, string]> = [
['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: Array<[string, string]> = [];
// 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>
<!-- Seção Integrada: Filtros e Registros -->
<div class="card bg-base-100 border-base-300 border shadow-xl">
<div class="card-body p-6">
<!-- Cabeçalho Unificado -->
<div class="mb-6 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div class="flex items-center gap-3">
<div class="from-primary/20 to-primary/10 rounded-xl bg-gradient-to-br p-3 shadow-sm">
<Clock class="text-primary h-6 w-6" strokeWidth={2.5} />
</div>
<div>
<h2 class="text-base-content text-2xl font-bold">Registros de Ponto</h2>
<p class="text-base-content/60 mt-1 text-sm">
Gerencie e visualize os registros de ponto dos funcionários
</p>
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<button type="button" class="btn btn-outline btn-sm gap-2" onclick={limparFiltros}>
<Filter class="h-4 w-4" />
Limpar Filtros
</button>
<button
type="button"
class="btn btn-primary btn-sm gap-2"
onclick={exportarCSV}
disabled={registrosFiltrados.length === 0}
>
<Download class="h-4 w-4" />
Exportar CSV
</button>
</div>
</div>
<!-- Filtros Compactos e Modernos -->
<div class="bg-base-200/50 border-base-300 mb-6 rounded-xl border p-4">
<div class="mb-3 flex items-center gap-2">
<Filter class="text-base-content/60 h-4 w-4" strokeWidth={2} />
<span class="text-base-content/70 text-sm font-semibold">Filtros de Busca</span>
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-5">
<div class="form-control">
<label class="label py-1" for="data-inicio-text">
<span class="label-text text-base-content/70 text-xs font-medium">Data Início</span>
</label>
<div class="relative">
<input
id="data-inicio-text"
type="text"
inputmode="numeric"
placeholder="dd/mm/aaaa"
value={dataInicioExibicao}
class="input input-bordered input-sm focus:input-primary pr-10"
oninput={(e) => {
const target = e.target as HTMLInputElement;
const valorComMascara = maskDate(target.value);
dataInicioExibicao = valorComMascara;
// Converter para formato backend quando completo
if (validateDate(valorComMascara)) {
dataInicioInterno = formatarDataParaBackendWrapper(valorComMascara);
// Atualizar o input date oculto
const dateInput = document.getElementById(
'data-inicio-date'
) as HTMLInputElement;
if (dateInput) {
dateInput.value = dataInicioInterno;
}
}
}}
onblur={() => {
// Validar e corrigir ao sair do campo
if (dataInicioExibicao && !validateDate(dataInicioExibicao)) {
toast.error('Data de início inválida. Use o formato dd/mm/aaaa');
dataInicioExibicao = formatarDataParaExibicao(dataInicioInterno);
} else if (dataInicioExibicao && validateDate(dataInicioExibicao)) {
dataInicioInterno = formatarDataParaBackendWrapper(dataInicioExibicao);
}
}}
/>
<input
id="data-inicio-date"
type="date"
value={dataInicioInterno}
class="sr-only"
onchange={(e) => {
const target = e.target as HTMLInputElement;
if (target.value) {
dataInicioInterno = target.value;
dataInicioExibicao = formatarDataParaExibicao(target.value);
}
}}
/>
<button
type="button"
class="btn btn-ghost btn-xs btn-circle absolute top-1/2 right-2 z-20 h-6 w-6 -translate-y-1/2 p-0"
onclick={() => {
const dateInput = document.getElementById('data-inicio-date') as HTMLInputElement;
if (dateInput?.showPicker) {
dateInput.showPicker();
} else {
// Fallback: clicar no input date
dateInput?.click();
}
}}
title="Abrir calendário"
>
<Calendar class="text-base-content/50 h-3.5 w-3.5" />
</button>
</div>
</div>
<div class="form-control">
<label class="label py-1" for="data-fim-text">
<span class="label-text text-base-content/70 text-xs font-medium">Data Fim</span>
</label>
<div class="relative">
<input
id="data-fim-text"
type="text"
inputmode="numeric"
placeholder="dd/mm/aaaa"
value={dataFimExibicao}
class="input input-bordered input-sm focus:input-primary pr-10"
oninput={(e) => {
const target = e.target as HTMLInputElement;
const valorComMascara = maskDate(target.value);
dataFimExibicao = valorComMascara;
// Converter para formato backend quando completo
if (validateDate(valorComMascara)) {
dataFimInterno = formatarDataParaBackendWrapper(valorComMascara);
// Atualizar o input date oculto
const dateInput = document.getElementById('data-fim-date') as HTMLInputElement;
if (dateInput) {
dateInput.value = dataFimInterno;
}
}
}}
onblur={() => {
// Validar e corrigir ao sair do campo
if (dataFimExibicao && !validateDate(dataFimExibicao)) {
toast.error('Data de fim inválida. Use o formato dd/mm/aaaa');
dataFimExibicao = formatarDataParaExibicao(dataFimInterno);
} else if (dataFimExibicao && validateDate(dataFimExibicao)) {
dataFimInterno = formatarDataParaBackendWrapper(dataFimExibicao);
}
}}
/>
<input
id="data-fim-date"
type="date"
value={dataFimInterno}
class="sr-only"
onchange={(e) => {
const target = e.target as HTMLInputElement;
if (target.value) {
dataFimInterno = target.value;
dataFimExibicao = formatarDataParaExibicao(target.value);
}
}}
/>
<button
type="button"
class="btn btn-ghost btn-xs btn-circle absolute top-1/2 right-2 z-20 h-6 w-6 -translate-y-1/2 p-0"
onclick={() => {
const dateInput = document.getElementById('data-fim-date') as HTMLInputElement;
if (dateInput?.showPicker) {
dateInput.showPicker();
} else {
// Fallback: clicar no input date
dateInput?.click();
}
}}
title="Abrir calendário"
>
<Calendar class="text-base-content/50 h-3.5 w-3.5" />
</button>
</div>
</div>
<div class="form-control">
<label class="label py-1" for="funcionario">
<span class="label-text text-base-content/70 text-xs font-medium">Funcionário</span>
</label>
<select
id="funcionario"
bind:value={funcionarioIdFiltro}
class="select select-bordered select-sm focus:select-primary"
>
<option value="">Todos os funcionários</option>
{#each funcionarios as funcionario (funcionario._id)}
<option value={funcionario._id}>{funcionario.nome}</option>
{/each}
</select>
</div>
<div class="form-control">
<label class="label py-1" for="status">
<span class="label-text text-base-content/70 text-xs font-medium">Status</span>
</label>
<select
id="status"
bind:value={statusFiltro}
class="select select-bordered select-sm focus:select-primary"
>
<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 py-1" for="localizacao">
<span class="label-text text-base-content/70 text-xs font-medium">Localização</span>
</label>
<select
id="localizacao"
bind:value={localizacaoFiltro}
class="select select-bordered select-sm focus:select-primary"
>
<option value="todos">Todas</option>
<option value="dentro">Dentro do Raio</option>
<option value="fora">Fora do Raio</option>
</select>
</div>
</div>
<!-- Badges de Filtros Ativos -->
{#if funcionarioIdFiltro || dataInicio || dataFim || statusFiltro !== 'todos' || localizacaoFiltro !== 'todos'}
<div class="border-base-300 mt-4 flex flex-wrap items-center gap-2 border-t pt-3">
<span class="text-base-content/60 text-xs font-medium">Filtros ativos:</span>
{#if funcionarioIdFiltro && funcionarioSelecionadoNome}
<div class="badge badge-primary badge-sm gap-1.5">
<Users class="h-3 w-3" />
{funcionarioSelecionadoNome}
</div>
{/if}
{#if dataInicio}
<div class="badge badge-info badge-sm gap-1.5">
<Clock class="h-3 w-3" />
De: {formatarDataDDMMAAAA(dataInicio)}
</div>
{/if}
{#if dataFim}
<div class="badge badge-info badge-sm gap-1.5">
<Clock class="h-3 w-3" />
Até: {formatarDataDDMMAAAA(dataFim)}
</div>
{/if}
{#if statusFiltro !== 'todos'}
<div class="badge badge-warning badge-sm">
Status: {statusFiltro === 'dentro' ? 'Dentro do Prazo' : 'Fora do Prazo'}
</div>
{/if}
{#if localizacaoFiltro !== 'todos'}
<div class="badge badge-secondary badge-sm">
Local: {localizacaoFiltro === 'dentro' ? 'Dentro do Raio' : 'Fora do Raio'}
</div>
{/if}
<div class="badge badge-ghost badge-sm">
{registrosFiltrados.length} registro(s)
</div>
</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-4">
{#each registrosAgrupados as grupo (grupo.funcionarioId)}
<div
class="card bg-base-100 border-base-300 border shadow-md transition-all duration-200 hover:shadow-lg"
>
<div class="card-body p-5">
<!-- Cabeçalho Modernizado -->
<div
class="border-base-300 mb-4 flex flex-col gap-4 border-b pb-4 lg:flex-row lg:items-center lg:justify-between"
>
<div class="flex items-start gap-3">
<div class="bg-primary/10 rounded-lg p-2.5">
<Users class="text-primary h-5 w-5" strokeWidth={2.5} />
</div>
<div class="flex-1">
<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/60 mt-1 text-sm">
Matrícula: <span class="font-semibold">{grupo.funcionario.matricula}</span
>
</p>
{/if}
{#if grupo.funcionario?.descricaoCargo}
<p class="text-base-content/50 mt-1 text-xs">
{grupo.funcionario.descricaoCargo}
</p>
{/if}
</div>
</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-lg border p-3 shadow-sm transition-all hover:shadow-md {saldoPositivo
? 'border-success/30 bg-success/5'
: 'border-error/30 bg-error/5'}"
>
<div class="flex items-center gap-2.5">
<div
class="rounded-lg p-1.5 {saldoPositivo
? 'bg-success/20'
: 'bg-error/20'}"
>
{#if saldoPositivo}
<TrendingUp class="text-success h-4 w-4" strokeWidth={2.5} />
{:else}
<TrendingDown class="text-error h-4 w-4" strokeWidth={2.5} />
{/if}
</div>
<div>
<p class="text-base-content/60 text-xs font-medium">Banco de Horas</p>
<p
class="text-base font-bold {saldoPositivo
? 'text-success'
: 'text-error'}"
>
{formatarSaldoHoras(saldoAcumulado)}
</p>
</div>
</div>
</div>
{/if}
{/key}
<button
class="btn btn-primary btn-sm gap-2"
onclick={(e) => {
e.preventDefault();
abrirModalImpressao(grupo.funcionarioId);
}}
>
<Printer class="h-4 w-4" />
Imprimir Ficha
</button>
</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 (grupoData.data)}
{@const totalRegistros = grupoData.registros.length}
{@const dataFormatada = formatarDataDDMMAAAA(grupoData.data)}
{@const saldosParciais = calcularSaldosParciais(
grupoData.registros.map((r) => ({
tipo: r.tipo,
hora: r.hora,
minuto: r.minuto,
_id: r._id
}))
)}
{@const isUltimoDia =
dataIndex === Object.values(grupo.registrosPorData).length - 1}
{#each grupoData.registros as registro, index (registro._id)}
{@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={gerarPDFComSelecaoWrapper}
/>
{/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()}
>
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="modal-box flex max-h-[90vh] max-w-4xl flex-col overflow-hidden"
onclick={(e) => e.stopPropagation()}
role="document"
>
<div
class="border-base-300 mb-4 flex flex-shrink-0 items-center justify-between border-b pb-4"
>
<h3 id="modal-title" class="text-xl font-bold">Detalhes do Registro de Ponto</h3>
<button class="btn btn-sm btn-circle btn-ghost" onclick={fecharModalDetalhes}>
<X class="h-4 w-4" />
</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 && registroDetalhesId}
<button
class="btn btn-primary gap-2"
onclick={() => {
if (registroDetalhesId) {
imprimirDetalhesRegistroWrapper(registroDetalhesId);
}
}}
>
<Printer class="h-4 w-4" />
Imprimir PDF
</button>
{/if}
<button class="btn btn-outline" onclick={fecharModalDetalhes}>Fechar</button>
</div>
</div>
<button
type="button"
class="modal-backdrop"
onclick={fecharModalDetalhes}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
fecharModalDetalhes();
}
}}
aria-label="Fechar modal"
></button>
</dialog>
{/if}