Files
sgse-app/apps/web/src/lib/components/ponto/RegistroPonto.svelte
deyvisonwanderley c056506ce5 feat: enhance time synchronization and Jitsi configuration handling
- Implemented a comprehensive time synchronization mechanism that applies GMT offsets based on user configuration, ensuring accurate timestamps across the application.
- Updated the Jitsi configuration to include SSH settings, allowing for better integration with Docker setups.
- Refactored the backend queries and mutations to handle the new SSH configuration fields, ensuring secure and flexible server management.
- Enhanced error handling and logging for time synchronization processes, providing clearer feedback for users and developers.
2025-11-22 18:18:16 -03:00

1432 lines
50 KiB
Svelte

<script lang="ts">
import { onMount } from 'svelte';
import { useConvexClient, useQuery } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import RelogioSincronizado from './RelogioSincronizado.svelte';
import WebcamCapture from './WebcamCapture.svelte';
import ComprovantePonto from './ComprovantePonto.svelte';
import ErrorModal from '../ErrorModal.svelte';
import { obterTempoServidor } from '$lib/utils/sincronizacaoTempo';
import { obterInformacoesDispositivo } from '$lib/utils/deviceInfo';
import {
formatarHoraPonto,
getTipoRegistroLabel,
getProximoTipoRegistro
} from '$lib/utils/ponto';
import { LogIn, LogOut, Clock, CheckCircle2, XCircle, TrendingUp, TrendingDown, Printer } from 'lucide-svelte';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import jsPDF from 'jspdf';
import logoGovPE from '$lib/assets/logo_governo_PE.png';
import { formatarDataHoraCompleta } from '$lib/utils/ponto';
const client = useConvexClient();
// Queries
const currentUser = useQuery(api.auth.getCurrentUser, {});
const configQuery = useQuery(api.configuracaoPonto.obterConfiguracao, {});
const registrosHojeQuery = useQuery(api.pontos.listarRegistrosDia, {});
// Query para histórico e saldo do dia
const funcionarioId = $derived(currentUser?.data?.funcionarioId ?? null);
const dataHoje = $derived(new Date().toISOString().split('T')[0]!);
const historicoSaldoQuery = useQuery(
api.pontos.obterHistoricoESaldoDia,
funcionarioId && dataHoje ? { funcionarioId, data: dataHoje } : 'skip'
);
// Query para verificar dispensa ativa
const dispensaQuery = useQuery(
api.pontos.verificarDispensaAtiva,
funcionarioId && dataHoje ? { funcionarioId, data: dataHoje } : 'skip'
);
// Estados
let mostrandoWebcam = $state(false);
let registrando = $state(false);
let sucesso = $state<string | null>(null);
let registroId = $state<Id<'registrosPonto'> | null>(null);
let mostrandoComprovante = $state(false);
let imagemCapturada = $state<Blob | null>(null);
let coletandoInfo = $state(false);
let capturandoAutomaticamente = $state(false);
let mostrarModalErro = $state(false);
let mensagemErroModal = $state('');
let detalhesErroModal = $state('');
let justificativa = $state('');
let mostrandoModalConfirmacao = $state(false);
let dataHoraAtual = $state<{ data: string; hora: string } | null>(null);
const registrosHoje = $derived(registrosHojeQuery?.data || []);
const config = $derived(configQuery?.data);
const proximoTipo = $derived.by(() => {
if (registrosHoje.length === 0) {
return 'entrada';
}
const ultimoRegistro = registrosHoje[registrosHoje.length - 1];
return getProximoTipoRegistro(ultimoRegistro?.tipo || null);
});
const tipoLabel = $derived.by(() => {
if (config) {
return getTipoRegistroLabel(proximoTipo, {
nomeEntrada: config.nomeEntrada,
nomeSaidaAlmoco: config.nomeSaidaAlmoco,
nomeRetornoAlmoco: config.nomeRetornoAlmoco,
nomeSaida: config.nomeSaida,
});
}
return getTipoRegistroLabel(proximoTipo);
});
async function uploadImagem(blob: Blob): Promise<Id<'_storage'> | undefined> {
try {
// Obter URL de upload
const uploadUrl = await client.mutation(api.pontos.generateUploadUrl, {});
// Criar File a partir do Blob
const file = new File([blob], 'ponto.jpg', { type: 'image/jpeg' });
// Fazer upload
const response = await fetch(uploadUrl, {
method: 'POST',
headers: { 'Content-Type': file.type },
body: file
});
if (!response.ok) {
throw new Error('Falha no upload da imagem');
}
// A resposta do Convex storage retorna JSON com storageId
const { storageId } = (await response.json()) as { storageId: string };
return storageId as Id<'_storage'>;
} catch (error) {
console.error('Erro ao fazer upload da imagem:', error);
return undefined;
}
}
// Verificar permissões de localização e webcam
async function verificarPermissoes(): Promise<{ localizacao: boolean; webcam: boolean }> {
let localizacaoAutorizada = false;
let webcamAutorizada = false;
// Verificar permissão de geolocalização
if (navigator.geolocation) {
try {
await new Promise<void>((resolve, reject) => {
const timeoutId = setTimeout(() => {
reject(new Error('Timeout'));
}, 5000);
navigator.geolocation.getCurrentPosition(
() => {
clearTimeout(timeoutId);
localizacaoAutorizada = true;
resolve();
},
() => {
clearTimeout(timeoutId);
reject(new Error('Permissão de localização negada'));
},
{ timeout: 5000, maximumAge: 0, enableHighAccuracy: false }
);
});
} catch (error) {
console.warn('Permissão de localização não concedida:', error);
}
}
// Verificar permissão de webcam
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
webcamAutorizada = true;
// Parar o stream imediatamente, apenas verificamos a permissão
stream.getTracks().forEach(track => track.stop());
} catch (error) {
console.warn('Permissão de webcam não concedida:', error);
}
}
return { localizacao: localizacaoAutorizada, webcam: webcamAutorizada };
}
async function registrarPonto() {
if (registrando) return;
// Verificar se tem funcionário associado
if (!temFuncionarioAssociado) {
mensagemErroModal = 'Usuário não possui funcionário associado';
detalhesErroModal = 'Você não possui um funcionário associado à sua conta. Entre em contato com o administrador do sistema.';
mostrarModalErro = true;
return;
}
// Verificar se está dispensado antes de registrar
if (estaDispensado) {
mensagemErroModal = 'Registro dispensado pelo gestor';
detalhesErroModal = motivoDispensa || 'Você está dispensado de registrar ponto no momento.';
mostrarModalErro = true;
return;
}
// Verificar permissões antes de registrar
const permissoes = await verificarPermissoes();
if (!permissoes.localizacao || !permissoes.webcam) {
mensagemErroModal = 'Permissões necessárias';
detalhesErroModal = 'Para registrar o ponto, é necessário autorizar o compartilhamento de localização e a captura de foto.';
mostrarModalErro = true;
return;
}
registrando = true;
sucesso = null;
coletandoInfo = true;
try {
// Coletar informações do dispositivo
const informacoesDispositivo = await obterInformacoesDispositivo();
coletandoInfo = false;
// Obter tempo sincronizado e aplicar GMT offset (igual ao relógio)
const configRelogio = await client.query(api.configuracaoRelogio.obterConfiguracao, {});
// Usar gmtOffset da configuração, sem valor padrão, pois 0 é um valor válido
const gmtOffset = configRelogio.gmtOffset ?? 0;
let timestampBase: number;
if (configRelogio.usarServidorExterno) {
try {
const resultado = await client.action(api.configuracaoRelogio.sincronizarTempo, {});
if (resultado.sucesso && resultado.timestamp) {
timestampBase = resultado.timestamp;
} else {
timestampBase = await obterTempoServidor(client);
}
} catch (error) {
console.warn('Erro ao sincronizar com servidor externo:', error);
if (configRelogio.fallbackParaPC) {
timestampBase = Date.now();
} else {
timestampBase = await obterTempoServidor(client);
}
}
} else {
// Usar relógio do PC (sem sincronização com servidor)
timestampBase = Date.now();
}
// Aplicar GMT offset ao timestamp
// Quando GMT é 0, compensar o timezone local do navegador para que o timestamp
// represente o horário local (não UTC), evitando que apareça 3 horas a mais
let timestamp: number;
if (gmtOffset !== 0) {
// Aplicar offset configurado
timestamp = timestampBase + (gmtOffset * 60 * 60 * 1000);
} else {
// Quando GMT = 0, ajustar para horário local do navegador
// getTimezoneOffset() retorna minutos POSITIVOS para fusos ATRÁS de UTC
// Exemplo: Brasil (UTC-3) retorna 180 minutos
// Subtrair esses minutos para que o timestamp represente o horário local
const timezoneOffset = new Date().getTimezoneOffset(); // Offset em minutos
timestamp = timestampBase - (timezoneOffset * 60 * 1000); // Subtrair minutos em milissegundos
}
// Sincronizado apenas se usar servidor externo e sincronização foi bem-sucedida
const sincronizadoComServidor = configRelogio.usarServidorExterno && timestampBase !== Date.now();
// Upload da imagem (obrigatória agora)
let imagemId: Id<'_storage'> | undefined = undefined;
if (imagemCapturada) {
try {
imagemId = await uploadImagem(imagemCapturada);
} catch (error) {
console.error('Erro ao fazer upload da imagem:', error);
throw new Error('Erro ao fazer upload da imagem. Tente novamente.');
}
} else {
throw new Error('É necessário capturar uma foto para registrar o ponto.');
}
// Registrar ponto
const resultado = await client.mutation(api.pontos.registrarPonto, {
imagemId,
informacoesDispositivo,
timestamp,
sincronizadoComServidor,
justificativa: justificativa.trim() || undefined
});
registroId = resultado.registroId;
const tipoLabelSucesso = config
? getTipoRegistroLabel(resultado.tipo, {
nomeEntrada: config.nomeEntrada,
nomeSaidaAlmoco: config.nomeSaidaAlmoco,
nomeRetornoAlmoco: config.nomeRetornoAlmoco,
nomeSaida: config.nomeSaida,
})
: getTipoRegistroLabel(resultado.tipo);
sucesso = `Ponto registrado com sucesso! Tipo: ${tipoLabelSucesso}`;
imagemCapturada = null;
justificativa = ''; // Limpar justificativa após registro
mostrandoModalConfirmacao = false;
// Mostrar comprovante após 1 segundo
setTimeout(() => {
mostrandoComprovante = true;
}, 1000);
} catch (error) {
console.error('Erro ao registrar ponto:', error);
const mensagemErro = error instanceof Error ? error.message : 'Erro ao registrar ponto';
// Verificar se é erro de registro duplicado
if (
mensagemErro.includes('Já existe um registro neste minuto') ||
mensagemErro.includes('já existe um registro')
) {
mensagemErroModal = 'Registro de ponto duplicado';
const tipoLabelErro = config
? getTipoRegistroLabel(proximoTipo, {
nomeEntrada: config.nomeEntrada,
nomeSaidaAlmoco: config.nomeSaidaAlmoco,
nomeRetornoAlmoco: config.nomeRetornoAlmoco,
nomeSaida: config.nomeSaida,
})
: getTipoRegistroLabel(proximoTipo);
detalhesErroModal = `Não é possível registrar o ponto no mesmo minuto.\n\nVocê já possui um registro de ${tipoLabelErro} para este minuto.\n\nPor favor, aguarde pelo menos 1 minuto antes de tentar registrar novamente.`;
mostrarModalErro = true;
} else {
// Outros erros também mostram no modal
mensagemErroModal = 'Erro ao registrar ponto';
detalhesErroModal = mensagemErro;
mostrarModalErro = true;
}
} finally {
registrando = false;
coletandoInfo = false;
}
}
async function handleWebcamCapture(blob: Blob | null) {
if (blob) {
imagemCapturada = blob;
}
mostrandoWebcam = false;
// Se capturou a foto, mostrar modal de confirmação
if (blob && capturandoAutomaticamente) {
capturandoAutomaticamente = false;
// Obter data e hora sincronizada do servidor com GMT offset (igual ao relógio)
try {
const configRelogio = await client.query(api.configuracaoRelogio.obterConfiguracao, {});
// Usar gmtOffset da configuração, sem valor padrão, pois 0 é um valor válido
const gmtOffset = configRelogio.gmtOffset ?? 0;
let timestampBase: number;
if (configRelogio.usarServidorExterno) {
try {
const resultado = await client.action(api.configuracaoRelogio.sincronizarTempo, {});
if (resultado.sucesso && resultado.timestamp) {
timestampBase = resultado.timestamp;
} else {
timestampBase = await obterTempoServidor(client);
}
} catch (error) {
console.warn('Erro ao sincronizar com servidor externo:', error);
if (configRelogio.fallbackParaPC) {
timestampBase = Date.now();
} else {
timestampBase = await obterTempoServidor(client);
}
}
} else {
// Usar relógio do PC (sem sincronização com servidor)
timestampBase = Date.now();
}
// Aplicar GMT offset ao timestamp
// Quando GMT é 0, usar timestamp UTC puro e deixar toLocaleTimeString() fazer a conversão automática
// Quando GMT ≠ 0, aplicar offset configurado ao timestamp
let timestamp: number;
if (gmtOffset !== 0) {
// Aplicar offset configurado
timestamp = timestampBase + (gmtOffset * 60 * 60 * 1000);
} else {
// Quando GMT = 0, manter timestamp UTC puro
// O toLocaleTimeString() converterá automaticamente para o timezone local do navegador
timestamp = timestampBase;
}
const dataObj = new Date(timestamp);
const data = dataObj.toLocaleDateString('pt-BR');
const hora = dataObj.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
dataHoraAtual = { data, hora };
} catch (error) {
console.warn('Erro ao obter tempo do servidor, usando tempo local:', error);
atualizarDataHoraAtual();
}
mostrandoModalConfirmacao = true;
}
}
function handleWebcamCancel() {
mostrandoWebcam = false;
capturandoAutomaticamente = false;
imagemCapturada = null;
mostrandoModalConfirmacao = false;
}
function handleWebcamError() {
// Em caso de erro na captura, fechar tudo
mostrandoWebcam = false;
capturandoAutomaticamente = false;
imagemCapturada = null;
mostrandoModalConfirmacao = false;
mensagemErroModal = 'Erro ao capturar foto';
detalhesErroModal = 'Não foi possível acessar a webcam. Verifique as permissões do navegador.';
mostrarModalErro = true;
}
function atualizarDataHoraAtual() {
const agora = new Date();
const data = agora.toLocaleDateString('pt-BR');
const hora = agora.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
dataHoraAtual = { data, hora };
}
async function iniciarRegistroComFoto() {
if (registrando || coletandoInfo) return;
// Verificar se tem funcionário associado
if (!temFuncionarioAssociado) {
mensagemErroModal = 'Usuário não possui funcionário associado';
detalhesErroModal = 'Você não possui um funcionário associado à sua conta. Entre em contato com o administrador do sistema.';
mostrarModalErro = true;
return;
}
// Verificar se está dispensado antes de abrir webcam
if (estaDispensado) {
mensagemErroModal = 'Registro dispensado pelo gestor';
detalhesErroModal = motivoDispensa || 'Você está dispensado de registrar ponto no momento.';
mostrarModalErro = true;
return;
}
// Verificar permissões antes de abrir webcam
const permissoes = await verificarPermissoes();
if (!permissoes.localizacao || !permissoes.webcam) {
mensagemErroModal = 'Permissões necessárias';
detalhesErroModal = 'Para registrar o ponto, é necessário autorizar o compartilhamento de localização e a captura de foto.';
mostrarModalErro = true;
return;
}
// Abrir webcam
capturandoAutomaticamente = true;
mostrandoWebcam = true;
}
function confirmarRegistro() {
mostrandoModalConfirmacao = false;
registrarPonto();
}
function cancelarRegistro() {
mostrandoModalConfirmacao = false;
imagemCapturada = null;
}
function fecharComprovante() {
mostrandoComprovante = false;
registroId = null;
}
function fecharModalErro() {
mostrarModalErro = false;
mensagemErroModal = '';
detalhesErroModal = '';
}
async function imprimirComprovante(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;
}
// Buscar configuração para usar nomes personalizados
const configComprovante = await client.query(api.configuracaoPonto.obterConfiguracao, {});
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('COMPROVANTE DE 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 tipoLabelComprovante = configComprovante
? getTipoRegistroLabel(registro.tipo, {
nomeEntrada: configComprovante.nomeEntrada,
nomeSaidaAlmoco: configComprovante.nomeSaidaAlmoco,
nomeRetornoAlmoco: configComprovante.nomeRetornoAlmoco,
nomeSaida: configComprovante.nomeSaida,
})
: getTipoRegistroLabel(registro.tipo);
doc.text(`Tipo: ${tipoLabelComprovante}`, 15, yPosition);
yPosition += 6;
const dataHora = formatarDataHoraCompleta(registro.data, registro.hora, registro.minuto, registro.segundo);
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 += 10;
// Imagem capturada (se disponível)
if (registro.imagemUrl) {
// 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 = `comprovante-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 comprovante PDF:', error);
alert('Erro ao gerar comprovante PDF. Tente novamente.');
}
}
const dispensaAtiva = $derived(dispensaQuery?.data);
const estaDispensado = $derived(dispensaAtiva?.dispensado ?? false);
const motivoDispensa = $derived(dispensaAtiva?.motivo ?? null);
const temFuncionarioAssociado = $derived(funcionarioId !== null);
const podeRegistrar = $derived.by(() => {
return !registrando && !coletandoInfo && config !== undefined && !estaDispensado && temFuncionarioAssociado;
});
// Referência para o modal
let modalRef: HTMLDivElement | null = $state(null);
// Efeito para garantir que o modal fique visível quando abrir
$effect(() => {
if (mostrandoWebcam && modalRef) {
// Aguardar um frame para garantir que o DOM foi atualizado
setTimeout(() => {
if (modalRef) {
modalRef.scrollIntoView({ behavior: 'smooth', block: 'center' });
// Também garantir que o modal-box esteja visível
const modalBox = modalRef.querySelector('.modal-box');
if (modalBox) {
(modalBox as HTMLElement).scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
}, 100);
}
});
// Solicitar permissões automaticamente ao montar o componente
onMount(async () => {
// Solicitar permissão de webcam
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
try {
// Solicitar apenas permissão, sem iniciar o stream ainda
await navigator.mediaDevices.getUserMedia({ video: true });
} catch (error) {
// Ignorar erro silenciosamente - a permissão será solicitada quando necessário
console.log('Permissão de webcam não concedida ainda');
}
}
// Solicitar permissão de geolocalização
if (navigator.geolocation) {
try {
// Solicitar permissão de geolocalização (timeout curto para não bloquear)
await new Promise<void>((resolve) => {
const timeoutId = setTimeout(() => resolve(), 2000); // Timeout de 2 segundos
navigator.geolocation.getCurrentPosition(
() => {
clearTimeout(timeoutId);
resolve();
},
() => {
clearTimeout(timeoutId);
resolve(); // Resolver mesmo se negado
},
{ timeout: 2000, maximumAge: 0, enableHighAccuracy: false } // enableHighAccuracy false para ser mais rápido
);
});
} catch (error) {
// Ignorar erro silenciosamente
console.log('Permissão de geolocalização não concedida ainda');
}
}
});
const mapaHorarios = $derived.by(() => {
if (!config) return [];
const horarios = [
{ tipo: 'entrada', horario: config.horarioEntrada, label: config.nomeEntrada || 'Entrada 1' },
{ tipo: 'saida_almoco', horario: config.horarioSaidaAlmoco, label: config.nomeSaidaAlmoco || 'Saída 1' },
{ tipo: 'retorno_almoco', horario: config.horarioRetornoAlmoco, label: config.nomeRetornoAlmoco || 'Entrada 2' },
{ tipo: 'saida', horario: config.horarioSaida, label: config.nomeSaida || 'Saída 2' }
];
return horarios.map((h) => {
const registro = registrosHoje.find((r) => r.tipo === h.tipo);
return {
...h,
registrado: !!registro,
horarioRegistrado: registro ? formatarHoraPonto(registro.hora, registro.minuto) : null,
dentroDoPrazo: registro?.dentroDoPrazo ?? null
};
});
});
// Dados do histórico e saldo
const historicoSaldo = $derived(historicoSaldoQuery?.data);
const registrosOrdenados = $derived.by(() => {
if (!historicoSaldo?.registros) return [];
return [...historicoSaldo.registros].sort((a, b) => {
const minutosA = a.hora * 60 + a.minuto;
const minutosB = b.hora * 60 + b.minuto;
return minutosA - minutosB;
});
});
// Formatação do saldo
const saldoFormatado = $derived.by(() => {
if (!historicoSaldo) return null;
const minutos = historicoSaldo.saldoMinutos;
const horas = Math.floor(Math.abs(minutos) / 60);
const mins = Math.abs(minutos) % 60;
const sinal = minutos >= 0 ? '+' : '-';
return `${sinal}${horas}h ${mins}min`;
});
const saldoPositivo = $derived(historicoSaldo ? historicoSaldo.saldoMinutos >= 0 : false);
</script>
<div class="space-y-6">
<!-- Alerta de Funcionário Não Associado -->
{#if !temFuncionarioAssociado}
<div class="alert alert-error shadow-lg">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<div>
<h3 class="font-bold">Funcionário Não Associado</h3>
<div class="text-sm">
Você não possui um funcionário associado à sua conta.
<br />
Entre em contato com o administrador do sistema para associar um funcionário à sua conta.
</div>
</div>
</div>
{/if}
<!-- Alerta de Dispensa -->
{#if estaDispensado && motivoDispensa && temFuncionarioAssociado}
<div class="alert alert-warning shadow-lg">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<div>
<h3 class="font-bold">Registro de Ponto Dispensado</h3>
<div class="text-sm">
Você está dispensado de registrar ponto no momento.
<br />
<strong>Motivo:</strong> {motivoDispensa}
</div>
</div>
</div>
{/if}
<!-- Botões de Registro -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body items-center">
<h2 class="card-title mb-4">Registrar Ponto</h2>
<div class="mb-6 w-full">
<RelogioSincronizado />
</div>
<div class="flex w-full flex-col items-center gap-4">
{#if sucesso}
<div class="alert alert-success w-full">
<CheckCircle2 class="h-5 w-5" />
<span>{sucesso}</span>
</div>
{/if}
<div class="mb-4 text-center">
<p class="text-lg font-semibold">Próximo registro: {tipoLabel}</p>
</div>
<!-- Campo de Justificativa (Opcional) -->
<div class="w-full">
<label for="justificativa" class="label">
<span class="label-text">Justificativa (Opcional)</span>
</label>
<textarea
id="justificativa"
class="textarea textarea-bordered w-full"
placeholder="Digite uma justificativa para este registro de ponto (opcional)"
bind:value={justificativa}
disabled={registrando}
rows="3"
></textarea>
</div>
<button
class="btn btn-primary btn-lg"
onclick={iniciarRegistroComFoto}
disabled={!podeRegistrar}
title={!temFuncionarioAssociado
? 'Você não possui funcionário associado à sua conta'
: estaDispensado
? 'Você está dispensado de registrar ponto no momento'
: ''}
>
{#if registrando}
<span class="loading loading-spinner loading-sm"></span>
{#if coletandoInfo}
Coletando informações...
{:else}
Registrando...
{/if}
{:else if !temFuncionarioAssociado}
<XCircle class="h-5 w-5" />
Funcionário Não Associado
{:else if estaDispensado}
<XCircle class="h-5 w-5" />
Registro Indisponível
{:else if proximoTipo === 'entrada' || proximoTipo === 'retorno_almoco'}
<LogIn class="h-5 w-5" />
Registrar Entrada
{:else}
<LogOut class="h-5 w-5" />
Registrar Saída
{/if}
</button>
</div>
</div>
</div>
<!-- Mapa de Horários -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title mb-6">
<Clock class="h-5 w-5" />
Horário Padrão
</h2>
<!-- Linha horizontal com espaçamento uniforme -->
<div class="flex flex-wrap items-stretch justify-between gap-4 md:gap-6">
{#each mapaHorarios as horario (horario.tipo)}
<div class="flex-1 min-w-[140px] max-w-[220px] mx-auto">
<div
class="relative h-full rounded-xl border-2 transition-all duration-300 hover:shadow-lg {horario.registrado
? horario.dentroDoPrazo
? 'bg-gradient-to-br from-success/20 to-success/10 border-success shadow-md'
: 'bg-gradient-to-br from-error/20 to-error/10 border-error shadow-md'
: 'bg-gradient-to-br from-base-200 to-base-300 border-base-300'} p-5"
>
<!-- Status Icon -->
<div class="absolute top-3 right-3">
{#if horario.registrado}
{#if horario.dentroDoPrazo}
<CheckCircle2 class="h-5 w-5 text-success" />
{:else}
<XCircle class="h-5 w-5 text-error" />
{/if}
{:else}
<Clock class="h-5 w-5 text-base-content/30" />
{/if}
</div>
<!-- Label -->
<div class="mb-3">
<span class="text-sm font-semibold text-base-content/80 uppercase tracking-wide">
{horario.label}
</span>
</div>
<!-- Horário Padrão -->
<div class="mb-2">
<div class="text-3xl font-bold text-primary font-mono">
{horario.horario}
</div>
</div>
<!-- Horário Registrado (se houver) -->
{#if horario.registrado}
<div class="mt-3 pt-3 border-t border-base-content/10">
<div class="flex items-center gap-2">
<div class="text-xs font-medium text-base-content/60">
Registrado:
</div>
<div class="text-sm font-bold text-base-content">
{horario.horarioRegistrado}
</div>
</div>
</div>
{:else}
<div class="mt-3 pt-3 border-t border-base-content/10">
<div class="text-xs text-base-content/40 italic">
Aguardando registro
</div>
</div>
{/if}
</div>
</div>
{/each}
</div>
</div>
</div>
<!-- Histórico e Saldo do Dia -->
{#if historicoSaldo && registrosOrdenados.length > 0}
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">
<Clock class="h-5 w-5" />
Histórico do Dia
</h2>
<!-- Saldo de Horas -->
<div class="my-4 rounded-lg border-2 p-4 {saldoPositivo ? 'border-success bg-success/10' : 'border-error bg-error/10'}">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-semibold opacity-70">Saldo de Horas</p>
<p class="text-2xl font-bold">
{saldoFormatado}
</p>
</div>
{#if saldoPositivo}
<TrendingUp class="h-8 w-8 text-success" />
{:else}
<TrendingDown class="h-8 w-8 text-error" />
{/if}
</div>
<div class="mt-2 text-sm opacity-70">
<p>Carga Horária Diária: {Math.floor(historicoSaldo.cargaHorariaDiaria / 60)}h {historicoSaldo.cargaHorariaDiaria % 60}min</p>
<p>Horas Trabalhadas: {Math.floor(historicoSaldo.horasTrabalhadas / 60)}h {historicoSaldo.horasTrabalhadas % 60}min</p>
</div>
</div>
<!-- Timeline de Registros -->
<div class="divider"></div>
<div class="space-y-4">
<h3 class="font-semibold">Timeline do Dia</h3>
<!-- Timeline Visual com horários padrão e registros reais -->
<div class="relative">
<!-- Linha vertical central da timeline -->
<div class="absolute left-1/2 top-0 bottom-0 w-1 bg-gradient-to-b from-primary/20 via-base-300 to-secondary/20 transform -translate-x-1/2"></div>
<!-- Container com duas colunas -->
<div class="grid grid-cols-2 gap-4 relative">
<!-- Coluna Entrada -->
<div class="space-y-4 pr-2">
<div class="sticky top-0 z-10 bg-base-100 pb-3 mb-2 border-b border-primary/20">
<h4 class="text-lg font-bold text-primary text-center flex items-center justify-center gap-2">
<LogIn class="h-5 w-5" />
Entradas
</h4>
</div>
{#each registrosOrdenados.filter(r => r.tipo === 'entrada' || r.tipo === 'retorno_almoco') as registro (registro._id)}
<div class="relative">
<!-- Linha horizontal conectando à timeline -->
<div class="absolute right-0 top-6 w-full h-0.5 bg-base-300/50" style="width: calc(100% - 0.5rem);"></div>
<!-- Card do registro -->
<div class="card {registro.dentroDoPrazo ? 'bg-success/5 border-success/30' : 'bg-error/5 border-error/30'} border-2 shadow-md hover:shadow-lg transition-all">
<div class="card-body p-4">
<!-- Tipo de registro e status -->
<div class="flex items-center gap-2 mb-2">
{#if registro.dentroDoPrazo}
<CheckCircle2 class="h-4 w-4 text-success flex-shrink-0" />
{:else}
<XCircle class="h-4 w-4 text-error flex-shrink-0" />
{/if}
<span class="text-sm font-semibold text-base-content/80">
{config
? getTipoRegistroLabel(registro.tipo, {
nomeEntrada: config.nomeEntrada,
nomeRetornoAlmoco: config.nomeRetornoAlmoco,
})
: getTipoRegistroLabel(registro.tipo)}
</span>
</div>
<!-- Horário registrado -->
<p class="text-3xl font-bold text-primary mb-1">
{formatarHoraPonto(registro.hora, registro.minuto)}
</p>
<!-- Comparação com horário esperado -->
{#if config}
{@const horarioEsperado = registro.tipo === 'entrada' ? config.horarioEntrada : config.horarioRetornoAlmoco}
{@const [horaEsperada, minutoEsperado] = horarioEsperado.split(':').map(Number)}
{@const minutosEsperados = horaEsperada * 60 + minutoEsperado}
{@const minutosRegistrados = registro.hora * 60 + registro.minuto}
{@const diferenca = minutosRegistrados - minutosEsperados}
{@const diferencaAbs = Math.abs(diferenca)}
{@const diferencaTexto = diferencaAbs >= 60
? `${Math.floor(diferencaAbs / 60)}h ${diferencaAbs % 60}min`
: `${diferencaAbs}min`}
<div class="flex items-center gap-2 text-xs mb-3">
<span class="text-base-content/50">Esperado:</span>
<span class="font-semibold">{horarioEsperado}</span>
{#if diferencaAbs > 0}
<span class="badge badge-xs {diferenca > 0 ? 'badge-warning' : 'badge-info'}">
{diferenca > 0 ? '+' : '-'}{diferencaTexto}
</span>
{/if}
</div>
{/if}
{#if registro.justificativa}
<div class="mt-2 rounded-lg bg-base-300/50 p-2 text-xs mb-3">
<p class="font-semibold opacity-70 mb-1">Justificativa:</p>
<p class="text-base-content/80">{registro.justificativa}</p>
</div>
{/if}
<button
class="btn btn-sm btn-outline btn-primary gap-2 w-full"
onclick={() => imprimirComprovante(registro._id)}
title="Imprimir Comprovante"
>
<Printer class="h-4 w-4" />
Imprimir Comprovante
</button>
</div>
</div>
</div>
{/each}
<!-- Mostrar horários esperados que não foram registrados -->
{#if config}
{#each [
{ tipo: 'entrada', horario: config.horarioEntrada, label: config.nomeEntrada || 'Entrada 1' },
{ tipo: 'retorno_almoco', horario: config.horarioRetornoAlmoco, label: config.nomeRetornoAlmoco || 'Entrada 2' }
] as horarioEsperado}
{#if !registrosOrdenados.find(r => r.tipo === horarioEsperado.tipo)}
<div class="relative opacity-50">
<div class="absolute right-0 top-6 w-full h-0.5 bg-base-300/30 border-dashed" style="width: calc(100% - 0.5rem);"></div>
<div class="card bg-base-200/50 border border-dashed border-base-300">
<div class="card-body p-3">
<p class="text-xs text-base-content/50 mb-1">{horarioEsperado.label} (não registrado)</p>
<p class="text-xl font-bold text-base-content/40">{horarioEsperado.horario}</p>
</div>
</div>
</div>
{/if}
{/each}
{/if}
</div>
<!-- Coluna Saída -->
<div class="space-y-4 pl-2">
<div class="sticky top-0 z-10 bg-base-100 pb-3 mb-2 border-b border-secondary/20">
<h4 class="text-lg font-bold text-secondary text-center flex items-center justify-center gap-2">
<LogOut class="h-5 w-5" />
Saídas
</h4>
</div>
{#each registrosOrdenados.filter(r => r.tipo === 'saida_almoco' || r.tipo === 'saida') as registro (registro._id)}
<div class="relative">
<!-- Linha horizontal conectando à timeline -->
<div class="absolute left-0 top-6 w-full h-0.5 bg-base-300/50" style="width: calc(100% - 0.5rem);"></div>
<!-- Card do registro -->
<div class="card {registro.dentroDoPrazo ? 'bg-success/5 border-success/30' : 'bg-error/5 border-error/30'} border-2 shadow-md hover:shadow-lg transition-all">
<div class="card-body p-4">
<!-- Tipo de registro e status -->
<div class="flex items-center gap-2 mb-2 justify-end">
<span class="text-sm font-semibold text-base-content/80">
{config
? getTipoRegistroLabel(registro.tipo, {
nomeSaidaAlmoco: config.nomeSaidaAlmoco,
nomeSaida: config.nomeSaida,
})
: getTipoRegistroLabel(registro.tipo)}
</span>
{#if registro.dentroDoPrazo}
<CheckCircle2 class="h-4 w-4 text-success flex-shrink-0" />
{:else}
<XCircle class="h-4 w-4 text-error flex-shrink-0" />
{/if}
</div>
<!-- Horário registrado -->
<p class="text-3xl font-bold text-secondary mb-1 text-right">
{formatarHoraPonto(registro.hora, registro.minuto)}
</p>
<!-- Comparação com horário esperado -->
{#if config}
{@const horarioEsperado = registro.tipo === 'saida_almoco' ? config.horarioSaidaAlmoco : config.horarioSaida}
{@const [horaEsperada, minutoEsperado] = horarioEsperado.split(':').map(Number)}
{@const minutosEsperados = horaEsperada * 60 + minutoEsperado}
{@const minutosRegistrados = registro.hora * 60 + registro.minuto}
{@const diferenca = minutosRegistrados - minutosEsperados}
{@const diferencaAbs = Math.abs(diferenca)}
{@const diferencaTexto = diferencaAbs >= 60
? `${Math.floor(diferencaAbs / 60)}h ${diferencaAbs % 60}min`
: `${diferencaAbs}min`}
<div class="flex items-center gap-2 text-xs mb-3 justify-end">
{#if diferencaAbs > 0}
<span class="badge badge-xs {diferenca > 0 ? 'badge-warning' : 'badge-info'}">
{diferenca > 0 ? '+' : '-'}{diferencaTexto}
</span>
{/if}
<span class="font-semibold">{horarioEsperado}</span>
<span class="text-base-content/50">Esperado:</span>
</div>
{/if}
{#if registro.justificativa}
<div class="mt-2 rounded-lg bg-base-300/50 p-2 text-xs mb-3">
<p class="font-semibold opacity-70 mb-1">Justificativa:</p>
<p class="text-base-content/80">{registro.justificativa}</p>
</div>
{/if}
<button
class="btn btn-sm btn-outline btn-primary gap-2 w-full"
onclick={() => imprimirComprovante(registro._id)}
title="Imprimir Comprovante"
>
<Printer class="h-4 w-4" />
Imprimir Comprovante
</button>
</div>
</div>
</div>
{/each}
<!-- Mostrar horários esperados que não foram registrados -->
{#if config}
{#each [
{ tipo: 'saida_almoco', horario: config.horarioSaidaAlmoco, label: config.nomeSaidaAlmoco || 'Saída 1' },
{ tipo: 'saida', horario: config.horarioSaida, label: config.nomeSaida || 'Saída 2' }
] as horarioEsperado}
{#if !registrosOrdenados.find(r => r.tipo === horarioEsperado.tipo)}
<div class="relative opacity-50">
<div class="absolute left-0 top-6 w-full h-0.5 bg-base-300/30 border-dashed" style="width: calc(100% - 0.5rem);"></div>
<div class="card bg-base-200/50 border border-dashed border-base-300">
<div class="card-body p-3">
<p class="text-xs text-base-content/50 mb-1 text-right">{horarioEsperado.label} (não registrado)</p>
<p class="text-xl font-bold text-base-content/40 text-right">{horarioEsperado.horario}</p>
</div>
</div>
</div>
{/if}
{/each}
{/if}
</div>
</div>
</div>
</div>
</div>
</div>
{/if}
<!-- Modal Webcam -->
{#if mostrandoWebcam}
<div
bind:this={modalRef}
class="modal modal-open"
style="display: flex; align-items: center; justify-content: center; position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 9999;"
>
<div class="modal-box max-w-2xl w-[95%] max-h-[90vh] overflow-y-auto relative" style="margin: auto; position: relative;">
<div class="sticky top-0 bg-base-100 z-10 pb-3 mb-4 border-b border-base-300 -mx-6 px-6">
<h3 class="text-lg font-bold">Capturar Foto</h3>
</div>
<div class="min-h-[200px] flex items-center justify-center py-4">
<WebcamCapture
onCapture={handleWebcamCapture}
onCancel={handleWebcamCancel}
onError={handleWebcamError}
autoCapture={false}
fotoObrigatoria={true}
/>
</div>
</div>
<form
method="dialog"
class="modal-backdrop"
onsubmit={(e) => {
e.preventDefault();
handleWebcamCancel();
}}
>
<button type="submit" aria-label="Fechar modal">fechar</button>
</form>
</div>
{/if}
<!-- Modal de Confirmação -->
{#if mostrandoModalConfirmacao && imagemCapturada && dataHoraAtual}
<div class="modal modal-open" style="display: flex; align-items: center; justify-content: center;">
<div class="modal-box max-w-3xl w-[95%] max-h-[85vh] overflow-hidden flex flex-col" style="margin: auto; max-height: 85vh;">
<!-- Header fixo -->
<div class="flex items-center justify-between mb-6 pb-4 border-b border-base-300 flex-shrink-0">
<div class="flex items-center gap-3">
<div class="p-2 bg-primary/10 rounded-lg">
<Clock class="h-6 w-6 text-primary" strokeWidth={2} />
</div>
<div>
<h3 class="font-bold text-xl text-base-content">Confirmar Registro de Ponto</h3>
<p class="text-sm text-base-content/70">Verifique as informações antes de confirmar</p>
</div>
</div>
<button
class="btn btn-sm btn-circle btn-ghost hover:bg-base-300"
onclick={cancelarRegistro}
disabled={registrando}
>
<XCircle class="h-5 w-5" />
</button>
</div>
<!-- Conteúdo com rolagem -->
<div class="flex-1 overflow-y-auto pr-2 space-y-6">
<!-- Card da Imagem -->
<div class="card bg-gradient-to-br from-base-200 to-base-300 shadow-lg border-2 border-primary/20">
<div class="card-body p-6">
<div class="flex items-center gap-2 mb-4">
<div class="p-1.5 bg-primary/10 rounded-lg">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 text-primary"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</div>
<h4 class="font-semibold text-lg">Foto Capturada</h4>
</div>
<div class="flex justify-center bg-base-100 rounded-xl p-4 border-2 border-primary/30">
<img
src={URL.createObjectURL(imagemCapturada)}
alt="Foto capturada do registro de ponto"
class="max-w-full max-h-[250px] rounded-lg shadow-md object-contain"
/>
</div>
</div>
</div>
<!-- Card de Informações -->
<div class="card bg-gradient-to-br from-primary/5 to-primary/10 shadow-lg border-2 border-primary/20">
<div class="card-body p-6">
<div class="flex items-center gap-2 mb-4">
<div class="p-1.5 bg-primary/20 rounded-lg">
<CheckCircle2 class="h-5 w-5 text-primary" strokeWidth={2} />
</div>
<h4 class="font-semibold text-lg">Informações do Registro</h4>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Tipo de Registro -->
<div class="bg-base-100 rounded-lg p-4 border border-base-300 shadow-sm">
<p class="text-sm font-medium text-base-content/70 mb-1">Tipo de Registro</p>
<p class="text-lg font-bold text-primary">{tipoLabel}</p>
</div>
<!-- Data -->
<div class="bg-base-100 rounded-lg p-4 border border-base-300 shadow-sm">
<p class="text-sm font-medium text-base-content/70 mb-1">Data</p>
<p class="text-lg font-bold text-base-content">{dataHoraAtual.data}</p>
</div>
<!-- Hora -->
<div class="bg-base-100 rounded-lg p-4 border border-base-300 shadow-sm">
<p class="text-sm font-medium text-base-content/70 mb-1">Horário</p>
<p class="text-lg font-bold text-base-content">{dataHoraAtual.hora}</p>
</div>
<!-- Status -->
<div class="bg-base-100 rounded-lg p-4 border border-base-300 shadow-sm">
<p class="text-sm font-medium text-base-content/70 mb-1">Status</p>
<div class="badge badge-success badge-lg gap-2">
<CheckCircle2 class="h-4 w-4" />
Pronto para Registrar
</div>
</div>
</div>
<!-- Justificativa (se houver) -->
{#if justificativa.trim()}
<div class="mt-4 bg-base-100 rounded-lg p-4 border border-base-300 shadow-sm">
<p class="text-sm font-medium text-base-content/70 mb-2">Justificativa</p>
<p class="text-base text-base-content whitespace-pre-wrap">{justificativa}</p>
</div>
{/if}
</div>
</div>
<!-- Aviso -->
<div class="alert alert-info shadow-lg">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<div>
<h3 class="font-bold">Confirme os dados</h3>
<div class="text-sm">
Verifique se a foto, data e horário estão corretos antes de confirmar o registro.
</div>
</div>
</div>
</div>
<!-- Footer fixo com botões -->
<div class="flex justify-end gap-3 pt-4 mt-4 border-t border-base-300 flex-shrink-0">
<button
class="btn btn-outline btn-lg"
onclick={cancelarRegistro}
disabled={registrando}
>
<XCircle class="h-5 w-5" />
Cancelar
</button>
<button
class="btn btn-primary btn-lg gap-2"
onclick={confirmarRegistro}
disabled={registrando}
>
{#if registrando}
<span class="loading loading-spinner loading-sm"></span>
Registrando...
{:else}
<CheckCircle2 class="h-5 w-5" />
Confirmar Registro
{/if}
</button>
</div>
</div>
<div class="modal-backdrop" onclick={cancelarRegistro}></div>
</div>
{/if}
<!-- Modal Comprovante -->
{#if mostrandoComprovante && registroId}
<ComprovantePonto {registroId} onClose={fecharComprovante} />
{/if}
<!-- Modal de Erro -->
<ErrorModal
open={mostrarModalErro}
title={mensagemErroModal || 'Erro ao registrar ponto'}
message={detalhesErroModal || mensagemErroModal || 'Ocorreu um erro ao registrar o ponto. Tente novamente.'}
onClose={fecharModalErro}
/>
</div>