- Introduced a bar chart to visualize statistics of point registrations, including on-time and late records. - Enhanced data handling by implementing checks for valid records and improved grouping logic for better data representation. - Added loading states and error handling for improved user feedback during data retrieval. - Refactored the layout to include a detailed statistics section, enhancing the overall user experience in the point management interface.
1640 lines
50 KiB
Svelte
1640 lines
50 KiB
Svelte
<script lang="ts">
|
|
import { onMount, onDestroy } from 'svelte';
|
|
import { useQuery, useConvexClient } from 'convex-svelte';
|
|
import { api } from '@sgse-app/backend/convex/_generated/api';
|
|
import { Clock, Filter, Download, Printer, BarChart3, Users, CheckCircle2, XCircle, TrendingUp, TrendingDown, FileText } from 'lucide-svelte';
|
|
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
|
import { formatarHoraPonto, getTipoRegistroLabel } from '$lib/utils/ponto';
|
|
import jsPDF from 'jspdf';
|
|
import autoTable from 'jspdf-autotable';
|
|
import logoGovPE from '$lib/assets/logo_governo_PE.png';
|
|
import PrintPontoModal from '$lib/components/ponto/PrintPontoModal.svelte';
|
|
import { toast } from 'svelte-sonner';
|
|
import { Chart, registerables } from 'chart.js';
|
|
|
|
Chart.register(...registerables);
|
|
|
|
const client = useConvexClient();
|
|
|
|
// Estados
|
|
let dataInicio = $state(new Date().toISOString().split('T')[0]!);
|
|
let dataFim = $state(new Date().toISOString().split('T')[0]!);
|
|
let funcionarioIdFiltro = $state<Id<'funcionarios'> | ''>('');
|
|
let carregando = $state(false);
|
|
let mostrarModalImpressao = $state(false);
|
|
let funcionarioParaImprimir = $state<Id<'funcionarios'> | ''>('');
|
|
let chartCanvas: HTMLCanvasElement;
|
|
let chartInstance: Chart | null = null;
|
|
|
|
// Parâmetros reativos para queries
|
|
const registrosParams = $derived({
|
|
funcionarioId: funcionarioIdFiltro || undefined,
|
|
dataInicio,
|
|
dataFim,
|
|
});
|
|
const estatisticasParams = $derived({
|
|
dataInicio,
|
|
dataFim,
|
|
});
|
|
|
|
// Queries
|
|
const funcionariosQuery = useQuery(api.funcionarios.getAll, {});
|
|
const registrosQuery = useQuery(api.pontos.listarRegistrosPeriodo, registrosParams);
|
|
const estatisticasQuery = useQuery(api.pontos.obterEstatisticas, estatisticasParams);
|
|
const configQuery = useQuery(api.configuracaoPonto.obterConfiguracao, {});
|
|
|
|
const funcionarios = $derived(funcionariosQuery?.data || []);
|
|
const registros = $derived(registrosQuery?.data || []);
|
|
const estatisticas = $derived(estatisticasQuery?.data);
|
|
const config = $derived(configQuery?.data);
|
|
|
|
|
|
// Dados do gráfico baseados nas estatísticas
|
|
const chartData = $derived.by(() => {
|
|
if (!estatisticas) return null;
|
|
|
|
return {
|
|
labels: ['Estatísticas de Registros'],
|
|
datasets: [
|
|
{
|
|
label: 'Dentro do Prazo',
|
|
data: [estatisticas.dentroDoPrazo || 0],
|
|
backgroundColor: 'rgba(34, 197, 94, 0.8)',
|
|
borderColor: 'rgba(34, 197, 94, 1)',
|
|
borderWidth: 2,
|
|
},
|
|
{
|
|
label: 'Fora do Prazo',
|
|
data: [estatisticas.foraDoPrazo || 0],
|
|
backgroundColor: 'rgba(239, 68, 68, 0.8)',
|
|
borderColor: 'rgba(239, 68, 68, 1)',
|
|
borderWidth: 2,
|
|
}
|
|
]
|
|
};
|
|
});
|
|
|
|
// Inicializar gráfico
|
|
$effect(() => {
|
|
if (!chartCanvas || !estatisticas || !chartData) {
|
|
return;
|
|
}
|
|
|
|
// 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(() => {
|
|
if (!chartCanvas || !estatisticas || !chartData) {
|
|
return;
|
|
}
|
|
|
|
const ctx = chartCanvas.getContext('2d');
|
|
if (!ctx) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
chartInstance = new Chart(ctx, {
|
|
type: 'bar',
|
|
data: chartData,
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: {
|
|
display: true,
|
|
position: 'top',
|
|
labels: {
|
|
color: 'hsl(var(--bc))',
|
|
font: {
|
|
size: 12,
|
|
family: "'Inter', sans-serif",
|
|
},
|
|
usePointStyle: true,
|
|
padding: 15,
|
|
}
|
|
},
|
|
tooltip: {
|
|
backgroundColor: 'rgba(0, 0, 0, 0.85)',
|
|
titleColor: '#fff',
|
|
bodyColor: '#fff',
|
|
borderColor: 'hsl(var(--p))',
|
|
borderWidth: 1,
|
|
padding: 12,
|
|
callbacks: {
|
|
label: function(context) {
|
|
const label = context.dataset.label || '';
|
|
const value = context.parsed.y;
|
|
const total = estatisticas.totalRegistros;
|
|
const percentage = total > 0 ? ((value / total) * 100).toFixed(1) : '0.0';
|
|
return `${label}: ${value} (${percentage}%)`;
|
|
}
|
|
}
|
|
}
|
|
},
|
|
scales: {
|
|
x: {
|
|
stacked: true,
|
|
grid: {
|
|
display: false,
|
|
},
|
|
ticks: {
|
|
color: 'hsl(var(--bc))',
|
|
font: {
|
|
size: 12,
|
|
}
|
|
}
|
|
},
|
|
y: {
|
|
stacked: true,
|
|
beginAtZero: true,
|
|
grid: {
|
|
color: 'rgba(0, 0, 0, 0.05)',
|
|
},
|
|
ticks: {
|
|
color: 'hsl(var(--bc))',
|
|
font: {
|
|
size: 11,
|
|
},
|
|
stepSize: 1,
|
|
}
|
|
}
|
|
},
|
|
animation: {
|
|
duration: 1000,
|
|
easing: 'easeInOutQuart'
|
|
},
|
|
interaction: {
|
|
mode: 'index',
|
|
intersect: false,
|
|
}
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('Erro ao criar gráfico:', error);
|
|
}
|
|
}, 100);
|
|
|
|
return () => {
|
|
clearTimeout(timeoutId);
|
|
if (chartInstance) {
|
|
chartInstance.destroy();
|
|
chartInstance = null;
|
|
}
|
|
};
|
|
});
|
|
|
|
onDestroy(() => {
|
|
if (chartInstance) {
|
|
chartInstance.destroy();
|
|
}
|
|
});
|
|
|
|
// Agrupar registros por funcionário e data
|
|
const registrosAgrupados = $derived.by(() => {
|
|
const agrupados: Record<
|
|
string,
|
|
{
|
|
funcionario: { nome: string; matricula?: string; descricaoCargo?: string } | null;
|
|
funcionarioId: Id<'funcionarios'>;
|
|
registrosPorData: Record<
|
|
string,
|
|
{
|
|
data: string;
|
|
registros: Array<typeof registros[number]>;
|
|
saldoDiario?: { saldoMinutos: number; horas: number; minutos: number; positivo: boolean };
|
|
}
|
|
>;
|
|
}
|
|
> = {};
|
|
|
|
// Usar Set para evitar registros duplicados
|
|
const registrosProcessados = new Set<string>();
|
|
|
|
// Verificar se registros é um array válido
|
|
if (!Array.isArray(registros) || registros.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
for (const registro of registros) {
|
|
// Verificar se o registro tem os campos necessários
|
|
if (!registro || !registro._id || !registro.funcionarioId || !registro.data) {
|
|
console.warn('⚠️ [DEBUG] Registro inválido ignorado:', registro);
|
|
continue;
|
|
}
|
|
|
|
// Criar chave única para evitar duplicatas
|
|
const chaveUnica = `${registro._id}`;
|
|
if (registrosProcessados.has(chaveUnica)) {
|
|
continue; // Pular se já foi processado
|
|
}
|
|
registrosProcessados.add(chaveUnica);
|
|
|
|
const key = registro.funcionarioId;
|
|
if (!agrupados[key]) {
|
|
agrupados[key] = {
|
|
funcionario: registro.funcionario,
|
|
funcionarioId: registro.funcionarioId,
|
|
registrosPorData: {},
|
|
};
|
|
}
|
|
|
|
const dataKey = registro.data;
|
|
if (!agrupados[key]!.registrosPorData[dataKey]) {
|
|
agrupados[key]!.registrosPorData[dataKey] = {
|
|
data: dataKey,
|
|
registros: [],
|
|
saldoDiario: undefined,
|
|
};
|
|
}
|
|
|
|
// Verificar se o registro já não está no array antes de adicionar
|
|
const jaExiste = agrupados[key]!.registrosPorData[dataKey]!.registros.some(
|
|
(r) => r._id === registro._id
|
|
);
|
|
if (!jaExiste) {
|
|
agrupados[key]!.registrosPorData[dataKey]!.registros.push(registro);
|
|
}
|
|
}
|
|
|
|
// Ordenar registros por data e hora dentro de cada grupo e usar saldo diário da query
|
|
const resultado = Object.values(agrupados);
|
|
|
|
// Ordenar grupos por nome do funcionário
|
|
resultado.sort((a, b) => {
|
|
const nomeA = a.funcionario?.nome || '';
|
|
const nomeB = b.funcionario?.nome || '';
|
|
return nomeA.localeCompare(nomeB, 'pt-BR');
|
|
});
|
|
|
|
for (const grupo of resultado) {
|
|
// Ordenar datas dentro de cada grupo (mais recente primeiro)
|
|
const datasOrdenadas = Object.keys(grupo.registrosPorData).sort((a, b) => {
|
|
return new Date(b).getTime() - new Date(a).getTime();
|
|
});
|
|
|
|
// Criar novo objeto com datas ordenadas
|
|
const registrosPorDataOrdenado: Record<string, typeof grupo.registrosPorData[string]> = {};
|
|
for (const dataKey of datasOrdenadas) {
|
|
registrosPorDataOrdenado[dataKey] = grupo.registrosPorData[dataKey]!;
|
|
}
|
|
grupo.registrosPorData = registrosPorDataOrdenado;
|
|
|
|
for (const dataKey in grupo.registrosPorData) {
|
|
const grupoData = grupo.registrosPorData[dataKey];
|
|
if (grupoData && grupoData.registros.length > 0) {
|
|
// Ordenar por hora e minuto
|
|
grupoData.registros.sort((a, b) => {
|
|
if (a.hora !== b.hora) {
|
|
return a.hora - b.hora;
|
|
}
|
|
return a.minuto - b.minuto;
|
|
});
|
|
|
|
// Usar saldo diário da query se disponível, senão calcular
|
|
const primeiroRegistro = grupoData.registros[0];
|
|
if (primeiroRegistro && 'saldoDiario' in primeiroRegistro && primeiroRegistro.saldoDiario) {
|
|
grupoData.saldoDiario = primeiroRegistro.saldoDiario;
|
|
} else {
|
|
// Calcular saldo diário como diferença entre saída e entrada
|
|
grupoData.saldoDiario = calcularSaldoDiario(grupoData.registros);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return resultado;
|
|
});
|
|
|
|
// Query para banco de horas de cada funcionário
|
|
const funcionariosComBancoHoras = $derived.by(() => {
|
|
return registrosAgrupados.map((grupo) => grupo.funcionarioId);
|
|
});
|
|
|
|
// Função para formatar saldo de horas
|
|
function formatarSaldoHoras(minutos: number): string {
|
|
const horas = Math.floor(Math.abs(minutos) / 60);
|
|
const mins = Math.abs(minutos) % 60;
|
|
const sinal = minutos >= 0 ? '+' : '-';
|
|
return `${sinal}${horas}h ${mins}min`;
|
|
}
|
|
|
|
// Função para formatar saldo diário
|
|
function formatarSaldoDiario(saldo?: { saldoMinutos: number; horas: number; minutos: number; positivo: boolean }): string {
|
|
if (!saldo) return '-';
|
|
const sinal = saldo.positivo ? '+' : '-';
|
|
return `${sinal}${saldo.horas}h ${saldo.minutos}min`;
|
|
}
|
|
|
|
// Função para formatar data em português
|
|
function formatarData(data: string): string {
|
|
if (!data) return '';
|
|
const dataObj = new Date(data + 'T00:00:00');
|
|
return dataObj.toLocaleDateString('pt-BR', {
|
|
day: '2-digit',
|
|
month: '2-digit',
|
|
year: 'numeric'
|
|
});
|
|
}
|
|
|
|
// Obter nome do funcionário selecionado
|
|
const funcionarioSelecionadoNome = $derived.by(() => {
|
|
if (!funcionarioIdFiltro) return null;
|
|
return funcionarios.find(f => f._id === funcionarioIdFiltro)?.nome || null;
|
|
});
|
|
|
|
// Função para calcular saldo diário como diferença entre saída e entrada
|
|
function calcularSaldoDiario(registros: Array<{ tipo: string; hora: number; minuto: number }>): { saldoMinutos: number; horas: number; minutos: number; positivo: boolean } | null {
|
|
if (registros.length === 0) return null;
|
|
|
|
// Ordenar registros por hora e minuto
|
|
const registrosOrdenados = [...registros].sort((a, b) => {
|
|
if (a.hora !== b.hora) {
|
|
return a.hora - b.hora;
|
|
}
|
|
return a.minuto - b.minuto;
|
|
});
|
|
|
|
// Buscar entrada (primeiro registro do tipo 'entrada')
|
|
const entrada = registrosOrdenados.find((r) => r.tipo === 'entrada');
|
|
// Buscar saída (último registro do tipo 'saida')
|
|
const saida = registrosOrdenados.filter((r) => r.tipo === 'saida').pop();
|
|
|
|
if (!entrada || !saida) return null;
|
|
|
|
// Calcular diferença em minutos
|
|
const minutosEntrada = entrada.hora * 60 + entrada.minuto;
|
|
const minutosSaida = saida.hora * 60 + saida.minuto;
|
|
|
|
// Se a saída for no dia seguinte (após meia-noite), adicionar 24 horas
|
|
let saldoMinutos = minutosSaida - minutosEntrada;
|
|
if (saldoMinutos < 0) {
|
|
saldoMinutos += 24 * 60; // Adicionar um dia em minutos
|
|
}
|
|
|
|
const horas = Math.floor(saldoMinutos / 60);
|
|
const minutos = saldoMinutos % 60;
|
|
|
|
return {
|
|
saldoMinutos,
|
|
horas,
|
|
minutos,
|
|
positivo: true, // Sempre positivo, pois é tempo trabalhado
|
|
};
|
|
}
|
|
|
|
function abrirModalImpressao(funcionarioId: Id<'funcionarios'>) {
|
|
funcionarioParaImprimir = funcionarioId;
|
|
mostrarModalImpressao = true;
|
|
}
|
|
|
|
async function gerarPDFComSelecao(sections: {
|
|
dadosFuncionario: boolean;
|
|
registrosPonto: boolean;
|
|
saldoDiario: boolean;
|
|
bancoHoras: boolean;
|
|
alteracoesGestor: boolean;
|
|
dispensasRegistro: boolean;
|
|
}) {
|
|
if (!funcionarioParaImprimir) return;
|
|
|
|
const funcionarioId = funcionarioParaImprimir;
|
|
|
|
// Verificar se pelo menos uma seção foi selecionada
|
|
if (!Object.values(sections).some((v) => v)) {
|
|
toast.error('Selecione pelo menos uma seção para imprimir');
|
|
return;
|
|
}
|
|
|
|
const funcionario = funcionarios.find((f) => f._id === funcionarioId);
|
|
if (!funcionario) {
|
|
toast.error('Funcionário não encontrado');
|
|
return;
|
|
}
|
|
|
|
// Buscar registros do funcionário no período selecionado
|
|
const registrosFuncionario = await client.query(api.pontos.listarRegistrosPeriodo, {
|
|
funcionarioId,
|
|
dataInicio,
|
|
dataFim,
|
|
});
|
|
|
|
if (!registrosFuncionario || registrosFuncionario.length === 0) {
|
|
toast.error('Nenhum registro encontrado para este funcionário no período selecionado');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const doc = new jsPDF();
|
|
|
|
// Logo
|
|
let yPosition = 20;
|
|
try {
|
|
const logoImg = new Image();
|
|
logoImg.src = logoGovPE;
|
|
await new Promise<void>((resolve, reject) => {
|
|
logoImg.onload = () => resolve();
|
|
logoImg.onerror = () => reject();
|
|
setTimeout(() => reject(), 3000);
|
|
});
|
|
|
|
const logoWidth = 25;
|
|
const aspectRatio = logoImg.height / logoImg.width;
|
|
const logoHeight = logoWidth * aspectRatio;
|
|
|
|
doc.addImage(logoImg, 'PNG', 15, 10, logoWidth, logoHeight);
|
|
yPosition = Math.max(20, 10 + logoHeight / 2);
|
|
} catch (err) {
|
|
console.warn('Não foi possível carregar a logo:', err);
|
|
}
|
|
|
|
// Cabeçalho
|
|
doc.setFontSize(16);
|
|
doc.setTextColor(41, 128, 185);
|
|
doc.text('FICHA DE PONTO', 105, yPosition, { align: 'center' });
|
|
|
|
yPosition += 10;
|
|
|
|
// Dados do Funcionário
|
|
if (sections.dadosFuncionario) {
|
|
doc.setFontSize(12);
|
|
doc.setTextColor(0, 0, 0);
|
|
doc.setFont('helvetica', 'bold');
|
|
doc.text('DADOS DO FUNCIONÁRIO', 15, yPosition);
|
|
doc.setFont('helvetica', 'normal');
|
|
|
|
yPosition += 8;
|
|
doc.setFontSize(10);
|
|
|
|
if (funcionario.matricula) {
|
|
doc.text(`Matrícula: ${funcionario.matricula}`, 15, yPosition);
|
|
yPosition += 6;
|
|
}
|
|
doc.text(`Nome: ${funcionario.nome}`, 15, yPosition);
|
|
yPosition += 6;
|
|
if (funcionario.descricaoCargo) {
|
|
doc.text(`Cargo/Função: ${funcionario.descricaoCargo}`, 15, yPosition);
|
|
yPosition += 6;
|
|
}
|
|
|
|
yPosition += 5;
|
|
// Formatar período para exibição
|
|
const dataInicioParts = dataInicio.split('-');
|
|
const dataFimParts = dataFim.split('-');
|
|
const periodoFormatado = `${dataInicioParts[2]}/${dataInicioParts[1]}/${dataInicioParts[0]} a ${dataFimParts[2]}/${dataFimParts[1]}/${dataFimParts[0]}`;
|
|
doc.text(`Período: ${periodoFormatado}`, 15, yPosition);
|
|
yPosition += 10;
|
|
}
|
|
|
|
// Buscar homologações e dispensas
|
|
let homologacoes: Array<{
|
|
_id: Id<'homologacoesPonto'>;
|
|
criadoEm: number;
|
|
registroId?: Id<'registrosPonto'>;
|
|
horaAnterior?: number;
|
|
minutoAnterior?: number;
|
|
horaNova?: number;
|
|
minutoNova?: number;
|
|
tipoAjuste?: 'compensar' | 'abonar' | 'descontar';
|
|
periodoDias?: number;
|
|
periodoHoras?: number;
|
|
periodoMinutos?: number;
|
|
motivoDescricao?: string;
|
|
motivoTipo?: string;
|
|
observacoes?: string;
|
|
}> = [];
|
|
|
|
let dispensas: Array<{
|
|
dataInicio: string;
|
|
dataFim: string;
|
|
horaInicio: number;
|
|
minutoInicio: number;
|
|
horaFim: number;
|
|
minutoFim: number;
|
|
motivo: string;
|
|
isento: boolean;
|
|
}> = [];
|
|
|
|
if (sections.alteracoesGestor) {
|
|
try {
|
|
homologacoes = await client.query(api.pontos.listarHomologacoes, {
|
|
funcionarioId,
|
|
}) || [];
|
|
} catch (error) {
|
|
console.warn('Erro ao buscar homologações:', error);
|
|
// Continuar mesmo se houver erro ao buscar homologações
|
|
}
|
|
}
|
|
|
|
if (sections.dispensasRegistro) {
|
|
try {
|
|
dispensas = await client.query(api.pontos.listarDispensas, {
|
|
funcionarioId,
|
|
apenasAtivas: false,
|
|
}) || [];
|
|
} catch (error) {
|
|
console.warn('Erro ao buscar dispensas:', error);
|
|
// Continuar mesmo se houver erro ao buscar dispensas
|
|
}
|
|
}
|
|
|
|
// Tabela de registros
|
|
if (sections.registrosPonto) {
|
|
const config = await client.query(api.configuracaoPonto.obterConfiguracao, {});
|
|
const tableData: string[][] = [];
|
|
|
|
// Agrupar por data para incluir saldo diário
|
|
const registrosPorData: Record<
|
|
string,
|
|
Array<{
|
|
data: string;
|
|
tipo: string;
|
|
hora: number;
|
|
minuto: number;
|
|
dentroDoPrazo: boolean;
|
|
}>
|
|
> = {};
|
|
|
|
for (const r of registrosFuncionario) {
|
|
const dataKey = r.data;
|
|
if (!registrosPorData[dataKey]) {
|
|
registrosPorData[dataKey] = [];
|
|
}
|
|
registrosPorData[dataKey]!.push({
|
|
data: r.data,
|
|
tipo: r.tipo,
|
|
hora: r.hora,
|
|
minuto: r.minuto,
|
|
dentroDoPrazo: r.dentroDoPrazo,
|
|
});
|
|
}
|
|
|
|
// Criar dados da tabela com saldo diário
|
|
for (const [data, regs] of Object.entries(registrosPorData)) {
|
|
// Formatar data para exibição (DD/MM/YYYY)
|
|
const dataParts = data.split('-');
|
|
const dataFormatada = `${dataParts[2]}/${dataParts[1]}/${dataParts[0]}`;
|
|
|
|
// Calcular saldo diário como diferença entre saída e entrada
|
|
const saldoDiarioDia = calcularSaldoDiario(regs);
|
|
|
|
for (const reg of regs) {
|
|
const linha: string[] = [
|
|
dataFormatada,
|
|
config
|
|
? getTipoRegistroLabel(reg.tipo as 'entrada' | 'saida_almoco' | 'retorno_almoco' | 'saida', {
|
|
nomeEntrada: config.nomeEntrada,
|
|
nomeSaidaAlmoco: config.nomeSaidaAlmoco,
|
|
nomeRetornoAlmoco: config.nomeRetornoAlmoco,
|
|
nomeSaida: config.nomeSaida,
|
|
})
|
|
: getTipoRegistroLabel(reg.tipo as 'entrada' | 'saida_almoco' | 'retorno_almoco' | 'saida'),
|
|
formatarHoraPonto(reg.hora, reg.minuto),
|
|
];
|
|
|
|
// Saldo Diário sempre após Horário
|
|
if (sections.saldoDiario) {
|
|
if (saldoDiarioDia) {
|
|
const sinal = saldoDiarioDia.positivo ? '+' : '-';
|
|
linha.push(`${sinal}${saldoDiarioDia.horas}h ${saldoDiarioDia.minutos}min`);
|
|
} else {
|
|
linha.push('-');
|
|
}
|
|
}
|
|
|
|
linha.push(reg.dentroDoPrazo ? 'Sim' : 'Não');
|
|
|
|
tableData.push(linha);
|
|
}
|
|
}
|
|
|
|
const headers = ['Data', 'Tipo', 'Horário'];
|
|
if (sections.saldoDiario) {
|
|
headers.push('Saldo Diário');
|
|
}
|
|
headers.push('Dentro do Prazo');
|
|
|
|
// Salvar a posição Y antes da tabela
|
|
const yPosAntesTabela = yPosition;
|
|
|
|
autoTable(doc, {
|
|
startY: yPosition,
|
|
head: [headers],
|
|
body: tableData,
|
|
theme: 'grid',
|
|
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
|
|
styles: { fontSize: 9 },
|
|
});
|
|
|
|
// Calcular posição Y após a tabela
|
|
const lastPage = doc.getNumberOfPages();
|
|
doc.setPage(lastPage);
|
|
const finalY = (doc as { lastAutoTable?: { finalY: number } }).lastAutoTable?.finalY;
|
|
|
|
if (finalY) {
|
|
yPosition = finalY;
|
|
} else {
|
|
const linhasTabela = tableData.length + 1;
|
|
yPosition = yPosAntesTabela + linhasTabela * 7 + 10;
|
|
}
|
|
yPosition += 10;
|
|
}
|
|
|
|
// Banco de Horas
|
|
if (sections.bancoHoras) {
|
|
if (yPosition > doc.internal.pageSize.getHeight() - 60) {
|
|
doc.addPage();
|
|
yPosition = 20;
|
|
}
|
|
|
|
const bancoHoras = await client.query(api.pontos.obterBancoHorasFuncionario, {
|
|
funcionarioId,
|
|
});
|
|
|
|
doc.setFontSize(12);
|
|
doc.setFont('helvetica', 'bold');
|
|
doc.setTextColor(41, 128, 185);
|
|
doc.text('RESUMO DO BANCO DE HORAS', 15, yPosition);
|
|
doc.setFont('helvetica', 'normal');
|
|
doc.setTextColor(0, 0, 0);
|
|
|
|
yPosition += 10;
|
|
doc.setFontSize(10);
|
|
|
|
if (bancoHoras) {
|
|
const saldoMinutos = bancoHoras.saldoAcumuladoMinutos;
|
|
const horas = Math.floor(Math.abs(saldoMinutos) / 60);
|
|
const minutos = Math.abs(saldoMinutos) % 60;
|
|
const sinal = saldoMinutos >= 0 ? '+' : '-';
|
|
const saldoFormatado = `${sinal}${horas}h ${minutos}min`;
|
|
|
|
doc.setFont('helvetica', 'bold');
|
|
doc.text('Saldo Atual:', 15, yPosition);
|
|
doc.setFont('helvetica', 'normal');
|
|
doc.text(saldoFormatado, 60, yPosition);
|
|
yPosition += 8;
|
|
|
|
if (saldoMinutos > 0) {
|
|
doc.setFont('helvetica', 'bold');
|
|
doc.setTextColor(0, 128, 0);
|
|
doc.text('Horas Excedentes:', 15, yPosition);
|
|
doc.setFont('helvetica', 'normal');
|
|
doc.text(`${horas}h ${minutos}min`, 75, yPosition);
|
|
doc.setTextColor(0, 0, 0);
|
|
yPosition += 8;
|
|
}
|
|
|
|
if (saldoMinutos < 0) {
|
|
doc.setFont('helvetica', 'bold');
|
|
doc.setTextColor(200, 0, 0);
|
|
doc.text('Horas a Pagar:', 15, yPosition);
|
|
doc.setFont('helvetica', 'normal');
|
|
doc.text(`${horas}h ${minutos}min`, 70, yPosition);
|
|
doc.setTextColor(0, 0, 0);
|
|
yPosition += 8;
|
|
}
|
|
|
|
doc.setFont('helvetica', 'bold');
|
|
doc.text('Total de Dias com Registro:', 15, yPosition);
|
|
doc.setFont('helvetica', 'normal');
|
|
doc.text(`${bancoHoras.totalDias} dias`, 95, yPosition);
|
|
yPosition += 10;
|
|
} else {
|
|
doc.text('Banco de horas não disponível', 15, yPosition);
|
|
yPosition += 10;
|
|
}
|
|
}
|
|
|
|
// Alterações pelo Gestor
|
|
if (sections.alteracoesGestor && homologacoes.length > 0) {
|
|
if (yPosition > doc.internal.pageSize.getHeight() - 60) {
|
|
doc.addPage();
|
|
yPosition = 20;
|
|
}
|
|
|
|
doc.setFontSize(12);
|
|
doc.setFont('helvetica', 'bold');
|
|
doc.setTextColor(41, 128, 185);
|
|
doc.text('ALTERAÇÕES PELO GESTOR', 15, yPosition);
|
|
doc.setFont('helvetica', 'normal');
|
|
doc.setTextColor(0, 0, 0);
|
|
yPosition += 10;
|
|
|
|
const homologacoesData = homologacoes.map((h) => {
|
|
// Formatar data de criação
|
|
const dataCriacao = new Date(h.criadoEm);
|
|
const dataFormatada = `${dataCriacao.getDate().toString().padStart(2, '0')}/${(dataCriacao.getMonth() + 1).toString().padStart(2, '0')}/${dataCriacao.getFullYear()}`;
|
|
|
|
if (h.registroId && h.horaAnterior !== undefined) {
|
|
return [
|
|
dataFormatada,
|
|
'Edição de Registro',
|
|
h.horaAnterior !== undefined
|
|
? `${formatarHoraPonto(h.horaAnterior, h.minutoAnterior || 0)} → ${formatarHoraPonto(h.horaNova || 0, h.minutoNova || 0)}`
|
|
: '-',
|
|
h.motivoDescricao || h.motivoTipo || '-',
|
|
h.observacoes || '-',
|
|
];
|
|
} else if (h.tipoAjuste) {
|
|
const tipoAjusteLabel = h.tipoAjuste === 'compensar' ? 'Compensar' : h.tipoAjuste === 'abonar' ? 'Abonar' : 'Descontar';
|
|
return [
|
|
dataFormatada,
|
|
`Ajuste: ${tipoAjusteLabel}`,
|
|
`${h.periodoDias || 0}d ${h.periodoHoras || 0}h ${h.periodoMinutos || 0}min`,
|
|
h.motivoDescricao || h.motivoTipo || '-',
|
|
h.observacoes || '-',
|
|
];
|
|
}
|
|
return [];
|
|
}).filter((row) => row.length > 0);
|
|
|
|
if (homologacoesData.length > 0) {
|
|
autoTable(doc, {
|
|
startY: yPosition,
|
|
head: [['Data', 'Tipo', 'Detalhes', 'Motivo', 'Observações']],
|
|
body: homologacoesData,
|
|
theme: 'grid',
|
|
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
|
|
styles: { fontSize: 9 },
|
|
});
|
|
|
|
const lastPage = doc.getNumberOfPages();
|
|
doc.setPage(lastPage);
|
|
const finalY = (doc as { lastAutoTable?: { finalY: number } }).lastAutoTable?.finalY;
|
|
if (finalY) {
|
|
yPosition = finalY + 10;
|
|
} else {
|
|
yPosition += homologacoesData.length * 7 + 10;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Dispensas de Registro
|
|
if (sections.dispensasRegistro && dispensas.length > 0) {
|
|
if (yPosition > doc.internal.pageSize.getHeight() - 60) {
|
|
doc.addPage();
|
|
yPosition = 20;
|
|
}
|
|
|
|
doc.setFontSize(12);
|
|
doc.setFont('helvetica', 'bold');
|
|
doc.setTextColor(41, 128, 185);
|
|
doc.text('DISPENSAS DE REGISTRO', 15, yPosition);
|
|
doc.setFont('helvetica', 'normal');
|
|
doc.setTextColor(0, 0, 0);
|
|
yPosition += 10;
|
|
|
|
const dispensasData = dispensas.map((d) => {
|
|
// Formatar data de início
|
|
const dataInicioParts = d.dataInicio.split('-');
|
|
const dataInicioFormatada = `${dataInicioParts[2]}/${dataInicioParts[1]}/${dataInicioParts[0]}`;
|
|
|
|
// Formatar data de fim
|
|
const dataFimParts = d.dataFim.split('-');
|
|
const dataFimFormatada = `${dataFimParts[2]}/${dataFimParts[1]}/${dataFimParts[0]}`;
|
|
|
|
return [
|
|
`${dataInicioFormatada} ${d.horaInicio.toString().padStart(2, '0')}:${d.minutoInicio.toString().padStart(2, '0')}`,
|
|
`${dataFimFormatada} ${d.horaFim.toString().padStart(2, '0')}:${d.minutoFim.toString().padStart(2, '0')}`,
|
|
d.motivo,
|
|
d.isento ? 'Isento (sem expiração)' : 'Temporária',
|
|
];
|
|
});
|
|
|
|
autoTable(doc, {
|
|
startY: yPosition,
|
|
head: [['Início', 'Fim', 'Motivo', 'Tipo']],
|
|
body: dispensasData,
|
|
theme: 'grid',
|
|
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
|
|
styles: { fontSize: 9 },
|
|
});
|
|
|
|
const lastPage = doc.getNumberOfPages();
|
|
doc.setPage(lastPage);
|
|
const finalY = (doc as { lastAutoTable?: { finalY: number } }).lastAutoTable?.finalY;
|
|
if (finalY) {
|
|
yPosition = finalY + 10;
|
|
} else {
|
|
yPosition += dispensasData.length * 7 + 10;
|
|
}
|
|
}
|
|
|
|
// Rodapé
|
|
const pageCount = doc.getNumberOfPages();
|
|
for (let i = 1; i <= pageCount; i++) {
|
|
doc.setPage(i);
|
|
doc.setFontSize(8);
|
|
doc.setTextColor(128, 128, 128);
|
|
doc.text(
|
|
`SGSE - Sistema de Gerenciamento de Secretaria | Página ${i} de ${pageCount}`,
|
|
doc.internal.pageSize.getWidth() / 2,
|
|
doc.internal.pageSize.getHeight() - 10,
|
|
{ align: 'center' }
|
|
);
|
|
}
|
|
|
|
// Salvar
|
|
const nomeArquivo = `ficha-ponto-${funcionario.matricula || funcionario.nome}-${dataInicio}-${dataFim}.pdf`;
|
|
doc.save(nomeArquivo);
|
|
|
|
// Fechar modal após gerar PDF
|
|
mostrarModalImpressao = false;
|
|
funcionarioParaImprimir = '';
|
|
toast.success('PDF gerado com sucesso!');
|
|
} catch (error) {
|
|
console.error('Erro ao gerar PDF:', error);
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
toast.error(`Erro ao gerar ficha de ponto: ${errorMessage}`);
|
|
}
|
|
}
|
|
|
|
async function imprimirDetalhesRegistro(registroId: Id<'registrosPonto'>) {
|
|
try {
|
|
// Buscar dados completos do registro
|
|
const registro = await client.query(api.pontos.obterRegistro, { registroId });
|
|
|
|
if (!registro) {
|
|
alert('Registro não encontrado');
|
|
return;
|
|
}
|
|
|
|
const doc = new jsPDF();
|
|
|
|
// Logo
|
|
let yPosition = 20;
|
|
try {
|
|
const logoImg = new Image();
|
|
logoImg.src = logoGovPE;
|
|
await new Promise<void>((resolve, reject) => {
|
|
logoImg.onload = () => resolve();
|
|
logoImg.onerror = () => reject();
|
|
setTimeout(() => reject(), 3000);
|
|
});
|
|
|
|
const logoWidth = 25;
|
|
const aspectRatio = logoImg.height / logoImg.width;
|
|
const logoHeight = logoWidth * aspectRatio;
|
|
|
|
doc.addImage(logoImg, 'PNG', 15, 10, logoWidth, logoHeight);
|
|
yPosition = Math.max(20, 10 + logoHeight / 2);
|
|
} catch (err) {
|
|
console.warn('Não foi possível carregar a logo:', err);
|
|
}
|
|
|
|
// Cabeçalho
|
|
doc.setFontSize(16);
|
|
doc.setTextColor(41, 128, 185);
|
|
doc.text('DETALHES DO REGISTRO DE PONTO', 105, yPosition, { align: 'center' });
|
|
|
|
yPosition += 15;
|
|
|
|
// Informações do Funcionário
|
|
doc.setFontSize(12);
|
|
doc.setTextColor(0, 0, 0);
|
|
doc.setFont('helvetica', 'bold');
|
|
doc.text('DADOS DO FUNCIONÁRIO', 15, yPosition);
|
|
doc.setFont('helvetica', 'normal');
|
|
|
|
yPosition += 8;
|
|
doc.setFontSize(10);
|
|
|
|
if (registro.funcionario) {
|
|
if (registro.funcionario.matricula) {
|
|
doc.text(`Matrícula: ${registro.funcionario.matricula}`, 15, yPosition);
|
|
yPosition += 6;
|
|
}
|
|
doc.text(`Nome: ${registro.funcionario.nome}`, 15, yPosition);
|
|
yPosition += 6;
|
|
if (registro.funcionario.descricaoCargo) {
|
|
doc.text(`Cargo/Função: ${registro.funcionario.descricaoCargo}`, 15, yPosition);
|
|
yPosition += 6;
|
|
}
|
|
}
|
|
|
|
yPosition += 5;
|
|
|
|
// Informações do Registro
|
|
doc.setFont('helvetica', 'bold');
|
|
doc.text('DADOS DO REGISTRO', 15, yPosition);
|
|
doc.setFont('helvetica', 'normal');
|
|
|
|
yPosition += 8;
|
|
doc.setFontSize(10);
|
|
|
|
const config = await client.query(api.configuracaoPonto.obterConfiguracao, {});
|
|
const tipoLabel = config
|
|
? getTipoRegistroLabel(registro.tipo, {
|
|
nomeEntrada: config.nomeEntrada,
|
|
nomeSaidaAlmoco: config.nomeSaidaAlmoco,
|
|
nomeRetornoAlmoco: config.nomeRetornoAlmoco,
|
|
nomeSaida: config.nomeSaida,
|
|
})
|
|
: getTipoRegistroLabel(registro.tipo);
|
|
doc.text(`Tipo: ${tipoLabel}`, 15, yPosition);
|
|
yPosition += 6;
|
|
|
|
const dataHora = `${registro.data} ${registro.hora.toString().padStart(2, '0')}:${registro.minuto.toString().padStart(2, '0')}:${registro.segundo.toString().padStart(2, '0')}`;
|
|
doc.text(`Data e Hora: ${dataHora}`, 15, yPosition);
|
|
yPosition += 6;
|
|
|
|
doc.text(`Status: ${registro.dentroDoPrazo ? 'Dentro do Prazo' : 'Fora do Prazo'}`, 15, yPosition);
|
|
yPosition += 6;
|
|
|
|
doc.text(`Tolerância: ${registro.toleranciaMinutos} minutos`, 15, yPosition);
|
|
yPosition += 6;
|
|
|
|
doc.text(
|
|
`Sincronizado: ${registro.sincronizadoComServidor ? 'Sim (Servidor)' : 'Não (PC Local)'}`,
|
|
15,
|
|
yPosition
|
|
);
|
|
yPosition += 6;
|
|
|
|
if (registro.justificativa) {
|
|
doc.text(`Justificativa: ${registro.justificativa}`, 15, yPosition);
|
|
yPosition += 6;
|
|
}
|
|
|
|
yPosition += 5;
|
|
|
|
// Localização
|
|
if (registro.latitude && registro.longitude) {
|
|
// Verificar se precisa de nova página
|
|
if (yPosition > 200) {
|
|
doc.addPage();
|
|
yPosition = 20;
|
|
}
|
|
|
|
doc.setFont('helvetica', 'bold');
|
|
doc.text('LOCALIZAÇÃO', 15, yPosition);
|
|
doc.setFont('helvetica', 'normal');
|
|
|
|
yPosition += 8;
|
|
doc.setFontSize(10);
|
|
|
|
doc.text(`Latitude: ${registro.latitude.toFixed(6)}`, 15, yPosition);
|
|
yPosition += 6;
|
|
|
|
doc.text(`Longitude: ${registro.longitude.toFixed(6)}`, 15, yPosition);
|
|
yPosition += 6;
|
|
|
|
if (registro.precisao) {
|
|
doc.text(`Precisão: ${registro.precisao.toFixed(2)} metros`, 15, yPosition);
|
|
yPosition += 6;
|
|
}
|
|
|
|
if (registro.endereco) {
|
|
doc.text(`Endereço: ${registro.endereco}`, 15, yPosition);
|
|
yPosition += 6;
|
|
}
|
|
|
|
if (registro.cidade) {
|
|
doc.text(`Cidade: ${registro.cidade}`, 15, yPosition);
|
|
yPosition += 6;
|
|
}
|
|
|
|
if (registro.estado) {
|
|
doc.text(`Estado: ${registro.estado}`, 15, yPosition);
|
|
yPosition += 6;
|
|
}
|
|
|
|
if (registro.pais) {
|
|
doc.text(`País: ${registro.pais}`, 15, yPosition);
|
|
yPosition += 6;
|
|
}
|
|
|
|
if (registro.timezone) {
|
|
doc.text(`Fuso Horário: ${registro.timezone}`, 15, yPosition);
|
|
yPosition += 6;
|
|
}
|
|
|
|
yPosition += 5;
|
|
}
|
|
|
|
// Dados Técnicos
|
|
// Verificar se precisa de nova página
|
|
if (yPosition > 200) {
|
|
doc.addPage();
|
|
yPosition = 20;
|
|
}
|
|
|
|
doc.setFont('helvetica', 'bold');
|
|
doc.text('DADOS TÉCNICOS', 15, yPosition);
|
|
doc.setFont('helvetica', 'normal');
|
|
|
|
yPosition += 8;
|
|
doc.setFontSize(10);
|
|
|
|
// Informações de Rede
|
|
if (registro.ipAddress || registro.ipPublico || registro.ipLocal) {
|
|
doc.setFont('helvetica', 'bold');
|
|
doc.text('Rede:', 15, yPosition);
|
|
doc.setFont('helvetica', 'normal');
|
|
yPosition += 6;
|
|
|
|
if (registro.ipAddress) {
|
|
doc.text(` IP: ${registro.ipAddress}`, 20, yPosition);
|
|
yPosition += 6;
|
|
}
|
|
|
|
if (registro.ipPublico) {
|
|
doc.text(` IP Público: ${registro.ipPublico}`, 20, yPosition);
|
|
yPosition += 6;
|
|
}
|
|
|
|
if (registro.ipLocal) {
|
|
doc.text(` IP Local: ${registro.ipLocal}`, 20, yPosition);
|
|
yPosition += 6;
|
|
}
|
|
|
|
yPosition += 3;
|
|
}
|
|
|
|
// Informações do Navegador
|
|
if (registro.browser || registro.userAgent) {
|
|
doc.setFont('helvetica', 'bold');
|
|
doc.text('Navegador:', 15, yPosition);
|
|
doc.setFont('helvetica', 'normal');
|
|
yPosition += 6;
|
|
|
|
if (registro.browser) {
|
|
doc.text(` Navegador: ${registro.browser}${registro.browserVersion ? ` ${registro.browserVersion}` : ''}`, 20, yPosition);
|
|
yPosition += 6;
|
|
}
|
|
|
|
if (registro.engine) {
|
|
doc.text(` Engine: ${registro.engine}`, 20, yPosition);
|
|
yPosition += 6;
|
|
}
|
|
|
|
if (registro.userAgent) {
|
|
// Quebrar user agent em múltiplas linhas se necessário
|
|
const userAgentLines = doc.splitTextToSize(` User Agent: ${registro.userAgent}`, 170);
|
|
doc.text(userAgentLines, 20, yPosition);
|
|
yPosition += userAgentLines.length * 6;
|
|
}
|
|
|
|
yPosition += 3;
|
|
}
|
|
|
|
// Informações do Sistema
|
|
if (registro.sistemaOperacional || registro.arquitetura) {
|
|
doc.setFont('helvetica', 'bold');
|
|
doc.text('Sistema:', 15, yPosition);
|
|
doc.setFont('helvetica', 'normal');
|
|
yPosition += 6;
|
|
|
|
if (registro.sistemaOperacional) {
|
|
doc.text(` SO: ${registro.sistemaOperacional}${registro.osVersion ? ` ${registro.osVersion}` : ''}`, 20, yPosition);
|
|
yPosition += 6;
|
|
}
|
|
|
|
if (registro.arquitetura) {
|
|
doc.text(` Arquitetura: ${registro.arquitetura}`, 20, yPosition);
|
|
yPosition += 6;
|
|
}
|
|
|
|
if (registro.plataforma) {
|
|
doc.text(` Plataforma: ${registro.plataforma}`, 20, yPosition);
|
|
yPosition += 6;
|
|
}
|
|
|
|
yPosition += 3;
|
|
}
|
|
|
|
// Informações do Dispositivo
|
|
if (registro.deviceType || registro.screenResolution) {
|
|
doc.setFont('helvetica', 'bold');
|
|
doc.text('Dispositivo:', 15, yPosition);
|
|
doc.setFont('helvetica', 'normal');
|
|
yPosition += 6;
|
|
|
|
if (registro.deviceType) {
|
|
doc.text(` Tipo: ${registro.deviceType}`, 20, yPosition);
|
|
yPosition += 6;
|
|
}
|
|
|
|
if (registro.deviceModel) {
|
|
doc.text(` Modelo: ${registro.deviceModel}`, 20, yPosition);
|
|
yPosition += 6;
|
|
}
|
|
|
|
if (registro.screenResolution) {
|
|
doc.text(` Resolução: ${registro.screenResolution}`, 20, yPosition);
|
|
yPosition += 6;
|
|
}
|
|
|
|
if (registro.coresTela) {
|
|
doc.text(` Cores: ${registro.coresTela}`, 20, yPosition);
|
|
yPosition += 6;
|
|
}
|
|
|
|
if (registro.isMobile || registro.isTablet || registro.isDesktop) {
|
|
const tipoDispositivo = registro.isMobile ? 'Mobile' : registro.isTablet ? 'Tablet' : 'Desktop';
|
|
doc.text(` Categoria: ${tipoDispositivo}`, 20, yPosition);
|
|
yPosition += 6;
|
|
}
|
|
|
|
if (registro.idioma) {
|
|
doc.text(` Idioma: ${registro.idioma}`, 20, yPosition);
|
|
yPosition += 6;
|
|
}
|
|
|
|
if (registro.connectionType) {
|
|
doc.text(` Conexão: ${registro.connectionType}`, 20, yPosition);
|
|
yPosition += 6;
|
|
}
|
|
|
|
if (registro.memoryInfo) {
|
|
doc.text(` Memória: ${registro.memoryInfo}`, 20, yPosition);
|
|
yPosition += 6;
|
|
}
|
|
|
|
yPosition += 3;
|
|
}
|
|
|
|
// Imagem capturada (se disponível)
|
|
if (registro.imagemUrl) {
|
|
yPosition += 10;
|
|
// Verificar se precisa de nova página
|
|
if (yPosition > 200) {
|
|
doc.addPage();
|
|
yPosition = 20;
|
|
}
|
|
|
|
doc.setFont('helvetica', 'bold');
|
|
doc.text('FOTO CAPTURADA', 105, yPosition, { align: 'center' });
|
|
doc.setFont('helvetica', 'normal');
|
|
yPosition += 10;
|
|
|
|
try {
|
|
// Carregar imagem usando fetch para evitar problemas de CORS
|
|
const response = await fetch(registro.imagemUrl);
|
|
if (!response.ok) {
|
|
throw new Error('Erro ao carregar imagem');
|
|
}
|
|
|
|
const blob = await response.blob();
|
|
const reader = new FileReader();
|
|
|
|
// Converter blob para base64
|
|
const base64 = await new Promise<string>((resolve, reject) => {
|
|
reader.onloadend = () => {
|
|
if (typeof reader.result === 'string') {
|
|
resolve(reader.result);
|
|
} else {
|
|
reject(new Error('Erro ao converter imagem'));
|
|
}
|
|
};
|
|
reader.onerror = () => reject(new Error('Erro ao ler imagem'));
|
|
reader.readAsDataURL(blob);
|
|
});
|
|
|
|
// Criar elemento de imagem para obter dimensões
|
|
const img = new Image();
|
|
await new Promise<void>((resolve, reject) => {
|
|
img.onload = () => resolve();
|
|
img.onerror = () => reject(new Error('Erro ao processar imagem'));
|
|
img.src = base64;
|
|
setTimeout(() => reject(new Error('Timeout ao processar imagem')), 10000);
|
|
});
|
|
|
|
// Calcular dimensões para caber na página (largura máxima 80mm, manter proporção)
|
|
const maxWidth = 80;
|
|
const maxHeight = 60;
|
|
let imgWidth = img.width;
|
|
let imgHeight = img.height;
|
|
const aspectRatio = imgWidth / imgHeight;
|
|
|
|
if (imgWidth > maxWidth || imgHeight > maxHeight) {
|
|
if (aspectRatio > 1) {
|
|
// Imagem horizontal
|
|
imgWidth = maxWidth;
|
|
imgHeight = maxWidth / aspectRatio;
|
|
} else {
|
|
// Imagem vertical
|
|
imgHeight = maxHeight;
|
|
imgWidth = maxHeight * aspectRatio;
|
|
}
|
|
}
|
|
|
|
// Centralizar imagem
|
|
const xPosition = (doc.internal.pageSize.getWidth() - imgWidth) / 2;
|
|
|
|
// Verificar se cabe na página atual
|
|
if (yPosition + imgHeight > doc.internal.pageSize.getHeight() - 20) {
|
|
doc.addPage();
|
|
yPosition = 20;
|
|
}
|
|
|
|
// Adicionar imagem ao PDF usando base64
|
|
doc.addImage(base64, 'JPEG', xPosition, yPosition, imgWidth, imgHeight);
|
|
yPosition += imgHeight + 10;
|
|
} catch (error) {
|
|
console.warn('Erro ao adicionar imagem ao PDF:', error);
|
|
doc.setFontSize(10);
|
|
doc.text('Foto não disponível para impressão', 105, yPosition, { align: 'center' });
|
|
yPosition += 6;
|
|
}
|
|
}
|
|
|
|
// Rodapé
|
|
const pageCount = doc.getNumberOfPages();
|
|
for (let i = 1; i <= pageCount; i++) {
|
|
doc.setPage(i);
|
|
doc.setFontSize(8);
|
|
doc.setTextColor(128, 128, 128);
|
|
doc.text(
|
|
`SGSE - Sistema de Gerenciamento de Secretaria | Página ${i} de ${pageCount}`,
|
|
doc.internal.pageSize.getWidth() / 2,
|
|
doc.internal.pageSize.getHeight() - 10,
|
|
{ align: 'center' }
|
|
);
|
|
}
|
|
|
|
// Salvar
|
|
const nomeArquivo = `detalhes-ponto-${registro.data}-${registro.hora.toString().padStart(2, '0')}${registro.minuto.toString().padStart(2, '0')}.pdf`;
|
|
doc.save(nomeArquivo);
|
|
} catch (error) {
|
|
console.error('Erro ao gerar PDF detalhado:', error);
|
|
alert('Erro ao gerar relatório detalhado. Tente novamente.');
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div class="container mx-auto px-4 py-6">
|
|
<!-- Header -->
|
|
<div class="flex items-center justify-between mb-6">
|
|
<div class="flex items-center gap-4">
|
|
<div class="p-3 bg-primary/10 rounded-xl">
|
|
<Clock class="h-8 w-8 text-primary" strokeWidth={2} />
|
|
</div>
|
|
<div>
|
|
<h1 class="text-3xl font-bold text-base-content">Registro de Pontos</h1>
|
|
<p class="text-base-content/60 mt-1">Gerencie e visualize os registros de ponto dos funcionários</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Estatísticas -->
|
|
{#if estatisticas}
|
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
|
<div class="stat bg-base-100 shadow-lg rounded-lg">
|
|
<div class="stat-figure text-primary">
|
|
<BarChart3 class="h-8 w-8" />
|
|
</div>
|
|
<div class="stat-title">Total de Registros</div>
|
|
<div class="stat-value text-primary">{estatisticas.totalRegistros}</div>
|
|
</div>
|
|
|
|
<div class="stat bg-base-100 shadow-lg rounded-lg">
|
|
<div class="stat-figure text-success">
|
|
<CheckCircle2 class="h-8 w-8" />
|
|
</div>
|
|
<div class="stat-title">Dentro do Prazo</div>
|
|
<div class="stat-value text-success">{estatisticas.dentroDoPrazo}</div>
|
|
<div class="stat-desc">
|
|
{estatisticas.totalRegistros > 0
|
|
? ((estatisticas.dentroDoPrazo / estatisticas.totalRegistros) * 100).toFixed(1)
|
|
: 0}%
|
|
</div>
|
|
</div>
|
|
|
|
<div class="stat bg-base-100 shadow-lg rounded-lg">
|
|
<div class="stat-figure text-error">
|
|
<XCircle class="h-8 w-8" />
|
|
</div>
|
|
<div class="stat-title">Fora do Prazo</div>
|
|
<div class="stat-value text-error">{estatisticas.foraDoPrazo}</div>
|
|
<div class="stat-desc">
|
|
{estatisticas.totalRegistros > 0
|
|
? ((estatisticas.foraDoPrazo / estatisticas.totalRegistros) * 100).toFixed(1)
|
|
: 0}%
|
|
</div>
|
|
</div>
|
|
|
|
<div class="stat bg-base-100 shadow-lg rounded-lg">
|
|
<div class="stat-figure text-info">
|
|
<Users class="h-8 w-8" />
|
|
</div>
|
|
<div class="stat-title">Funcionários</div>
|
|
<div class="stat-value text-info">{estatisticas.totalFuncionarios}</div>
|
|
<div class="stat-desc">
|
|
{estatisticas.funcionariosDentroPrazo} dentro do prazo, {estatisticas.funcionariosForaPrazo} fora
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Gráfico de Estatísticas -->
|
|
{#if estatisticas}
|
|
<div class="card bg-base-100 shadow-xl mb-6">
|
|
<div class="card-body">
|
|
<h2 class="card-title mb-4">
|
|
<BarChart3 class="h-5 w-5" />
|
|
Visão Geral das Estatísticas
|
|
</h2>
|
|
<div class="h-80 w-full relative">
|
|
<canvas bind:this={chartCanvas} class="w-full h-full"></canvas>
|
|
{#if !chartInstance && estatisticas}
|
|
<div class="absolute inset-0 flex items-center justify-center">
|
|
<span class="loading loading-spinner loading-lg text-primary"></span>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
<div class="mt-4 grid grid-cols-2 md:grid-cols-4 gap-4 text-center">
|
|
<div class="stat bg-base-200 rounded-lg p-4">
|
|
<div class="stat-title text-xs">Total</div>
|
|
<div class="stat-value text-primary text-2xl">{estatisticas.totalRegistros}</div>
|
|
</div>
|
|
<div class="stat bg-success/10 rounded-lg p-4">
|
|
<div class="stat-title text-xs">Dentro do Prazo</div>
|
|
<div class="stat-value text-success text-2xl">{estatisticas.dentroDoPrazo}</div>
|
|
<div class="stat-desc text-success">
|
|
{estatisticas.totalRegistros > 0
|
|
? ((estatisticas.dentroDoPrazo / estatisticas.totalRegistros) * 100).toFixed(1)
|
|
: 0}%
|
|
</div>
|
|
</div>
|
|
<div class="stat bg-error/10 rounded-lg p-4">
|
|
<div class="stat-title text-xs">Fora do Prazo</div>
|
|
<div class="stat-value text-error text-2xl">{estatisticas.foraDoPrazo}</div>
|
|
<div class="stat-desc text-error">
|
|
{estatisticas.totalRegistros > 0
|
|
? ((estatisticas.foraDoPrazo / estatisticas.totalRegistros) * 100).toFixed(1)
|
|
: 0}%
|
|
</div>
|
|
</div>
|
|
<div class="stat bg-info/10 rounded-lg p-4">
|
|
<div class="stat-title text-xs">Funcionários</div>
|
|
<div class="stat-value text-info text-2xl">{estatisticas.totalFuncionarios}</div>
|
|
<div class="stat-desc text-info">
|
|
{estatisticas.funcionariosDentroPrazo} dentro, {estatisticas.funcionariosForaPrazo} fora
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Filtros -->
|
|
<div class="card bg-base-100 shadow-xl mb-6">
|
|
<div class="card-body">
|
|
<h2 class="card-title mb-4">
|
|
<Filter class="h-5 w-5" />
|
|
Filtros
|
|
</h2>
|
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
<div class="form-control">
|
|
<label class="label" for="data-inicio">
|
|
<span class="label-text font-medium">Data Início</span>
|
|
</label>
|
|
<input
|
|
id="data-inicio"
|
|
type="date"
|
|
bind:value={dataInicio}
|
|
class="input input-bordered"
|
|
/>
|
|
</div>
|
|
|
|
<div class="form-control">
|
|
<label class="label" for="data-fim">
|
|
<span class="label-text font-medium">Data Fim</span>
|
|
</label>
|
|
<input
|
|
id="data-fim"
|
|
type="date"
|
|
bind:value={dataFim}
|
|
class="input input-bordered"
|
|
/>
|
|
</div>
|
|
|
|
<div class="form-control">
|
|
<label class="label" for="funcionario">
|
|
<span class="label-text font-medium">Funcionário</span>
|
|
</label>
|
|
<select
|
|
id="funcionario"
|
|
bind:value={funcionarioIdFiltro}
|
|
class="select select-bordered"
|
|
>
|
|
<option value="">Todos</option>
|
|
{#each funcionarios as funcionario}
|
|
<option value={funcionario._id}>{funcionario.nome}</option>
|
|
{/each}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Lista de Registros -->
|
|
<div class="card bg-base-100 shadow-xl">
|
|
<div class="card-body">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<h2 class="card-title">Registros</h2>
|
|
|
|
<!-- Exibição dos Filtros Selecionados -->
|
|
{#if funcionarioIdFiltro || dataInicio || dataFim}
|
|
<div class="flex items-center gap-2 flex-wrap">
|
|
{#if funcionarioIdFiltro && funcionarioSelecionadoNome}
|
|
<div class="badge badge-primary badge-lg gap-2">
|
|
<Users class="h-3 w-3" />
|
|
{funcionarioSelecionadoNome}
|
|
</div>
|
|
{/if}
|
|
{#if dataInicio}
|
|
<div class="badge badge-info badge-lg gap-2">
|
|
<Clock class="h-3 w-3" />
|
|
De: {formatarData(dataInicio)}
|
|
</div>
|
|
{/if}
|
|
{#if dataFim}
|
|
<div class="badge badge-info badge-lg gap-2">
|
|
<Clock class="h-3 w-3" />
|
|
Até: {formatarData(dataFim)}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if registrosQuery?.status === 'Loading'}
|
|
<div class="flex items-center justify-center py-12">
|
|
<span class="loading loading-spinner loading-lg text-primary"></span>
|
|
<span class="ml-4 text-base-content/70">Carregando registros...</span>
|
|
</div>
|
|
{:else if registrosQuery?.error}
|
|
<div class="alert alert-error">
|
|
<span>Erro ao carregar registros: {registrosQuery.error.message || 'Erro desconhecido'}</span>
|
|
</div>
|
|
{:else if !registrosQuery?.data}
|
|
<div class="alert alert-warning">
|
|
<span>Aguardando dados da consulta...</span>
|
|
</div>
|
|
{:else if registros.length === 0}
|
|
<div class="alert alert-info">
|
|
<span>Nenhum registro encontrado para o período selecionado</span>
|
|
<div class="text-sm mt-2 opacity-70">
|
|
Período: {formatarData(dataInicio)} até {formatarData(dataFim)}
|
|
{#if funcionarioIdFiltro && funcionarioSelecionadoNome}
|
|
<br />
|
|
Funcionário: {funcionarioSelecionadoNome}
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{:else if registrosAgrupados.length === 0}
|
|
<div class="alert alert-warning">
|
|
<span>Registros encontrados, mas não foi possível agrupá-los</span>
|
|
<div class="text-sm mt-2 opacity-70">
|
|
Total de registros: {registros.length}
|
|
</div>
|
|
</div>
|
|
{:else}
|
|
<div class="space-y-4">
|
|
{#each registrosAgrupados as grupo}
|
|
<div class="card bg-base-200">
|
|
<div class="card-body">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<div class="flex-1">
|
|
<h3 class="font-bold text-lg">
|
|
{grupo.funcionario?.nome || 'Funcionário não encontrado'}
|
|
</h3>
|
|
{#if grupo.funcionario?.matricula}
|
|
<p class="text-sm text-base-content/70">
|
|
Matrícula: {grupo.funcionario.matricula}
|
|
</p>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- 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="mx-4 rounded-lg border-2 p-3 {saldoPositivo ? 'border-success bg-success/10' : 'border-error bg-error/10'}">
|
|
<div class="flex items-center gap-2">
|
|
{#if saldoPositivo}
|
|
<TrendingUp class="h-5 w-5 text-success" />
|
|
{:else}
|
|
<TrendingDown class="h-5 w-5 text-error" />
|
|
{/if}
|
|
<div>
|
|
<p class="text-xs font-semibold opacity-70">Banco de Horas</p>
|
|
<p class="text-lg font-bold">
|
|
{formatarSaldoHoras(saldoAcumulado)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
{/key}
|
|
|
|
<button
|
|
class="btn btn-sm btn-primary"
|
|
onclick={() => abrirModalImpressao(grupo.funcionarioId)}
|
|
>
|
|
<Printer class="h-4 w-4" />
|
|
Imprimir Ficha
|
|
</button>
|
|
</div>
|
|
|
|
<div class="overflow-x-auto max-h-[600px] overflow-y-auto border border-base-300 rounded-lg">
|
|
<table class="table table-zebra">
|
|
<thead class="sticky top-0 bg-base-200 z-10 shadow-sm">
|
|
<tr>
|
|
<th class="bg-base-200 whitespace-nowrap">Data</th>
|
|
<th class="bg-base-200 whitespace-nowrap">Tipo</th>
|
|
<th class="bg-base-200 whitespace-nowrap">Horário</th>
|
|
<th class="bg-base-200 whitespace-nowrap">Saldo Diário</th>
|
|
<th class="bg-base-200 whitespace-nowrap">Status</th>
|
|
<th class="bg-base-200 whitespace-nowrap">Ações</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{#each Object.values(grupo.registrosPorData) as grupoData}
|
|
{@const totalRegistros = grupoData.registros.length}
|
|
{@const dataParts = grupoData.data.split('-')}
|
|
{@const dataFormatada = `${dataParts[2]}/${dataParts[1]}/${dataParts[0]}`}
|
|
{#each grupoData.registros as registro, index}
|
|
<tr>
|
|
<td class="whitespace-nowrap">{dataFormatada}</td>
|
|
<td class="whitespace-nowrap">
|
|
{config
|
|
? getTipoRegistroLabel(registro.tipo, {
|
|
nomeEntrada: config.nomeEntrada,
|
|
nomeSaidaAlmoco: config.nomeSaidaAlmoco,
|
|
nomeRetornoAlmoco: config.nomeRetornoAlmoco,
|
|
nomeSaida: config.nomeSaida,
|
|
})
|
|
: getTipoRegistroLabel(registro.tipo)}
|
|
</td>
|
|
<td class="whitespace-nowrap">{formatarHoraPonto(registro.hora, registro.minuto)}</td>
|
|
{#if index === 0}
|
|
<td class="whitespace-nowrap" rowspan={totalRegistros}>
|
|
{#if grupoData.saldoDiario}
|
|
<span
|
|
class="badge {grupoData.saldoDiario.positivo ? 'badge-success' : 'badge-error'}"
|
|
>
|
|
{formatarSaldoDiario(grupoData.saldoDiario)}
|
|
</span>
|
|
{:else}
|
|
<span class="badge badge-ghost">-</span>
|
|
{/if}
|
|
</td>
|
|
{/if}
|
|
<td class="whitespace-nowrap">
|
|
<span
|
|
class="badge {registro.dentroDoPrazo ? 'badge-success' : 'badge-error'}"
|
|
>
|
|
{registro.dentroDoPrazo ? 'Dentro do Prazo' : 'Fora do Prazo'}
|
|
</span>
|
|
</td>
|
|
<td class="whitespace-nowrap">
|
|
<button
|
|
class="btn btn-sm btn-outline btn-primary gap-2"
|
|
onclick={() => imprimirDetalhesRegistro(registro._id)}
|
|
title="Imprimir Detalhes"
|
|
>
|
|
<FileText class="h-4 w-4" />
|
|
Imprimir Detalhes
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
{/each}
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{#if mostrarModalImpressao && funcionarioParaImprimir}
|
|
<PrintPontoModal
|
|
funcionarioId={funcionarioParaImprimir}
|
|
onClose={() => {
|
|
mostrarModalImpressao = false;
|
|
funcionarioParaImprimir = '';
|
|
}}
|
|
onGenerate={gerarPDFComSelecao}
|
|
/>
|
|
{/if}
|
|
|