5034 lines
176 KiB
Svelte
5034 lines
176 KiB
Svelte
<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}
|