Files
sgse-app/apps/web/src/routes/(dashboard)/recursos-humanos/registro-pontos/+page.svelte
deyvisonwanderley d6aaa15cf4 feat: enhance point registration and location validation features
- Refactored the RegistroPonto component to improve the layout and user experience, including a new section for displaying standard hours.
- Updated RelogioSincronizado to include GMT offset adjustments for accurate time display.
- Introduced new location validation logic in the backend to ensure point registrations are within allowed geofenced areas.
- Enhanced the device information schema to capture additional GPS data, improving the reliability of location checks.
- Added new endpoints for managing allowed marking addresses, facilitating better control over where points can be registered.
2025-11-21 05:12:27 -03:00

2062 lines
68 KiB
Svelte

<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { useQuery, useConvexClient } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import { Clock, Filter, Download, Printer, BarChart3, Users, CheckCircle2, XCircle, TrendingUp, TrendingDown, FileText } from 'lucide-svelte';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { formatarHoraPonto, getTipoRegistroLabel } from '$lib/utils/ponto';
import jsPDF from 'jspdf';
import autoTable from 'jspdf-autotable';
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';
Chart.register(...registerables);
const client = useConvexClient();
// Estados
let dataInicio = $state(new Date().toISOString().split('T')[0]!);
let dataFim = $state(new Date().toISOString().split('T')[0]!);
let funcionarioIdFiltro = $state<Id<'funcionarios'> | ''>('');
let carregando = $state(false);
let mostrarModalImpressao = $state(false);
let funcionarioParaImprimir = $state<Id<'funcionarios'> | ''>('');
let chartCanvas: HTMLCanvasElement;
let chartInstance: Chart | null = null;
// Parâmetros reativos para queries
const registrosParams = $derived({
funcionarioId: funcionarioIdFiltro || undefined,
dataInicio,
dataFim,
});
const estatisticasParams = $derived({
dataInicio,
dataFim,
});
// Queries
const funcionariosQuery = useQuery(api.funcionarios.getAll, {});
const registrosQuery = useQuery(api.pontos.listarRegistrosPeriodo, registrosParams);
const estatisticasQuery = useQuery(api.pontos.obterEstatisticas, estatisticasParams);
const configQuery = useQuery(api.configuracaoPonto.obterConfiguracao, {});
const funcionarios = $derived(funcionariosQuery?.data || []);
const registros = $derived(registrosQuery?.data || []);
const estatisticas = $derived(estatisticasQuery?.data);
const config = $derived(configQuery?.data);
// Dados do gráfico baseados nas estatísticas
const chartData = $derived.by(() => {
if (!estatisticas) return null;
return {
labels: ['Estatísticas de Registros'],
datasets: [
{
label: 'Dentro do Prazo',
data: [estatisticas.dentroDoPrazo || 0],
backgroundColor: 'rgba(34, 197, 94, 0.8)',
borderColor: 'rgba(34, 197, 94, 1)',
borderWidth: 2,
},
{
label: 'Fora do Prazo',
data: [estatisticas.foraDoPrazo || 0],
backgroundColor: 'rgba(239, 68, 68, 0.8)',
borderColor: 'rgba(239, 68, 68, 1)',
borderWidth: 2,
}
]
};
});
// Função para criar/atualizar o gráfico
function criarGrafico() {
if (!chartCanvas || !estatisticas || !chartData) {
return;
}
const ctx = chartCanvas.getContext('2d');
if (!ctx) {
return;
}
// Destruir gráfico anterior se existir
if (chartInstance) {
chartInstance.destroy();
chartInstance = null;
}
try {
chartInstance = new Chart(ctx, {
type: 'bar',
data: chartData,
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
position: 'top',
labels: {
color: 'hsl(var(--bc))',
font: {
size: 12,
family: "'Inter', sans-serif",
},
usePointStyle: true,
padding: 15,
}
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.85)',
titleColor: '#fff',
bodyColor: '#fff',
borderColor: 'hsl(var(--p))',
borderWidth: 1,
padding: 12,
callbacks: {
label: function(context) {
const label = context.dataset.label || '';
const value = context.parsed.y;
const total = estatisticas.totalRegistros;
const percentage = total > 0 ? ((value / total) * 100).toFixed(1) : '0.0';
return `${label}: ${value} (${percentage}%)`;
}
}
}
},
scales: {
x: {
stacked: true,
grid: {
display: false,
},
ticks: {
color: 'hsl(var(--bc))',
font: {
size: 12,
}
}
},
y: {
stacked: true,
beginAtZero: true,
grid: {
color: 'rgba(0, 0, 0, 0.05)',
},
ticks: {
color: 'hsl(var(--bc))',
font: {
size: 11,
},
stepSize: 1,
}
}
},
animation: {
duration: 1000,
easing: 'easeInOutQuart'
},
interaction: {
mode: 'index',
intersect: false,
}
}
});
} catch (error) {
console.error('Erro ao criar gráfico:', error);
}
}
// Inicializar gráfico quando canvas e dados estiverem disponíveis
$effect(() => {
if (chartCanvas && estatisticas && chartData) {
// Aguardar um pouco para garantir que o canvas está renderizado
const timeoutId = setTimeout(() => {
criarGrafico();
}, 100);
return () => {
clearTimeout(timeoutId);
};
}
});
// Também tentar criar quando o canvas for montado
onMount(() => {
if (chartCanvas && estatisticas && chartData) {
setTimeout(() => {
criarGrafico();
}, 200);
}
});
onDestroy(() => {
if (chartInstance) {
chartInstance.destroy();
}
});
// Agrupar registros por funcionário e data
const registrosAgrupados = $derived.by(() => {
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 };
}
>;
}
> = {};
// Usar Set para evitar registros duplicados
const registrosProcessados = new Set<string>();
// Verificar se registros é um array válido
if (!Array.isArray(registros) || registros.length === 0) {
return [];
}
for (const registro of registros) {
// 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;
});
// Usar saldo diário da query se disponível, senão calcular
const primeiroRegistro = grupoData.registros[0];
if (primeiroRegistro && 'saldoDiario' in primeiroRegistro && primeiroRegistro.saldoDiario) {
grupoData.saldoDiario = primeiroRegistro.saldoDiario;
} else {
// Calcular saldo diário como diferença entre saída e entrada
grupoData.saldoDiario = calcularSaldoDiario(grupoData.registros);
}
}
}
}
return resultado;
});
// Query para banco de horas de cada funcionário
const funcionariosComBancoHoras = $derived.by(() => {
return registrosAgrupados.map((grupo) => grupo.funcionarioId);
});
// Função para formatar saldo de horas
function formatarSaldoHoras(minutos: number): string {
const horas = Math.floor(Math.abs(minutos) / 60);
const mins = Math.abs(minutos) % 60;
const sinal = minutos >= 0 ? '+' : '-';
return `${sinal}${horas}h ${mins}min`;
}
// Função para formatar saldo diário
function formatarSaldoDiario(saldo?: { saldoMinutos: number; horas: number; minutos: number; positivo: boolean }): string {
if (!saldo) return '-';
const sinal = saldo.positivo ? '+' : '-';
return `${sinal}${saldo.horas}h ${saldo.minutos}min`;
}
// Função para formatar data em português
function formatarData(data: string): string {
if (!data) return '';
const dataObj = new Date(data + 'T00:00:00');
return dataObj.toLocaleDateString('pt-BR', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
}
// Obter nome do funcionário selecionado
const funcionarioSelecionadoNome = $derived.by(() => {
if (!funcionarioIdFiltro) return null;
return funcionarios.find(f => f._id === funcionarioIdFiltro)?.nome || null;
});
// Função para calcular saldo diário como diferença entre saída e entrada
function calcularSaldoDiario(registros: Array<{ tipo: string; hora: number; minuto: number }>): { saldoMinutos: number; horas: number; minutos: number; positivo: boolean } | null {
if (registros.length === 0) return null;
// Ordenar registros por hora e minuto
const registrosOrdenados = [...registros].sort((a, b) => {
if (a.hora !== b.hora) {
return a.hora - b.hora;
}
return a.minuto - b.minuto;
});
// Buscar entrada (primeiro registro do tipo 'entrada')
const entrada = registrosOrdenados.find((r) => r.tipo === 'entrada');
// Buscar saída (último registro do tipo 'saida')
const saida = registrosOrdenados.filter((r) => r.tipo === 'saida').pop();
if (!entrada || !saida) return null;
// Calcular diferença em minutos
const minutosEntrada = entrada.hora * 60 + entrada.minuto;
const minutosSaida = saida.hora * 60 + saida.minuto;
// Se a saída for no dia seguinte (após meia-noite), adicionar 24 horas
let saldoMinutos = minutosSaida - minutosEntrada;
if (saldoMinutos < 0) {
saldoMinutos += 24 * 60; // Adicionar um dia em minutos
}
const horas = Math.floor(saldoMinutos / 60);
const minutos = saldoMinutos % 60;
return {
saldoMinutos,
horas,
minutos,
positivo: true, // Sempre positivo, pois é tempo trabalhado
};
}
function abrirModalImpressao(funcionarioId: Id<'funcionarios'>) {
funcionarioParaImprimir = funcionarioId;
mostrarModalImpressao = true;
}
async function gerarPDFComSelecao(sections: {
dadosFuncionario: boolean;
registrosPonto: boolean;
saldoDiario: boolean;
bancoHoras: boolean;
alteracoesGestor: boolean;
dispensasRegistro: boolean;
}) {
if (!funcionarioParaImprimir) return;
const funcionarioId = funcionarioParaImprimir;
// Verificar se pelo menos uma seção foi selecionada
if (!Object.values(sections).some((v) => v)) {
toast.error('Selecione pelo menos uma seção para imprimir');
return;
}
const funcionario = funcionarios.find((f) => f._id === funcionarioId);
if (!funcionario) {
toast.error('Funcionário não encontrado');
return;
}
// Buscar registros do funcionário no período selecionado
const registrosFuncionario = await client.query(api.pontos.listarRegistrosPeriodo, {
funcionarioId,
dataInicio,
dataFim,
});
if (!registrosFuncionario || registrosFuncionario.length === 0) {
toast.error('Nenhum registro encontrado para este funcionário no período selecionado');
return;
}
try {
const doc = new jsPDF();
// Logo
let yPosition = 20;
try {
const logoImg = new Image();
logoImg.src = logoGovPE;
await new Promise<void>((resolve, reject) => {
logoImg.onload = () => resolve();
logoImg.onerror = () => reject();
setTimeout(() => reject(), 3000);
});
const logoWidth = 25;
const aspectRatio = logoImg.height / logoImg.width;
const logoHeight = logoWidth * aspectRatio;
doc.addImage(logoImg, 'PNG', 15, 10, logoWidth, logoHeight);
yPosition = Math.max(20, 10 + logoHeight / 2);
} catch (err) {
console.warn('Não foi possível carregar a logo:', err);
}
// Cabeçalho
doc.setFontSize(16);
doc.setTextColor(41, 128, 185);
doc.text('FICHA DE PONTO', 105, yPosition, { align: 'center' });
yPosition += 10;
// Dados do Funcionário
if (sections.dadosFuncionario) {
doc.setFontSize(12);
doc.setTextColor(0, 0, 0);
doc.setFont('helvetica', 'bold');
doc.text('DADOS DO FUNCIONÁRIO', 15, yPosition);
doc.setFont('helvetica', 'normal');
yPosition += 8;
doc.setFontSize(10);
if (funcionario.matricula) {
doc.text(`Matrícula: ${funcionario.matricula}`, 15, yPosition);
yPosition += 6;
}
doc.text(`Nome: ${funcionario.nome}`, 15, yPosition);
yPosition += 6;
if (funcionario.descricaoCargo) {
doc.text(`Cargo/Função: ${funcionario.descricaoCargo}`, 15, yPosition);
yPosition += 6;
}
yPosition += 5;
// Formatar período para exibição
const dataInicioParts = dataInicio.split('-');
const dataFimParts = dataFim.split('-');
const periodoFormatado = `${dataInicioParts[2]}/${dataInicioParts[1]}/${dataInicioParts[0]} a ${dataFimParts[2]}/${dataFimParts[1]}/${dataFimParts[0]}`;
doc.text(`Período: ${periodoFormatado}`, 15, yPosition);
yPosition += 10;
}
// Buscar homologações e dispensas
let homologacoes: Array<{
_id: Id<'homologacoesPonto'>;
criadoEm: number;
registroId?: Id<'registrosPonto'>;
horaAnterior?: number;
minutoAnterior?: number;
horaNova?: number;
minutoNova?: number;
tipoAjuste?: 'compensar' | 'abonar' | 'descontar';
periodoDias?: number;
periodoHoras?: number;
periodoMinutos?: number;
motivoDescricao?: string;
motivoTipo?: string;
observacoes?: string;
}> = [];
let dispensas: Array<{
dataInicio: string;
dataFim: string;
horaInicio: number;
minutoInicio: number;
horaFim: number;
minutoFim: number;
motivo: string;
isento: boolean;
}> = [];
if (sections.alteracoesGestor) {
try {
homologacoes = await client.query(api.pontos.listarHomologacoes, {
funcionarioId,
}) || [];
} catch (error) {
console.warn('Erro ao buscar homologações:', error);
// Continuar mesmo se houver erro ao buscar homologações
}
}
if (sections.dispensasRegistro) {
try {
dispensas = await client.query(api.pontos.listarDispensas, {
funcionarioId,
apenasAtivas: false,
}) || [];
} catch (error) {
console.warn('Erro ao buscar dispensas:', error);
// Continuar mesmo se houver erro ao buscar dispensas
}
}
// Tabela de registros
if (sections.registrosPonto) {
const config = await client.query(api.configuracaoPonto.obterConfiguracao, {});
const tableData: string[][] = [];
// Agrupar por data para incluir saldo diário
const registrosPorData: Record<
string,
Array<{
data: string;
tipo: string;
hora: number;
minuto: number;
dentroDoPrazo: boolean;
}>
> = {};
for (const r of registrosFuncionario) {
const dataKey = r.data;
if (!registrosPorData[dataKey]) {
registrosPorData[dataKey] = [];
}
registrosPorData[dataKey]!.push({
data: r.data,
tipo: r.tipo,
hora: r.hora,
minuto: r.minuto,
dentroDoPrazo: r.dentroDoPrazo,
});
}
// Criar dados da tabela com saldo diário
for (const [data, regs] of Object.entries(registrosPorData)) {
// Formatar data para exibição (DD/MM/YYYY)
const dataParts = data.split('-');
const dataFormatada = `${dataParts[2]}/${dataParts[1]}/${dataParts[0]}`;
// Calcular saldo diário como diferença entre saída e entrada
const saldoDiarioDia = calcularSaldoDiario(regs);
for (const reg of regs) {
const linha: 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),
];
// Saldo Diário sempre após Horário
if (sections.saldoDiario) {
if (saldoDiarioDia) {
const sinal = saldoDiarioDia.positivo ? '+' : '-';
linha.push(`${sinal}${saldoDiarioDia.horas}h ${saldoDiarioDia.minutos}min`);
} else {
linha.push('-');
}
}
linha.push(reg.dentroDoPrazo ? 'Sim' : 'Não');
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 },
});
// 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,
});
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;
doc.setFontSize(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`;
doc.setFont('helvetica', 'bold');
doc.text('Saldo Atual:', 15, yPosition);
doc.setFont('helvetica', 'normal');
doc.text(saldoFormatado, 60, yPosition);
yPosition += 8;
if (saldoMinutos > 0) {
doc.setFont('helvetica', 'bold');
doc.setTextColor(0, 128, 0);
doc.text('Horas Excedentes:', 15, yPosition);
doc.setFont('helvetica', 'normal');
doc.text(`${horas}h ${minutos}min`, 75, yPosition);
doc.setTextColor(0, 0, 0);
yPosition += 8;
}
if (saldoMinutos < 0) {
doc.setFont('helvetica', 'bold');
doc.setTextColor(200, 0, 0);
doc.text('Horas a Pagar:', 15, yPosition);
doc.setFont('helvetica', 'normal');
doc.text(`${horas}h ${minutos}min`, 70, yPosition);
doc.setTextColor(0, 0, 0);
yPosition += 8;
}
doc.setFont('helvetica', 'bold');
doc.text('Total de Dias com Registro:', 15, yPosition);
doc.setFont('helvetica', 'normal');
doc.text(`${bancoHoras.totalDias} dias`, 95, yPosition);
yPosition += 10;
} else {
doc.text('Banco de horas não disponível', 15, yPosition);
yPosition += 10;
}
}
// 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
const dataCriacao = new Date(h.criadoEm);
const dataFormatada = `${dataCriacao.getDate().toString().padStart(2, '0')}/${(dataCriacao.getMonth() + 1).toString().padStart(2, '0')}/${dataCriacao.getFullYear()}`;
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 },
});
const lastPage = doc.getNumberOfPages();
doc.setPage(lastPage);
const finalY = (doc as { lastAutoTable?: { finalY: number } }).lastAutoTable?.finalY;
if (finalY) {
yPosition = finalY + 10;
} else {
yPosition += homologacoesData.length * 7 + 10;
}
}
}
// Dispensas de Registro
if (sections.dispensasRegistro && dispensas.length > 0) {
if (yPosition > doc.internal.pageSize.getHeight() - 60) {
doc.addPage();
yPosition = 20;
}
doc.setFontSize(12);
doc.setFont('helvetica', 'bold');
doc.setTextColor(41, 128, 185);
doc.text('DISPENSAS DE REGISTRO', 15, yPosition);
doc.setFont('helvetica', 'normal');
doc.setTextColor(0, 0, 0);
yPosition += 10;
const dispensasData = dispensas.map((d) => {
// Formatar data de início
const dataInicioParts = d.dataInicio.split('-');
const dataInicioFormatada = `${dataInicioParts[2]}/${dataInicioParts[1]}/${dataInicioParts[0]}`;
// Formatar data de fim
const dataFimParts = d.dataFim.split('-');
const dataFimFormatada = `${dataFimParts[2]}/${dataFimParts[1]}/${dataFimParts[0]}`;
return [
`${dataInicioFormatada} ${d.horaInicio.toString().padStart(2, '0')}:${d.minutoInicio.toString().padStart(2, '0')}`,
`${dataFimFormatada} ${d.horaFim.toString().padStart(2, '0')}:${d.minutoFim.toString().padStart(2, '0')}`,
d.motivo,
d.isento ? 'Isento (sem expiração)' : 'Temporária',
];
});
autoTable(doc, {
startY: yPosition,
head: [['Início', 'Fim', 'Motivo', 'Tipo']],
body: dispensasData,
theme: 'grid',
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
styles: { fontSize: 9 },
});
const lastPage = doc.getNumberOfPages();
doc.setPage(lastPage);
const finalY = (doc as { lastAutoTable?: { finalY: number } }).lastAutoTable?.finalY;
if (finalY) {
yPosition = finalY + 10;
} else {
yPosition += dispensasData.length * 7 + 10;
}
}
// Rodapé
const pageCount = doc.getNumberOfPages();
for (let i = 1; i <= pageCount; i++) {
doc.setPage(i);
doc.setFontSize(8);
doc.setTextColor(128, 128, 128);
doc.text(
`SGSE - Sistema de Gerenciamento de Secretaria | Página ${i} de ${pageCount}`,
doc.internal.pageSize.getWidth() / 2,
doc.internal.pageSize.getHeight() - 10,
{ align: 'center' }
);
}
// Salvar
const nomeArquivo = `ficha-ponto-${funcionario.matricula || funcionario.nome}-${dataInicio}-${dataFim}.pdf`;
doc.save(nomeArquivo);
// Fechar modal após gerar PDF
mostrarModalImpressao = false;
funcionarioParaImprimir = '';
toast.success('PDF gerado com sucesso!');
} catch (error) {
console.error('Erro ao gerar PDF:', error);
const errorMessage = error instanceof Error ? error.message : String(error);
toast.error(`Erro ao gerar ficha de ponto: ${errorMessage}`);
}
}
async function imprimirDetalhesRegistro(registroId: Id<'registrosPonto'>) {
try {
// Buscar dados completos do registro
const registro = await client.query(api.pontos.obterRegistro, { registroId });
if (!registro) {
alert('Registro não encontrado');
return;
}
const doc = new jsPDF();
// Logo
let yPosition = 20;
try {
const logoImg = new Image();
logoImg.src = logoGovPE;
await new Promise<void>((resolve, reject) => {
logoImg.onload = () => resolve();
logoImg.onerror = () => reject();
setTimeout(() => reject(), 3000);
});
const logoWidth = 25;
const aspectRatio = logoImg.height / logoImg.width;
const logoHeight = logoWidth * aspectRatio;
doc.addImage(logoImg, 'PNG', 15, 10, logoWidth, logoHeight);
yPosition = Math.max(20, 10 + logoHeight / 2);
} catch (err) {
console.warn('Não foi possível carregar a logo:', err);
}
// Cabeçalho
doc.setFontSize(16);
doc.setTextColor(41, 128, 185);
doc.text('DETALHES DO REGISTRO DE PONTO', 105, yPosition, { align: 'center' });
yPosition += 15;
// Informações do Funcionário
doc.setFontSize(12);
doc.setTextColor(0, 0, 0);
doc.setFont('helvetica', 'bold');
doc.text('DADOS DO FUNCIONÁRIO', 15, yPosition);
doc.setFont('helvetica', 'normal');
yPosition += 8;
doc.setFontSize(10);
if (registro.funcionario) {
if (registro.funcionario.matricula) {
doc.text(`Matrícula: ${registro.funcionario.matricula}`, 15, yPosition);
yPosition += 6;
}
doc.text(`Nome: ${registro.funcionario.nome}`, 15, yPosition);
yPosition += 6;
if (registro.funcionario.descricaoCargo) {
doc.text(`Cargo/Função: ${registro.funcionario.descricaoCargo}`, 15, yPosition);
yPosition += 6;
}
}
yPosition += 5;
// Informações do Registro
doc.setFont('helvetica', 'bold');
doc.text('DADOS DO REGISTRO', 15, yPosition);
doc.setFont('helvetica', 'normal');
yPosition += 8;
doc.setFontSize(10);
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);
doc.text(`Tipo: ${tipoLabel}`, 15, yPosition);
yPosition += 6;
const dataHora = `${registro.data} ${registro.hora.toString().padStart(2, '0')}:${registro.minuto.toString().padStart(2, '0')}:${registro.segundo.toString().padStart(2, '0')}`;
doc.text(`Data e Hora: ${dataHora}`, 15, yPosition);
yPosition += 6;
doc.text(`Status: ${registro.dentroDoPrazo ? 'Dentro do Prazo' : 'Fora do Prazo'}`, 15, yPosition);
yPosition += 6;
doc.text(`Tolerância: ${registro.toleranciaMinutos} minutos`, 15, yPosition);
yPosition += 6;
doc.text(
`Sincronizado: ${registro.sincronizadoComServidor ? 'Sim (Servidor)' : 'Não (PC Local)'}`,
15,
yPosition
);
yPosition += 6;
if (registro.justificativa) {
doc.text(`Justificativa: ${registro.justificativa}`, 15, yPosition);
yPosition += 6;
}
yPosition += 5;
// Localização
if (registro.latitude && registro.longitude) {
// Verificar se precisa de nova página
if (yPosition > 200) {
doc.addPage();
yPosition = 20;
}
doc.setFont('helvetica', 'bold');
doc.text('LOCALIZAÇÃO', 15, yPosition);
doc.setFont('helvetica', 'normal');
yPosition += 8;
doc.setFontSize(10);
doc.text(`Latitude: ${registro.latitude.toFixed(6)}`, 15, yPosition);
yPosition += 6;
doc.text(`Longitude: ${registro.longitude.toFixed(6)}`, 15, yPosition);
yPosition += 6;
if (registro.precisao) {
doc.text(`Precisão: ${registro.precisao.toFixed(2)} metros`, 15, yPosition);
yPosition += 6;
}
if (registro.endereco) {
doc.text(`Endereço: ${registro.endereco}`, 15, yPosition);
yPosition += 6;
}
if (registro.cidade) {
doc.text(`Cidade: ${registro.cidade}`, 15, yPosition);
yPosition += 6;
}
if (registro.estado) {
doc.text(`Estado: ${registro.estado}`, 15, yPosition);
yPosition += 6;
}
if (registro.pais) {
doc.text(`País: ${registro.pais}`, 15, yPosition);
yPosition += 6;
}
if (registro.timezone) {
doc.text(`Fuso Horário: ${registro.timezone}`, 15, yPosition);
yPosition += 6;
}
yPosition += 5;
}
// 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.setFont('helvetica', 'bold');
doc.setFontSize(12);
doc.text('VALIDAÇÃO DE LOCALIZAÇÃO GPS', 15, yPosition);
doc.setFont('helvetica', 'normal');
doc.setFontSize(10);
yPosition += 10;
// Informações detalhadas do GPS
doc.setFont('helvetica', 'bold');
doc.text('Dados do GPS:', 15, yPosition);
doc.setFont('helvetica', 'normal');
yPosition += 6;
if (registro.precisao !== null && registro.precisao !== undefined) {
doc.text(` Precisão: ${registro.precisao.toFixed(2)} metros`, 20, yPosition);
yPosition += 6;
}
if (registro.altitude !== null && registro.altitude !== undefined) {
doc.text(` Altitude: ${registro.altitude.toFixed(2)} metros`, 20, yPosition);
yPosition += 6;
}
if (registro.altitudeAccuracy !== null && registro.altitudeAccuracy !== undefined) {
doc.text(` Precisão da Altitude: ${registro.altitudeAccuracy.toFixed(2)} metros`, 20, yPosition);
yPosition += 6;
}
if (registro.heading !== null && registro.heading !== undefined) {
doc.text(` Direção (Heading): ${registro.heading.toFixed(2)}°`, 20, yPosition);
yPosition += 6;
}
if (registro.speed !== null && registro.speed !== undefined) {
doc.text(` Velocidade: ${(registro.speed * 3.6).toFixed(2)} km/h`, 20, yPosition);
yPosition += 6;
}
yPosition += 3;
// Confiabilidade e Scores
doc.setFont('helvetica', 'bold');
doc.text('Confiabilidade:', 15, yPosition);
doc.setFont('helvetica', 'normal');
yPosition += 6;
if (registro.confiabilidadeGPS !== null && registro.confiabilidadeGPS !== undefined) {
const confiabilidadePercent = (registro.confiabilidadeGPS * 100).toFixed(1);
const confiabilidadeCor = registro.confiabilidadeGPS >= 0.7 ? [0, 128, 0] : registro.confiabilidadeGPS >= 0.4 ? [255, 165, 0] : [255, 0, 0];
doc.setTextColor(confiabilidadeCor[0], confiabilidadeCor[1], confiabilidadeCor[2]);
doc.text(` Confiabilidade GPS (Frontend): ${confiabilidadePercent}%`, 20, yPosition);
doc.setTextColor(0, 0, 0);
yPosition += 6;
}
if (registro.scoreConfiancaBackend !== null && registro.scoreConfiancaBackend !== undefined) {
const scorePercent = (registro.scoreConfiancaBackend * 100).toFixed(1);
const scoreCor = registro.scoreConfiancaBackend >= 0.7 ? [0, 128, 0] : registro.scoreConfiancaBackend >= 0.4 ? [255, 165, 0] : [255, 0, 0];
doc.setTextColor(scoreCor[0], scoreCor[1], scoreCor[2]);
doc.text(` Score de Confiança (Backend): ${scorePercent}%`, 20, yPosition);
doc.setTextColor(0, 0, 0);
yPosition += 6;
}
yPosition += 3;
// Status de Validação
if (registro.suspeitaSpoofing !== null && registro.suspeitaSpoofing !== undefined) {
doc.setFont('helvetica', 'bold');
doc.text('Status de Validação:', 15, yPosition);
doc.setFont('helvetica', 'normal');
yPosition += 6;
if (registro.suspeitaSpoofing) {
doc.setTextColor(255, 0, 0);
doc.setFont('helvetica', 'bold');
doc.text(' ⚠️ MARCAÇÃO SUSPEITA DETECTADA', 20, yPosition);
doc.setFont('helvetica', 'normal');
doc.setTextColor(0, 0, 0);
yPosition += 6;
} else {
doc.setTextColor(0, 128, 0);
doc.setFont('helvetica', 'bold');
doc.text(' ✓ Localização validada com sucesso', 20, yPosition);
doc.setFont('helvetica', 'normal');
doc.setTextColor(0, 0, 0);
yPosition += 6;
}
if (registro.motivoSuspeita) {
doc.setTextColor(255, 0, 0);
const motivoLines = doc.splitTextToSize(` Motivo: ${registro.motivoSuspeita}`, 170);
doc.text(motivoLines, 20, yPosition);
yPosition += motivoLines.length * 5;
doc.setTextColor(0, 0, 0);
}
yPosition += 3;
}
// Avisos de Validação
if (registro.avisosValidacao && registro.avisosValidacao.length > 0) {
doc.setFont('helvetica', 'bold');
doc.text('Avisos de Validação:', 15, yPosition);
doc.setFont('helvetica', 'normal');
yPosition += 6;
registro.avisosValidacao.forEach((aviso: string) => {
const avisoLines = doc.splitTextToSize(` • ${aviso}`, 170);
doc.text(avisoLines, 20, yPosition);
yPosition += avisoLines.length * 5;
});
yPosition += 3;
}
// Análise de Propriedades GPS
doc.setFont('helvetica', 'bold');
doc.text('Análise de Propriedades GPS:', 15, yPosition);
doc.setFont('helvetica', 'normal');
yPosition += 6;
let propriedadesGPS = 0;
let propriedadesTotais = 5;
if (registro.altitude !== null && registro.altitude !== undefined && registro.altitude !== 0) {
doc.text(' ✓ Altitude disponível', 20, yPosition);
propriedadesGPS++;
} else {
doc.text(' ✗ Altitude não disponível', 20, yPosition);
}
yPosition += 5;
if (registro.altitudeAccuracy !== null && registro.altitudeAccuracy !== undefined && registro.altitudeAccuracy > 0) {
doc.text(' ✓ Precisão de altitude disponível', 20, yPosition);
propriedadesGPS++;
} else {
doc.text(' ✗ Precisão de altitude não disponível', 20, yPosition);
}
yPosition += 5;
if (registro.heading !== null && registro.heading !== undefined && !isNaN(registro.heading)) {
doc.text(' ✓ Direção (heading) disponível', 20, yPosition);
propriedadesGPS++;
} else {
doc.text(' ✗ Direção (heading) não disponível', 20, yPosition);
}
yPosition += 5;
if (registro.speed !== null && registro.speed !== undefined && !isNaN(registro.speed)) {
doc.text(' ✓ Velocidade disponível', 20, yPosition);
propriedadesGPS++;
} else {
doc.text(' ✗ Velocidade não disponível', 20, yPosition);
}
yPosition += 5;
if (registro.precisao !== null && registro.precisao !== undefined && registro.precisao < 20) {
doc.text(' ✓ Alta precisão GPS (< 20m)', 20, yPosition);
propriedadesGPS++;
} else if (registro.precisao !== null && registro.precisao !== undefined && registro.precisao >= 20 && registro.precisao < 100) {
doc.text(' ⚠ Precisão média GPS (20-100m)', 20, yPosition);
propriedadesGPS += 0.5;
} else {
doc.text(' ✗ Baixa precisão GPS (> 100m)', 20, yPosition);
}
yPosition += 5;
// 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];
doc.setFont('helvetica', 'bold');
doc.setTextColor(qualidadeCor[0], qualidadeCor[1], qualidadeCor[2]);
doc.text(`Qualidade GPS: ${qualidadeTexto} (${qualidadeGPS.toFixed(0)}% das propriedades)`, 20, yPosition);
doc.setFont('helvetica', 'normal');
doc.setTextColor(0, 0, 0);
yPosition += 8;
}
// 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.setFont('helvetica', 'bold');
doc.setFontSize(12);
doc.text('VALIDAÇÃO DE LOCALIZAÇÃO PERMITIDA', 15, yPosition);
doc.setFont('helvetica', 'normal');
doc.setFontSize(10);
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);
}
}
doc.setFont('helvetica', 'bold');
doc.text('Endereço Esperado:', 15, yPosition);
doc.setFont('helvetica', 'normal');
yPosition += 6;
doc.text(` Nome: ${enderecoEsperadoNome}`, 20, yPosition);
yPosition += 6;
const enderecoLines = doc.splitTextToSize(` Endereço: ${enderecoEsperadoEndereco}`, 170);
doc.text(enderecoLines, 20, yPosition);
yPosition += enderecoLines.length * 5 + 3;
if (enderecoEsperadoLatitude !== null && enderecoEsperadoLongitude !== null) {
doc.text(` Coordenadas: ${enderecoEsperadoLatitude.toFixed(6)}, ${enderecoEsperadoLongitude.toFixed(6)}`, 20, yPosition);
yPosition += 6;
}
yPosition += 3;
doc.setFont('helvetica', 'bold');
doc.text('Localização do Registro:', 15, yPosition);
doc.setFont('helvetica', 'normal');
yPosition += 6;
doc.text(` Coordenadas: ${registro.latitude.toFixed(6)}, ${registro.longitude.toFixed(6)}`, 20, yPosition);
yPosition += 6;
if (registro.distanciaEnderecoEsperado !== null && registro.distanciaEnderecoEsperado !== undefined) {
const distanciaKm = (registro.distanciaEnderecoEsperado / 1000).toFixed(2);
const distanciaMetros = registro.distanciaEnderecoEsperado.toFixed(0);
if (registro.distanciaEnderecoEsperado >= 1000) {
doc.text(` Distância: ${distanciaKm} km (${distanciaMetros} metros)`, 20, yPosition);
} else {
doc.text(` Distância: ${distanciaMetros} metros`, 20, yPosition);
}
yPosition += 6;
}
yPosition += 3;
doc.setFont('helvetica', 'bold');
doc.text('Raio Permitido:', 15, yPosition);
doc.setFont('helvetica', 'normal');
yPosition += 6;
if (registro.raioToleranciaUsado !== null && registro.raioToleranciaUsado !== undefined) {
const raioKm = (registro.raioToleranciaUsado / 1000).toFixed(2);
const raioMetros = registro.raioToleranciaUsado.toFixed(0);
if (registro.raioToleranciaUsado >= 1000) {
doc.text(` ${raioKm} km (${raioMetros} metros)`, 20, yPosition);
} else {
doc.text(` ${raioMetros} metros`, 20, yPosition);
}
yPosition += 6;
} else {
doc.text(' Não configurado', 20, yPosition);
yPosition += 6;
}
yPosition += 3;
// Status da validação
doc.setFont('helvetica', 'bold');
doc.text('Status:', 15, yPosition);
doc.setFont('helvetica', 'normal');
yPosition += 6;
if (registro.dentroRaioPermitido === true) {
doc.setTextColor(0, 128, 0);
doc.setFont('helvetica', 'bold');
doc.text(' ✓ DENTRO DO RAIO PERMITIDO', 20, yPosition);
doc.setFont('helvetica', 'normal');
doc.setTextColor(0, 0, 0);
} else if (registro.dentroRaioPermitido === false) {
doc.setTextColor(255, 0, 0);
doc.setFont('helvetica', 'bold');
doc.text(' ⚠️ FORA DO RAIO PERMITIDO', 20, yPosition);
doc.setFont('helvetica', 'normal');
doc.setTextColor(0, 0, 0);
yPosition += 6;
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);
if (distanciaExcedente >= 1000) {
doc.text(` ${distanciaExcedenteKm} km além do permitido`, 20, yPosition);
} else {
doc.text(` ${distanciaExcedenteMetros} metros além do permitido`, 20, yPosition);
}
}
yPosition += 6;
doc.setFont('helvetica', 'normal');
doc.setTextColor(0, 0, 0);
doc.setFontSize(9);
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;
doc.setFontSize(10);
} else {
doc.text(' Não validado', 20, yPosition);
}
yPosition += 8;
} else {
doc.text('Validação de localização permitida não configurada para este registro.', 15, yPosition);
yPosition += 8;
}
}
// Dados Técnicos
// Verificar se precisa de nova página
if (yPosition > 200) {
doc.addPage();
yPosition = 20;
}
doc.setFont('helvetica', 'bold');
doc.text('DADOS TÉCNICOS', 15, yPosition);
doc.setFont('helvetica', 'normal');
yPosition += 8;
doc.setFontSize(10);
// Informações de Rede
if (registro.ipAddress || registro.ipPublico || registro.ipLocal) {
doc.setFont('helvetica', 'bold');
doc.text('Rede:', 15, yPosition);
doc.setFont('helvetica', 'normal');
yPosition += 6;
if (registro.ipAddress) {
doc.text(` IP: ${registro.ipAddress}`, 20, yPosition);
yPosition += 6;
}
if (registro.ipPublico) {
doc.text(` IP Público: ${registro.ipPublico}`, 20, yPosition);
yPosition += 6;
}
if (registro.ipLocal) {
doc.text(` IP Local: ${registro.ipLocal}`, 20, yPosition);
yPosition += 6;
}
yPosition += 3;
}
// Informações do Navegador
if (registro.browser || registro.userAgent) {
doc.setFont('helvetica', 'bold');
doc.text('Navegador:', 15, yPosition);
doc.setFont('helvetica', 'normal');
yPosition += 6;
if (registro.browser) {
doc.text(` Navegador: ${registro.browser}${registro.browserVersion ? ` ${registro.browserVersion}` : ''}`, 20, yPosition);
yPosition += 6;
}
if (registro.engine) {
doc.text(` Engine: ${registro.engine}`, 20, yPosition);
yPosition += 6;
}
if (registro.userAgent) {
// Quebrar user agent em múltiplas linhas se necessário
const userAgentLines = doc.splitTextToSize(` User Agent: ${registro.userAgent}`, 170);
doc.text(userAgentLines, 20, yPosition);
yPosition += userAgentLines.length * 6;
}
yPosition += 3;
}
// Informações do Sistema
if (registro.sistemaOperacional || registro.arquitetura) {
doc.setFont('helvetica', 'bold');
doc.text('Sistema:', 15, yPosition);
doc.setFont('helvetica', 'normal');
yPosition += 6;
if (registro.sistemaOperacional) {
doc.text(` SO: ${registro.sistemaOperacional}${registro.osVersion ? ` ${registro.osVersion}` : ''}`, 20, yPosition);
yPosition += 6;
}
if (registro.arquitetura) {
doc.text(` Arquitetura: ${registro.arquitetura}`, 20, yPosition);
yPosition += 6;
}
if (registro.plataforma) {
doc.text(` Plataforma: ${registro.plataforma}`, 20, yPosition);
yPosition += 6;
}
yPosition += 3;
}
// Informações do Dispositivo
if (registro.deviceType || registro.screenResolution) {
doc.setFont('helvetica', 'bold');
doc.text('Dispositivo:', 15, yPosition);
doc.setFont('helvetica', 'normal');
yPosition += 6;
if (registro.deviceType) {
doc.text(` Tipo: ${registro.deviceType}`, 20, yPosition);
yPosition += 6;
}
if (registro.deviceModel) {
doc.text(` Modelo: ${registro.deviceModel}`, 20, yPosition);
yPosition += 6;
}
if (registro.screenResolution) {
doc.text(` Resolução: ${registro.screenResolution}`, 20, yPosition);
yPosition += 6;
}
if (registro.coresTela) {
doc.text(` Cores: ${registro.coresTela}`, 20, yPosition);
yPosition += 6;
}
if (registro.isMobile || registro.isTablet || registro.isDesktop) {
const tipoDispositivo = registro.isMobile ? 'Mobile' : registro.isTablet ? 'Tablet' : 'Desktop';
doc.text(` Categoria: ${tipoDispositivo}`, 20, yPosition);
yPosition += 6;
}
if (registro.idioma) {
doc.text(` Idioma: ${registro.idioma}`, 20, yPosition);
yPosition += 6;
}
if (registro.connectionType) {
doc.text(` Conexão: ${registro.connectionType}`, 20, yPosition);
yPosition += 6;
}
if (registro.memoryInfo) {
doc.text(` Memória: ${registro.memoryInfo}`, 20, yPosition);
yPosition += 6;
}
yPosition += 3;
}
// 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é
const pageCount = doc.getNumberOfPages();
for (let i = 1; i <= pageCount; i++) {
doc.setPage(i);
doc.setFontSize(8);
doc.setTextColor(128, 128, 128);
doc.text(
`SGSE - Sistema de Gerenciamento de Secretaria | Página ${i} de ${pageCount}`,
doc.internal.pageSize.getWidth() / 2,
doc.internal.pageSize.getHeight() - 10,
{ align: 'center' }
);
}
// Salvar
const nomeArquivo = `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 px-4 py-6 max-w-7xl">
<!-- Header -->
<section class="relative mb-8 overflow-hidden rounded-2xl border border-base-300 bg-gradient-to-br from-primary/10 via-base-100 to-secondary/10 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="p-4 bg-primary/20 rounded-2xl backdrop-blur-sm border border-primary/30 shadow-lg">
<Clock class="h-10 w-10 text-primary" strokeWidth={2.5} />
</div>
<div class="max-w-3xl space-y-2">
<h1 class="text-4xl font-black text-base-content leading-tight 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="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<!-- Total de Registros -->
<div class="card bg-gradient-to-br from-blue-500/10 to-blue-600/20 border border-blue-500/20 shadow-xl hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-1">
<div class="card-body">
<div class="flex items-center justify-between">
<div class="flex-1">
<p class="text-sm text-base-content/70 font-semibold mb-1">Total de Registros</p>
<p class="text-3xl font-bold text-base-content">{estatisticas.totalRegistros}</p>
</div>
<div class="p-3 bg-blue-500/20 rounded-xl">
<BarChart3 class="h-8 w-8 text-blue-600" strokeWidth={2.5} />
</div>
</div>
</div>
</div>
<!-- Dentro do Prazo -->
<div class="card bg-gradient-to-br from-green-500/10 to-green-600/20 border border-green-500/20 shadow-xl hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-1">
<div class="card-body">
<div class="flex items-center justify-between">
<div class="flex-1">
<p class="text-sm text-base-content/70 font-semibold mb-1">Dentro do Prazo</p>
<p class="text-3xl font-bold text-green-600">{estatisticas.dentroDoPrazo}</p>
<p class="text-xs text-base-content/60 mt-1">
{estatisticas.totalRegistros > 0
? ((estatisticas.dentroDoPrazo / estatisticas.totalRegistros) * 100).toFixed(1)
: 0}% do total
</p>
</div>
<div class="p-3 bg-green-500/20 rounded-xl">
<CheckCircle2 class="h-8 w-8 text-green-600" strokeWidth={2.5} />
</div>
</div>
</div>
</div>
<!-- Fora do Prazo -->
<div class="card bg-gradient-to-br from-red-500/10 to-red-600/20 border border-red-500/20 shadow-xl hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-1">
<div class="card-body">
<div class="flex items-center justify-between">
<div class="flex-1">
<p class="text-sm text-base-content/70 font-semibold mb-1">Fora do Prazo</p>
<p class="text-3xl font-bold text-red-600">{estatisticas.foraDoPrazo}</p>
<p class="text-xs text-base-content/60 mt-1">
{estatisticas.totalRegistros > 0
? ((estatisticas.foraDoPrazo / estatisticas.totalRegistros) * 100).toFixed(1)
: 0}% do total
</p>
</div>
<div class="p-3 bg-red-500/20 rounded-xl">
<XCircle class="h-8 w-8 text-red-600" strokeWidth={2.5} />
</div>
</div>
</div>
</div>
<!-- Funcionários -->
<div class="card bg-gradient-to-br from-purple-500/10 to-purple-600/20 border border-purple-500/20 shadow-xl hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-1">
<div class="card-body">
<div class="flex items-center justify-between">
<div class="flex-1">
<p class="text-sm text-base-content/70 font-semibold mb-1">Funcionários</p>
<p class="text-3xl font-bold text-purple-600">{estatisticas.totalFuncionarios}</p>
<p class="text-xs text-base-content/60 mt-1">
{estatisticas.funcionariosDentroPrazo} dentro, {estatisticas.funcionariosForaPrazo} fora
</p>
</div>
<div class="p-3 bg-purple-500/20 rounded-xl">
<Users class="h-8 w-8 text-purple-600" strokeWidth={2.5} />
</div>
</div>
</div>
</div>
</div>
{/if}
<!-- Gráfico de Estatísticas -->
{#if estatisticas}
<div class="card bg-base-100/90 backdrop-blur-sm border border-base-300 shadow-xl mb-8">
<div class="card-body">
<div class="flex items-center justify-between mb-6">
<h2 class="card-title text-2xl">
<div class="p-2 bg-primary/10 rounded-lg">
<BarChart3 class="h-6 w-6 text-primary" strokeWidth={2.5} />
</div>
<span>Visão Geral das Estatísticas</span>
</h2>
</div>
<div class="h-80 w-full relative rounded-xl bg-base-200/50 p-4 border border-base-300">
<canvas bind:this={chartCanvas} class="w-full h-full"></canvas>
{#if !chartInstance && estatisticas}
<div class="absolute inset-0 flex items-center justify-center">
<span class="loading loading-spinner loading-lg text-primary"></span>
</div>
{/if}
</div>
</div>
</div>
{/if}
<!-- Filtros -->
<div class="card bg-base-100/90 backdrop-blur-sm border border-base-300 shadow-xl mb-8">
<div class="card-body">
<div class="flex items-center gap-3 mb-6">
<div class="p-2 bg-secondary/10 rounded-lg">
<Filter class="h-5 w-5 text-secondary" strokeWidth={2.5} />
</div>
<h2 class="card-title text-2xl mb-0">Filtros de Busca</h2>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="form-control">
<label class="label" for="data-inicio">
<span class="label-text font-semibold">Data Início</span>
</label>
<input
id="data-inicio"
type="date"
bind:value={dataInicio}
class="input input-bordered input-primary focus:input-primary focus:ring-2 focus:ring-primary/20"
/>
</div>
<div class="form-control">
<label class="label" for="data-fim">
<span class="label-text font-semibold">Data Fim</span>
</label>
<input
id="data-fim"
type="date"
bind:value={dataFim}
class="input input-bordered input-primary focus:input-primary focus:ring-2 focus:ring-primary/20"
/>
</div>
<div class="form-control">
<label class="label" for="funcionario">
<span class="label-text font-semibold">Funcionário</span>
</label>
<select
id="funcionario"
bind:value={funcionarioIdFiltro}
class="select select-bordered select-primary focus:select-primary focus:ring-2 focus:ring-primary/20"
>
<option value="">Todos os funcionários</option>
{#each funcionarios as funcionario}
<option value={funcionario._id}>{funcionario.nome}</option>
{/each}
</select>
</div>
</div>
</div>
</div>
<!-- Lista de Registros -->
<div class="card bg-base-100/90 backdrop-blur-sm border border-base-300 shadow-xl">
<div class="card-body">
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 mb-6">
<div class="flex items-center gap-3">
<div class="p-2 bg-primary/10 rounded-lg">
<Clock class="h-6 w-6 text-primary" strokeWidth={2.5} />
</div>
<h2 class="card-title text-2xl mb-0">Registros de Ponto</h2>
</div>
<!-- Exibição dos Filtros Selecionados -->
{#if funcionarioIdFiltro || dataInicio || dataFim}
<div class="flex items-center gap-2 flex-wrap">
{#if funcionarioIdFiltro && funcionarioSelecionadoNome}
<div class="badge badge-primary badge-lg gap-2 px-4 py-3">
<Users class="h-4 w-4" />
{funcionarioSelecionadoNome}
</div>
{/if}
{#if dataInicio}
<div class="badge badge-info badge-lg gap-2 px-4 py-3">
<Clock class="h-4 w-4" />
De: {formatarData(dataInicio)}
</div>
{/if}
{#if dataFim}
<div class="badge badge-info badge-lg gap-2 px-4 py-3">
<Clock class="h-4 w-4" />
Até: {formatarData(dataFim)}
</div>
{/if}
</div>
{/if}
</div>
{#if registrosQuery?.status === 'Loading'}
<div class="flex flex-col items-center justify-center py-16 bg-base-200/50 rounded-xl border border-base-300">
<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-sm text-base-content/50 mt-2">Aguarde um momento</span>
</div>
{:else if registrosQuery?.error}
<div class="alert alert-error shadow-lg border-2 border-error/50">
<XCircle class="h-6 w-6" />
<div>
<h3 class="font-bold">Erro ao carregar registros</h3>
<div class="text-sm mt-1">{registrosQuery.error.message || 'Erro desconhecido'}</div>
</div>
</div>
{:else if !registrosQuery?.data}
<div class="alert alert-warning shadow-lg border-2 border-warning/50">
<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 shadow-lg border-2 border-info/50 rounded-xl bg-gradient-to-r from-info/10 to-info/5">
<FileText class="h-6 w-6 text-info" />
<div class="flex-1">
<h3 class="font-bold text-base-content">Nenhum registro encontrado</h3>
<div class="text-sm mt-2 opacity-80">
<p>Período: <span class="font-semibold">{formatarData(dataInicio)} até {formatarData(dataFim)}</span></p>
{#if funcionarioIdFiltro && funcionarioSelecionadoNome}
<p class="mt-1">Funcionário: <span class="font-semibold">{funcionarioSelecionadoNome}</span></p>
{/if}
<p class="mt-2 text-base-content/60">Tente ajustar os filtros para encontrar registros.</p>
</div>
</div>
</div>
{:else if registrosAgrupados.length === 0}
<div class="alert alert-warning shadow-lg border-2 border-warning/50 rounded-xl bg-gradient-to-r from-warning/10 to-warning/5">
<FileText class="h-6 w-6 text-warning" />
<div class="flex-1">
<h3 class="font-bold">Registros encontrados, mas não foi possível agrupá-los</h3>
<div class="text-sm mt-2 opacity-80">
Total de registros: <span class="font-semibold">{registros.length}</span>
</div>
</div>
</div>
{:else}
<div class="space-y-6">
{#each registrosAgrupados as grupo}
<div class="card bg-gradient-to-br from-base-100 to-base-200/50 border border-base-300 shadow-lg hover:shadow-xl transition-all duration-300">
<div class="card-body p-6">
<div class="flex flex-col lg:flex-row items-start lg:items-center justify-between gap-4 mb-6">
<div class="flex-1">
<div class="flex items-center gap-3 mb-2">
<div class="p-2 bg-primary/10 rounded-lg">
<Users class="h-5 w-5 text-primary" strokeWidth={2.5} />
</div>
<h3 class="font-bold text-xl text-base-content">
{grupo.funcionario?.nome || 'Funcionário não encontrado'}
</h3>
</div>
{#if grupo.funcionario?.matricula}
<p class="text-sm text-base-content/70 ml-11">
Matrícula: <span class="font-semibold">{grupo.funcionario.matricula}</span>
</p>
{/if}
{#if grupo.funcionario?.descricaoCargo}
<p class="text-sm text-base-content/60 ml-11">
{grupo.funcionario.descricaoCargo}
</p>
{/if}
</div>
<div class="flex items-center gap-3 flex-wrap">
<!-- Banco de Horas -->
{#key grupo.funcionarioId}
{@const bancoHorasQuery = useQuery(
api.pontos.obterBancoHorasFuncionario,
{ funcionarioId: grupo.funcionarioId }
)}
{@const bancoHoras = bancoHorasQuery?.data}
{@const saldoAcumulado = bancoHoras?.saldoAcumuladoMinutos ?? 0}
{@const saldoPositivo = saldoAcumulado >= 0}
{#if bancoHoras}
<div class="rounded-xl border-2 p-4 shadow-md transition-all hover:scale-105 {saldoPositivo ? 'border-success/50 bg-gradient-to-br from-success/10 to-success/5' : 'border-error/50 bg-gradient-to-br from-error/10 to-error/5'}">
<div class="flex items-center gap-3">
<div class="p-2 {saldoPositivo ? 'bg-success/20' : 'bg-error/20'} rounded-lg">
{#if saldoPositivo}
<TrendingUp class="h-5 w-5 text-success" strokeWidth={2.5} />
{:else}
<TrendingDown class="h-5 w-5 text-error" strokeWidth={2.5} />
{/if}
</div>
<div>
<p class="text-xs font-semibold opacity-70 mb-1">Banco de Horas</p>
<p class="text-xl font-bold {saldoPositivo ? 'text-success' : 'text-error'}">
{formatarSaldoHoras(saldoAcumulado)}
</p>
</div>
</div>
</div>
{/if}
{/key}
<button
class="btn btn-primary gap-2 shadow-md hover:shadow-lg transition-all"
onclick={() => abrirModalImpressao(grupo.funcionarioId)}
>
<Printer class="h-4 w-4" />
Imprimir Ficha
</button>
</div>
</div>
<div class="overflow-x-auto max-h-[600px] overflow-y-auto border border-base-300 rounded-xl shadow-inner bg-base-100/50">
<table class="table table-zebra">
<thead class="sticky top-0 z-10 shadow-md bg-gradient-to-r from-base-300 to-base-200">
<tr>
<th class="whitespace-nowrap font-bold text-base-content border-b border-base-400">Data</th>
<th class="whitespace-nowrap font-bold text-base-content border-b border-base-400">Tipo</th>
<th class="whitespace-nowrap font-bold text-base-content border-b border-base-400">Horário</th>
<th class="whitespace-nowrap font-bold text-base-content border-b border-base-400">Saldo Diário</th>
<th class="whitespace-nowrap font-bold text-base-content border-b border-base-400">Status</th>
<th class="whitespace-nowrap font-bold text-base-content border-b border-base-400">Ações</th>
</tr>
</thead>
<tbody>
{#each Object.values(grupo.registrosPorData) as grupoData}
{@const totalRegistros = grupoData.registros.length}
{@const dataParts = grupoData.data.split('-')}
{@const dataFormatada = `${dataParts[2]}/${dataParts[1]}/${dataParts[0]}`}
{#each grupoData.registros as registro, index}
<tr>
<td class="whitespace-nowrap">{dataFormatada}</td>
<td class="whitespace-nowrap">
{config
? getTipoRegistroLabel(registro.tipo, {
nomeEntrada: config.nomeEntrada,
nomeSaidaAlmoco: config.nomeSaidaAlmoco,
nomeRetornoAlmoco: config.nomeRetornoAlmoco,
nomeSaida: config.nomeSaida,
})
: getTipoRegistroLabel(registro.tipo)}
</td>
<td class="whitespace-nowrap">{formatarHoraPonto(registro.hora, registro.minuto)}</td>
{#if index === 0}
<td class="whitespace-nowrap" rowspan={totalRegistros}>
{#if grupoData.saldoDiario}
<span
class="badge badge-lg font-semibold {grupoData.saldoDiario.positivo ? 'badge-success shadow-sm' : 'badge-error shadow-sm'}"
>
{formatarSaldoDiario(grupoData.saldoDiario)}
</span>
{:else}
<span class="badge badge-ghost badge-lg">-</span>
{/if}
</td>
{/if}
<td class="whitespace-nowrap">
<span
class="badge badge-lg 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 gap-2 hover:btn-primary hover:shadow-md transition-all"
onclick={() => imprimirDetalhesRegistro(registro._id)}
title="Imprimir Detalhes"
>
<FileText class="h-4 w-4" />
Detalhes
</button>
</td>
</tr>
{/each}
{/each}
</tbody>
</table>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>
{#if mostrarModalImpressao && funcionarioParaImprimir}
<PrintPontoModal
funcionarioId={funcionarioParaImprimir}
onClose={() => {
mostrarModalImpressao = false;
funcionarioParaImprimir = '';
}}
onGenerate={gerarPDFComSelecao}
/>
{/if}